diff --git a/.github/workflows/alembic-head-check.yml b/.github/workflows/alembic-head-check.yml index f03b9d1f58..6605c5b945 100644 --- a/.github/workflows/alembic-head-check.yml +++ b/.github/workflows/alembic-head-check.yml @@ -7,7 +7,7 @@ on: jobs: check-multiple-heads: - if: ${{ contains(github.event.pull_request.labels.*.name, 'require:db-migration') }} + if: ${{ contains(github.event.pull_request.labels.*.name, 'require:db-migration') && github.event.pull_request.merged == false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index fcb081337f..308a87b2f5 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -4,6 +4,10 @@ on: pull_request_target: types: [labeled, unlabeled, opened, synchronize, reopened] +permissions: + issues: write + pull-requests: write + jobs: auto-label: runs-on: ubuntu-latest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index bae89cb914..117a170b97 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -57,5 +57,5 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.coverage.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 61940cca2d..843a5a8c3c 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -12,8 +12,8 @@ concurrency: jobs: lint: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} - runs-on: arc-runner-set + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') && github.event.pull_request.merged == false }} + runs-on: ubuntu-latest steps: - name: Calculate the fetch depth run: | @@ -76,13 +76,13 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.lint.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. typecheck: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} - runs-on: arc-runner-set + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') && github.event.pull_request.merged == false }} + runs-on: ubuntu-latest steps: - name: Calculate the fetch depth run: | @@ -139,12 +139,12 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.check.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. test: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') && github.event.pull_request.merged == false }} runs-on: [ubuntu-latest-8-cores] steps: - name: Calculate the fetch depth @@ -212,7 +212,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.test.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. @@ -319,7 +319,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.deploy.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. @@ -398,7 +398,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.deploy.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. build-sbom: diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 6a23043bed..5b69fde0c7 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -8,7 +8,7 @@ permissions: jobs: docs-preview-links-: - if: ${{ contains(github.event.pull_request.labels.*.name, 'area:docs') }} + if: ${{ contains(github.event.pull_request.labels.*.name, 'area:docs') && github.event.pull_request.merged == false }} runs-on: ubuntu-latest steps: - name: Make a link to the doc preview build (en) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index facb6c9f04..62a7bca62c 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -53,5 +53,5 @@ jobs: uses: actions/upload-artifact@v3 with: name: pants.test.log - path: .pants.d/pants.log + path: .pants.d/workdir/pants.log if: always() # We want the log even on failures. diff --git a/.github/workflows/timeline-check.yml b/.github/workflows/timeline-check.yml index 49e98afeea..9bc53ea9d0 100644 --- a/.github/workflows/timeline-check.yml +++ b/.github/workflows/timeline-check.yml @@ -7,7 +7,7 @@ on: jobs: pr-number-assign: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:changelog') && github.event.pull_request.number != null }} + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:changelog') && github.event.pull_request.number != null && github.event.pull_request.merged == false }} uses: ./.github/workflows/pr-number-assign.yml secrets: WORKFLOW_PAT: ${{ secrets.WORKFLOW_PAT }} diff --git a/changes/1226.feature.md b/changes/1226.feature.md new file mode 100644 index 0000000000..a87ac3b305 --- /dev/null +++ b/changes/1226.feature.md @@ -0,0 +1 @@ +Add a policy to predicate to limit the number and resources of concurrent pending sessions. diff --git a/changes/1754.feature.md b/changes/1754.feature.md new file mode 100644 index 0000000000..69bcf4f5e0 --- /dev/null +++ b/changes/1754.feature.md @@ -0,0 +1 @@ +Implement `/services/_/try` API diff --git a/changes/1803.feature.md b/changes/1803.feature.md new file mode 100644 index 0000000000..30394da936 --- /dev/null +++ b/changes/1803.feature.md @@ -0,0 +1 @@ +Add support for multi directory mkdir by fixing cli to accept multiple arguments and by adding list type annotation to accept multiple directories diff --git a/changes/1890.fix.md b/changes/1890.fix.md new file mode 100644 index 0000000000..a92ec5b287 --- /dev/null +++ b/changes/1890.fix.md @@ -0,0 +1 @@ +Fix malfunctioning CLI command `session create-from-template` by reorganizing `click.option` decorators diff --git a/changes/1896.deprecation.md b/changes/1896.deprecation.md new file mode 100644 index 0000000000..faedcdff77 --- /dev/null +++ b/changes/1896.deprecation.md @@ -0,0 +1 @@ +Remove the image importer API no longer used and unused since the release of Forklift diff --git a/changes/1927.fix.md b/changes/1927.fix.md new file mode 100644 index 0000000000..72a063d507 --- /dev/null +++ b/changes/1927.fix.md @@ -0,0 +1 @@ +Allow passing HTTP status codes via the pydantic-based API response model objects diff --git a/changes/1930.fix.md b/changes/1930.fix.md new file mode 100644 index 0000000000..03b6d39fd4 --- /dev/null +++ b/changes/1930.fix.md @@ -0,0 +1 @@ +Fix inability to download beyond 500 MB via SFTP by preventing dropbear from decreasing the trasnfer window size indefinitely, which happens with non-retrying psftp-based SFTP client implementations diff --git a/changes/1932.deps.md b/changes/1932.deps.md new file mode 100644 index 0000000000..101fccaa87 --- /dev/null +++ b/changes/1932.deps.md @@ -0,0 +1 @@ +Replace `passlib[bcrypt]` to `bcrypt` which is better maintained diff --git a/changes/1934.fix.md b/changes/1934.fix.md new file mode 100644 index 0000000000..0c907dda2b --- /dev/null +++ b/changes/1934.fix.md @@ -0,0 +1 @@ +Fix CLI `agent info` related issues by replacing `HardwareMetadata` to `dict` when class check and adding parameter to default metric value formatter. \ No newline at end of file diff --git a/changes/1935.fix.md b/changes/1935.fix.md new file mode 100644 index 0000000000..ba9b13ba7e --- /dev/null +++ b/changes/1935.fix.md @@ -0,0 +1 @@ +Change `endpoints.model` and `endpoint_tokens.endpoint` to nullable and set `ondelete="SET NULL"`. \ No newline at end of file diff --git a/changes/1938.feature.md b/changes/1938.feature.md new file mode 100644 index 0000000000..d61010f6d0 --- /dev/null +++ b/changes/1938.feature.md @@ -0,0 +1 @@ +Bump the manager API version to v8.20240315 with some big changes memo'ed in manager/server.py diff --git a/changes/1939.deps.md b/changes/1939.deps.md new file mode 100644 index 0000000000..14bbf66805 --- /dev/null +++ b/changes/1939.deps.md @@ -0,0 +1 @@ +Upgrade pyzmq and callosum version to improve malformed packet handling in manager-to-agent RPC channels diff --git a/changes/1948.feature.md b/changes/1948.feature.md new file mode 100644 index 0000000000..ca1eca1bb6 --- /dev/null +++ b/changes/1948.feature.md @@ -0,0 +1 @@ +Add new `user_resource_policies.max_session_count_per_model_session` column to limit number of maximum available sessions per each model service created by user diff --git a/changes/1950.fix.md b/changes/1950.fix.md new file mode 100644 index 0000000000..6cd6f54caa --- /dev/null +++ b/changes/1950.fix.md @@ -0,0 +1 @@ +Use `buildDate` instead of `build` to retrieve web static version to follow lablup/backend.ai-webui#2072 diff --git a/changes/1952.fix.md b/changes/1952.fix.md new file mode 100644 index 0000000000..a36f2ddf4f --- /dev/null +++ b/changes/1952.fix.md @@ -0,0 +1 @@ +Fix `endpoint.routings` GQL field showing routing ID instead of status enum diff --git a/fixtures/manager/example-users.json b/fixtures/manager/example-users.json index 050e59f7a5..56a0671e5b 100644 --- a/fixtures/manager/example-users.json +++ b/fixtures/manager/example-users.json @@ -16,7 +16,8 @@ { "name": "default", "max_vfolder_count": 0, - "max_quota_scope_size": -1 + "max_quota_scope_size": -1, + "max_session_count_per_model_session": 10 } ], "project_resource_policies": [ diff --git a/python.lock b/python.lock index 582fe8f120..33511479f1 100644 --- a/python.lock +++ b/python.lock @@ -34,9 +34,10 @@ // "attrs>=20.3", // "backend.ai-krunner-alpine==5.1.0", // "backend.ai-krunner-static-gnu==4.1.1", +// "bcrypt>=4.1.2", // "boto3~=1.26", // "cachetools~=5.2.0", -// "callosum~=1.0.1", +// "callosum~=1.0.3", // "cattrs~=22.2.0", // "click~=8.1.7", // "colorama>=0.4.4", @@ -62,7 +63,6 @@ // "namedlist~=1.8", // "networkx~=2.8.7", // "packaging>=21.3", -// "passlib[bcrypt]>=1.7.4", // "pexpect~=4.8", // "psutil~=5.9.1", // "pycryptodome>=3.14.1", @@ -72,7 +72,7 @@ // "python-dateutil>=2.8", // "python-dotenv~=0.20.0", // "python-json-logger>=2.0.1", -// "pyzmq~=24.0.1", +// "pyzmq~=25.1.2", // "redis[hiredis]==4.5.5", // "rich~=13.6", // "setproctitle~=1.3.2", @@ -98,7 +98,7 @@ // "types-tabulate", // "typing_extensions~=4.3", // "uvloop~=0.17.0; sys_platform != \"Windows\"", -// "yarl~=1.8.2", +// "yarl!=1.9.0,!=1.9.1,!=1.9.2,<2.0,>=1.8.2", // "zipstream-new~=1.1.8" // ], // "manylinux": "manylinux2014", @@ -335,21 +335,21 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "b31cbf99142ca0c93cfc180e3f32fcdca0bcab35367e1380bc70dfaaf88cdc60", - "url": "https://files.pythonhosted.org/packages/fe/0c/e1db340ac8d75af3e51236696077e399cc3dfe8ea25d2a524066bff196a9/aiohttp_sse-2.1.0-py3-none-any.whl" + "hash": "339f9803bcf4682a2060e75548760d86abe4424a0d92ba66ff4985de3bd743dc", + "url": "https://files.pythonhosted.org/packages/5a/b8/bf448b1d2dbe6cf16c3be0b92230a8f032f2f0ed159a2299284c709819c8/aiohttp_sse-2.2.0-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "dfe8f7271ab4470891fa1bfa1913d6889b3d19015dd3d3a4cab949e66971bbca", - "url": "https://files.pythonhosted.org/packages/2f/3f/cc4f5a3fe6cb50ad5b9d26bb7738c5da1f61645b517d4230df2fc32d89f0/aiohttp-sse-2.1.0.tar.gz" + "hash": "a48dd5774031d3f41a29e159ebdbb93e89c8f37c1e9e83e196296be51885a5c2", + "url": "https://files.pythonhosted.org/packages/80/df/4ddb30e689695fd91cf41c072e154061120ed166e8baf6c9a0020f27dffc/aiohttp-sse-2.2.0.tar.gz" } ], "project_name": "aiohttp-sse", "requires_dists": [ "aiohttp>=3.0" ], - "requires_python": ">=3.7", - "version": "2.1.0" + "requires_python": ">=3.8", + "version": "2.2.0" }, { "artifacts": [ @@ -936,36 +936,36 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "179cdcff2dee116ff0bbe10c21a374fff8ae0d9ea3842bd8dd2c9f69e8185d91", - "url": "https://files.pythonhosted.org/packages/15/ac/f51d13299fb897ca4314b4085ee7bc409985ec7e478adbd327102f0fe14e/boto3-1.34.43-py3-none-any.whl" + "hash": "f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c", + "url": "https://files.pythonhosted.org/packages/00/a7/b950d33d63fb80d6dbe8295641560f4697444ce89ff117e2ecaf78e95044/boto3-1.34.54-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "ed646f600b76939d54fa1ff868415793551a5a08b9de0a09696b46d116da7da5", - "url": "https://files.pythonhosted.org/packages/f5/68/2bccad434276f94f42a9331f3056eb0c0b0b36466f5179d4c43d374b6bd1/boto3-1.34.43.tar.gz" + "hash": "8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8", + "url": "https://files.pythonhosted.org/packages/f8/e9/d16f4c5614fdb2a5d12af17dc0c0517fba999fa50daa5e2e55ab1b6375e6/boto3-1.34.54.tar.gz" } ], "project_name": "boto3", "requires_dists": [ - "botocore<1.35.0,>=1.34.43", + "botocore<1.35.0,>=1.34.54", "botocore[crt]<2.0a0,>=1.21.0; extra == \"crt\"", "jmespath<2.0.0,>=0.7.1", "s3transfer<0.11.0,>=0.10.0" ], "requires_python": ">=3.8", - "version": "1.34.43" + "version": "1.34.54" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "ab7d8046a8c3326ecf3d9f9884e79aa77fed864416ed8af52b9e22ab055acf4e", - "url": "https://files.pythonhosted.org/packages/74/f8/fb598ee499f19c1532cf47a6eb34c3c20447f9f81e48bb82a017a49bab6a/botocore-1.34.43-py3-none-any.whl" + "hash": "bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5", + "url": "https://files.pythonhosted.org/packages/58/ec/9f382db663962e0ba84e706112a984287e3952c2d129aca1d86575bca202/botocore-1.34.54-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "00dea9fd602dc97420318d373079bdfcc8da34501aaa908ab98b477526bdefec", - "url": "https://files.pythonhosted.org/packages/86/13/c7e79ed15fe9fcd2b1fe9fe7eb7685a3eb9bd4346b6a3cb1c099863809dd/botocore-1.34.43.tar.gz" + "hash": "4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa", + "url": "https://files.pythonhosted.org/packages/f7/f3/797c4c19071699ce87f7e76229d56c2a79af4f4431aa84f8988bdb52a047/botocore-1.34.54.tar.gz" } ], "project_name": "botocore", @@ -977,7 +977,7 @@ "urllib3<2.1,>=1.25.4; python_version >= \"3.10\"" ], "requires_python": ">=3.8", - "version": "1.34.43" + "version": "1.34.54" }, { "artifacts": [ @@ -1001,24 +1001,23 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "09b7c7d3f697d5252cca8e200fcdc6710ed53be608905f6b8fa9ef7e04d43b7c", - "url": "https://files.pythonhosted.org/packages/48/71/0eedc7f84de70e67d286055960c3b617371e6b47072689e6ed8afd3f4599/callosum-1.0.1-py3-none-any.whl" + "hash": "2dd92ab8d86408df943510539f64d40844d755dc53799ed6a16b2312a77b9557", + "url": "https://files.pythonhosted.org/packages/d7/34/be5c545880a3fb21c74a828dde06c9c281a8769902a4f1bed373816f772d/callosum-1.0.3-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "da0f1960be20afaae95b02d4387b841d050f18f2e708a823e140777cd365ccb5", - "url": "https://files.pythonhosted.org/packages/b6/6a/441d605d34a029c4690a5ca97bd58384ea272b2708bfd72c191b5d7208db/callosum-1.0.1.tar.gz" + "hash": "381b62718accbc777c05eb8d6ae2fd98ab68fca2b5842de7ef94fdf7d8de5a38", + "url": "https://files.pythonhosted.org/packages/c6/87/af504e56c3f43d73e22385fd9b05f3c63ac08885f3958ecd03443a17761f/callosum-1.0.3.tar.gz" } ], "project_name": "callosum", "requires_dists": [ "Click>=8.0; extra == \"test\"", "attrs>=21.3.0", - "black~=23.9.1; extra == \"lint\"", "build>=1.0.3; extra == \"build\"", "codecov>=2.1; extra == \"test\"", - "msgpack>=1.0.4", - "mypy~=1.5.1; extra == \"typecheck\"", + "msgpack>=1.0.7", + "mypy~=1.8.0; extra == \"typecheck\"", "pre-commit; extra == \"dev\"", "pytest-asyncio~=0.21; extra == \"test\"", "pytest-cov>=4.0; extra == \"test\"", @@ -1026,22 +1025,22 @@ "pytest~=7.2.2; extra == \"test\"", "python-dateutil>=2.8.2", "python-snappy>=0.6.1; extra == \"snappy\"", - "pyzmq>=23.0.0; extra == \"zeromq\"", + "pyzmq>=25.1.1; extra == \"zeromq\"", "redis>=4.6.0; extra == \"redis\"", - "ruff-lsp>=0.0.38; extra == \"lint\"", - "ruff>=0.0.287; extra == \"lint\"", + "ruff-lsp>=0.0.52; extra == \"lint\"", + "ruff>=0.2.2; extra == \"lint\"", "sphinx-autodoc-typehints; extra == \"docs\"", "sphinx~=4.3; extra == \"docs\"", "temporenc>=0.1", - "thriftpy2>=0.4.16; extra == \"thrift\"", + "thriftpy2>=0.4.20; extra == \"thrift\"", "towncrier~=22.12; extra == \"build\"", "twine~=4.0; extra == \"build\"", "types-python-dateutil; extra == \"typecheck\"", "wheel>=0.41.0; extra == \"build\"", - "yarl>=1.8.2" + "yarl!=1.9.0,!=1.9.1,!=1.9.2,>=1.8.2" ], "requires_python": ">=3.11", - "version": "1.0.1" + "version": "1.0.3" }, { "artifacts": [ @@ -1290,103 +1289,103 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33", - "url": "https://files.pythonhosted.org/packages/74/bb/f5e04bb44e7bfb88bb71ecb4d60a8dffaa19262c1ebe832250ee82e06de8/cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl" + "hash": "1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30", + "url": "https://files.pythonhosted.org/packages/ca/2e/9f2c49bd6a18d46c05ec098b040e7d4599c61f50ced40a39adfae3f68306/cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl" }, { "algorithm": "sha256", - "hash": "a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1", - "url": "https://files.pythonhosted.org/packages/02/87/555b8e1b44386da3eacf5cf5a67c75e46224ca4c6213e4af152ac5941963/cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl" + "hash": "7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc", + "url": "https://files.pythonhosted.org/packages/0e/1d/62a2324882c0db89f64358dadfb95cae024ee3ba9fde3d5fd4d2f58af9f5/cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl" }, { "algorithm": "sha256", - "hash": "5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2", - "url": "https://files.pythonhosted.org/packages/0b/9a/4957ac93763929e0b5ea2222114ee86fcfcdacf915447978b3a1e5ac7323/cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl" + "hash": "6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1", + "url": "https://files.pythonhosted.org/packages/13/9e/a55763a32d340d7b06d045753c186b690e7d88780cafce5f88cb931536be/cryptography-42.0.5.tar.gz" }, { "algorithm": "sha256", - "hash": "8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529", - "url": "https://files.pythonhosted.org/packages/0c/a8/b89bbf4eba7fedba5ed0963a9ad27c3b106622bb70f7c2bfae921cad6573/cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl" + "hash": "2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da", + "url": "https://files.pythonhosted.org/packages/2c/9c/821ef6144daf80360cf6093520bf07eec7c793103ed4b1bf3fa17d2b55d8/cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl" }, { "algorithm": "sha256", - "hash": "e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888", - "url": "https://files.pythonhosted.org/packages/0f/6f/40f1b5c6bafc809dd21a9e577458ecc1d8062a7e10148d140f402b535eaa/cryptography-42.0.2.tar.gz" + "hash": "cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a", + "url": "https://files.pythonhosted.org/packages/48/c8/c0962598c43d3cff2c9d6ac66d0c612bdfb1975be8d87b8889960cf8c81d/cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl" }, { "algorithm": "sha256", - "hash": "09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589", - "url": "https://files.pythonhosted.org/packages/13/e0/529b44aac99133684311c4807d5eb7706c4acbffabd26ff1fa088ea59dad/cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl" + "hash": "e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1", + "url": "https://files.pythonhosted.org/packages/50/26/248cd8b6809635ed412159791c0d3869d8ec9dfdc57d428d500a14d425b7/cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, { "algorithm": "sha256", - "hash": "28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a", - "url": "https://files.pythonhosted.org/packages/1a/36/4f5f60d9a94d1b4be9df2a15dc3394f4435e0119e15af8de6bd7fe4118ed/cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + "hash": "b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1", + "url": "https://files.pythonhosted.org/packages/5b/3d/c3c21e3afaf43bacccc3ebf61d1a0d47cef6e2607dbba01662f6f9d8fc40/cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl" }, { "algorithm": "sha256", - "hash": "a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929", - "url": "https://files.pythonhosted.org/packages/2f/dc/74877e59e9d5f7014c833a93d4299925d3f4b0259131c930711061c3d51f/cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl" + "hash": "f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7", + "url": "https://files.pythonhosted.org/packages/64/f7/d3c83c79947cc6807e6acd3b2d9a1cbd312042777bc7eec50c869913df79/cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl" }, { "algorithm": "sha256", - "hash": "2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3", - "url": "https://files.pythonhosted.org/packages/3c/72/fb557573cebcae88c6efe3a73981181384e08408c1125a8e97a7fb3edde4/cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" + "hash": "a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7", + "url": "https://files.pythonhosted.org/packages/69/f6/630eb71f246208103ffee754b8375b6b334eeedb28620b3ae57be815eeeb/cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl" }, { "algorithm": "sha256", - "hash": "130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea", - "url": "https://files.pythonhosted.org/packages/45/82/3da127b1b75ea24f09ad85bcef5a6a7526be795eec4077663cd3ff52d19d/cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl" + "hash": "5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8", + "url": "https://files.pythonhosted.org/packages/6d/4d/f7c14c7a49e35df829e04d451a57b843208be7442c8e087250c195775be1/cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl" }, { "algorithm": "sha256", - "hash": "b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446", - "url": "https://files.pythonhosted.org/packages/5b/44/4c984f47a302236e1c76b721bfc8d407de9ab6620a9037c2de026d30c38f/cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + "hash": "c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922", + "url": "https://files.pythonhosted.org/packages/7d/bc/b6c691c960b5dcd54c5444e73af7f826e62af965ba59b6d7e9928b6489a2/cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl" }, { "algorithm": "sha256", - "hash": "44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90", - "url": "https://files.pythonhosted.org/packages/61/dd/aecb8fe565b5c90a04bd5e564d0a42eadf33596b87ab87f75d986f06480f/cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl" + "hash": "b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278", + "url": "https://files.pythonhosted.org/packages/8c/50/9185cca136596448d9cc595ae22a9bd4412ad35d812550c37c1390d54673/cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl" }, { "algorithm": "sha256", - "hash": "61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d", - "url": "https://files.pythonhosted.org/packages/9a/d8/cb66df54747a05218b9e0cfa1e7606d96b892750a173196139ac29fe3f2e/cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl" + "hash": "3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc", + "url": "https://files.pythonhosted.org/packages/c2/40/c7cb9d6819b90640ffc3c4028b28f46edc525feaeaa0d98ea23e843d446d/cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl" }, { "algorithm": "sha256", - "hash": "b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9", - "url": "https://files.pythonhosted.org/packages/af/4e/178466a513ff8c1aba7f56fafb169fe27af4c28df4b770cd2c30fa6fde5e/cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl" + "hash": "a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16", + "url": "https://files.pythonhosted.org/packages/d1/f1/fd98e6e79242d9aeaf6a5d49639a7e85f05741575af14d3f4a1d477f572e/cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl" }, { "algorithm": "sha256", - "hash": "ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4", - "url": "https://files.pythonhosted.org/packages/b1/85/11c92b74d7560cb2725653b49f54129c7284748bc56119a6dbabcaf51d05/cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + "hash": "7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e", + "url": "https://files.pythonhosted.org/packages/d4/fa/057f9d7a5364c86ccb6a4bd4e5c58920dcb66532be0cc21da3f9c7617ec3/cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, { "algorithm": "sha256", - "hash": "36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1", - "url": "https://files.pythonhosted.org/packages/b8/ca/acd576a5e2cf16448c9c31ae72c0d389a12acd58bd190bf91bb6082ffc91/cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl" + "hash": "16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d", + "url": "https://files.pythonhosted.org/packages/d8/b1/127ecb373d02db85a7a7de5093d7ac7b7714b8907d631f0591e8f002998d/cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl" }, { "algorithm": "sha256", - "hash": "3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2", - "url": "https://files.pythonhosted.org/packages/d2/f6/a506b5a7b73253c450fab89c882b635cd0038adcc8e83e9729219d68f597/cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + "hash": "b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec", + "url": "https://files.pythonhosted.org/packages/d9/f9/27dda069a9f9bfda7c75305e222d904cc2445acf5eab5c696ade57d36f1b/cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl" }, { "algorithm": "sha256", - "hash": "ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a", - "url": "https://files.pythonhosted.org/packages/ef/9f/49de69b6b55b812b492824bb1e5f4e37bb6953886c4c3fe0062be240f7e7/cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl" + "hash": "2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb", + "url": "https://files.pythonhosted.org/packages/e2/59/61b2364f2a4d3668d933531bc30d012b9b2de1e534df4805678471287d57/cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, { "algorithm": "sha256", - "hash": "701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be", - "url": "https://files.pythonhosted.org/packages/f3/cd/e76223293a9c1c668e6de1c5400276a710f8fb5c69da1b07a6e66bbb45ae/cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl" + "hash": "0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee", + "url": "https://files.pythonhosted.org/packages/e5/61/67e090a41c70ee526bd5121b1ccabab85c727574332d03326baaedea962d/cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl" }, { "algorithm": "sha256", - "hash": "55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242", - "url": "https://files.pythonhosted.org/packages/f4/06/4229967761a1daf385bdb09bcb11d3d40970a54b52e896b41f43065eecf6/cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl" + "hash": "329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4", + "url": "https://files.pythonhosted.org/packages/fb/0b/14509319a1b49858425553d2fb3808579cfdfe98c1d71a3f046c1b4e0108/cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" } ], "project_name": "cryptography", @@ -1413,7 +1412,7 @@ "sphinxcontrib-spelling>=4.0.1; extra == \"docstest\"" ], "requires_python": ">=3.7", - "version": "42.0.2" + "version": "42.0.5" }, { "artifacts": [ @@ -1572,26 +1571,31 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307", - "url": "https://files.pythonhosted.org/packages/8f/2e/cf6accf7415237d6faeeebdc7832023c90e0282aa16fd3263db0eb4715ec/future-0.18.3.tar.gz" + "hash": "929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", + "url": "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", + "url": "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz" } ], "project_name": "future", "requires_dists": [], "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,>=2.6", - "version": "0.18.3" + "version": "1.0.0" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "7634d29dcd1e101f5226a23cbc4a0c6cda6394253bf80e281d9c5c6797869c53", - "url": "https://files.pythonhosted.org/packages/ff/ce/1b4dc8b5ecdc9a99202b093729192b69301c33064d0e312fb8d9e384dbe0/google_auth-2.28.0-py2.py3-none-any.whl" + "hash": "25141e2d7a14bfcba945f5e9827f98092716e99482562f15306e5b026e21aa72", + "url": "https://files.pythonhosted.org/packages/b7/1d/f152a5f6d243b6acbb2a710ed19aa47154d678359bed995abdd9daf0cff0/google_auth-2.28.1-py2.py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "3cfc1b6e4e64797584fb53fc9bd0b7afa9b7c0dba2004fa7dcc9349e58cc3195", - "url": "https://files.pythonhosted.org/packages/22/1d/65514adf8e2fc3546f4fc7025afe00828597eb98c414ef3327867dc263c6/google-auth-2.28.0.tar.gz" + "hash": "34fc3046c257cedcf1622fc4b31fc2be7923d9b4d44973d481125ecc50d83885", + "url": "https://files.pythonhosted.org/packages/9a/15/ac42556763c08e1b1821a7e55f3a93982c50ca7f25adf8f61a01dd2ed98b/google-auth-2.28.1.tar.gz" } ], "project_name": "google-auth", @@ -1609,7 +1613,7 @@ "rsa<5,>=3.1.4" ], "requires_python": ">=3.7", - "version": "2.28.0" + "version": "2.28.1" }, { "artifacts": [ @@ -2340,35 +2344,32 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "c21d4b98fee747c130e6bc8f45c4b3199ea66bc00c12ee1f639f0aeca034d5e9", - "url": "https://files.pythonhosted.org/packages/57/e9/4368d49d3b462da16a3bac976487764a84dd85cef97232c7bd61f5bdedf3/marshmallow-3.20.2-py3-none-any.whl" + "hash": "e7997f83571c7fd476042c2c188e4ee8a78900ca5e74bd9c8097afa56624e9bd", + "url": "https://files.pythonhosted.org/packages/f5/97/6e4ddd6713bba5ede1d18f3959d7bffde38e56f7f7ae7c031c9a3d746b95/marshmallow-3.21.0-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "4c1daff273513dc5eb24b219a8035559dc573c8f322558ef85f5438ddd1236dd", - "url": "https://files.pythonhosted.org/packages/03/81/763717b3448e5d3a3906f27ab2ffedc9a495e8077946f54b8033967d29fd/marshmallow-3.20.2.tar.gz" + "hash": "20f53be28c6e374a711a16165fb22a8dc6003e3f7cda1285e3ca777b9193885b", + "url": "https://files.pythonhosted.org/packages/86/14/0dec31a81b16d39b6cfcb5ddd7e560d46dc5ea0d1d1bca0bb275a679071f/marshmallow-3.21.0.tar.gz" } ], "project_name": "marshmallow", "requires_dists": [ - "alabaster==0.7.15; extra == \"docs\"", + "alabaster==0.7.16; extra == \"docs\"", "autodocsumm==0.2.12; extra == \"docs\"", + "marshmallow[tests]; extra == \"dev\"", "packaging>=17.0", - "pre-commit<4.0,>=2.4; extra == \"dev\"", - "pre-commit<4.0,>=2.4; extra == \"lint\"", - "pytest; extra == \"dev\"", + "pre-commit~=3.5; extra == \"dev\"", "pytest; extra == \"tests\"", - "pytz; extra == \"dev\"", "pytz; extra == \"tests\"", - "simplejson; extra == \"dev\"", "simplejson; extra == \"tests\"", - "sphinx-issues==3.0.1; extra == \"docs\"", + "sphinx-issues==4.0.0; extra == \"docs\"", "sphinx-version-warning==1.1.2; extra == \"docs\"", "sphinx==7.2.6; extra == \"docs\"", "tox; extra == \"dev\"" ], "requires_python": ">=3.8", - "version": "3.20.2" + "version": "3.21.0" }, { "artifacts": [ @@ -2437,59 +2438,64 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", - "url": "https://files.pythonhosted.org/packages/4c/bc/dc184d943692671149848438fb3bed3a3de288ce7998cb91bc98f40f201b/msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl" + "hash": "24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca", + "url": "https://files.pythonhosted.org/packages/ce/39/3a1f468109c02d8c3780d0731555f05e0b332152e1ce2582c2f97ab9ff25/msgpack-1.0.8-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", + "url": "https://files.pythonhosted.org/packages/08/4c/17adf86a8fbb02c144c7569dc4919483c01a2ac270307e2d59e1ce394087/msgpack-1.0.8.tar.gz" }, { "algorithm": "sha256", - "hash": "730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", - "url": "https://files.pythonhosted.org/packages/15/56/a677cd761a2cefb2e3ffe7e684633294dccb161d78e8ea6da9277e45b4a2/msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl" + "hash": "e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b", + "url": "https://files.pythonhosted.org/packages/17/29/7f3f30dd40bf1c2599350099645d3664b3aadb803583cbfce57a28047c4d/msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl" }, { "algorithm": "sha256", - "hash": "5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", - "url": "https://files.pythonhosted.org/packages/26/a5/78a7d87f5f8ffe4c32167afa15d4957db649bab4822f909d8d765339bbab/msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + "hash": "5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba", + "url": "https://files.pythonhosted.org/packages/1a/01/01a88f7971c68037dab4be2737b50e00557bbdaf179ab988803c736043ed/msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, { "algorithm": "sha256", - "hash": "3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", - "url": "https://files.pythonhosted.org/packages/6d/74/bd02044eb628c7361ad2bd8c1a6147af5c6c2bbceb77b3b1da20f4a8a9c5/msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + "hash": "9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836", + "url": "https://files.pythonhosted.org/packages/3e/0e/96477b0448c593cc5c679e855c7bb58bb6543a065760e67cad0c3f90deb1/msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl" }, { "algorithm": "sha256", - "hash": "572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", - "url": "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz" + "hash": "dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a", + "url": "https://files.pythonhosted.org/packages/43/7c/82b729d105dae9f8be500228fdd8cfc1f918a18e285afcbf6d6915146037/msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl" }, { "algorithm": "sha256", - "hash": "f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", - "url": "https://files.pythonhosted.org/packages/d4/53/698c10913947f97f6fe7faad86a34e6aa1b66cea2df6f99105856bd346d9/msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl" + "hash": "d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad", + "url": "https://files.pythonhosted.org/packages/46/ca/96051d40050cd17bf054996662dbf8900da9995fa0a3308f2597a47bedad/msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl" }, { "algorithm": "sha256", - "hash": "6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", - "url": "https://files.pythonhosted.org/packages/df/09/dee50913ba5cc047f7fd7162f09453a676e7935c84b3bf3a398e12108677/msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + "hash": "e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce", + "url": "https://files.pythonhosted.org/packages/dd/06/adb6c8cdea18f9ba09b7dc1442b50ce222858ae4a85703420349784429d0/msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl" }, { "algorithm": "sha256", - "hash": "84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", - "url": "https://files.pythonhosted.org/packages/f5/3f/9730c6cb574b15d349b80cd8523a7df4b82058528339f952ea1c32ac8a10/msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl" + "hash": "3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b", + "url": "https://files.pythonhosted.org/packages/e0/3f/978df03be94c2198be22df5d6e31b69ef7a9759c6cc0cce4ed1d08e2b27b/msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl" }, { "algorithm": "sha256", - "hash": "85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", - "url": "https://files.pythonhosted.org/packages/f5/4e/1ab4a982cbd90f988e49f849fc1212f2c04a59870c59daabf8950617e2aa/msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl" + "hash": "1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950", + "url": "https://files.pythonhosted.org/packages/f5/9a/88388f7960930a7dc0bbcde3d1db1bd543c9645483f3172c64853f4cab67/msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" }, { "algorithm": "sha256", - "hash": "576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", - "url": "https://files.pythonhosted.org/packages/f9/b3/309de40dc7406b7f3492332c5ee2b492a593c2a9bb97ea48ebf2f5279999/msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl" + "hash": "83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85", + "url": "https://files.pythonhosted.org/packages/f6/f0/a7bdb48223cd21b9abed814b08fca8fe6a40931e70ec97c24d2f15d68ef3/msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" } ], "project_name": "msgpack", "requires_dists": [], "requires_python": ">=3.8", - "version": "1.0.7" + "version": "1.0.8" }, { "artifacts": [ @@ -2690,31 +2696,6 @@ "requires_python": ">=3.7", "version": "23.2" }, - { - "artifacts": [ - { - "algorithm": "sha256", - "hash": "aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", - "url": "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl" - }, - { - "algorithm": "sha256", - "hash": "defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", - "url": "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz" - } - ], - "project_name": "passlib", - "requires_dists": [ - "argon2-cffi>=18.2.0; extra == \"argon2\"", - "bcrypt>=3.1.0; extra == \"bcrypt\"", - "cloud-sptheme>=1.10.1; extra == \"build-docs\"", - "cryptography; extra == \"totp\"", - "sphinx>=1.6; extra == \"build-docs\"", - "sphinxcontrib-fulltoc>=1.2.0; extra == \"build-docs\"" - ], - "requires_python": null, - "version": "1.7.4" - }, { "artifacts": [ { @@ -2863,24 +2844,6 @@ "requires_python": null, "version": "0.7.0" }, - { - "artifacts": [ - { - "algorithm": "sha256", - "hash": "607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", - "url": "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl" - }, - { - "algorithm": "sha256", - "hash": "51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "url": "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz" - } - ], - "project_name": "py", - "requires_dists": [], - "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7", - "version": "1.11.0" - }, { "artifacts": [ { @@ -3192,35 +3155,34 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6", - "url": "https://files.pythonhosted.org/packages/c7/10/727155d44c5e04bb08e880668e53079547282e4f950535234e5a80690564/pytest-8.0.0-py3-none-any.whl" + "hash": "ee32db7af8de4629a455806befa90559f307424c07b8413ccfc30bf5b221dd7e", + "url": "https://files.pythonhosted.org/packages/5a/4a/3f626e3974bea1e6d471bd86f7965c67cd06d5770d1fec9aae445c44da7b/pytest-8.1.0-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c", - "url": "https://files.pythonhosted.org/packages/50/fd/af2d835eed57448960c4e7e9ab76ee42f24bcdd521e967191bc26fa2dece/pytest-8.0.0.tar.gz" + "hash": "f8fa04ab8f98d185113ae60ea6d79c22f8143b14bc1caeced44a0ab844928323", + "url": "https://files.pythonhosted.org/packages/3e/71/ad447fd2f816a01e6e1c60771232fcab50e8a3069f115d885c8ba87152da/pytest-8.1.0.tar.gz" } ], "project_name": "pytest", "requires_dists": [ "argcomplete; extra == \"testing\"", - "attrs>=19.2.0; extra == \"testing\"", + "attrs>=19.2; extra == \"testing\"", "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", "hypothesis>=3.56; extra == \"testing\"", "iniconfig", "mock; extra == \"testing\"", - "nose; extra == \"testing\"", "packaging", - "pluggy<2.0,>=1.3.0", + "pluggy<2.0,>=1.4", "pygments>=2.7.2; extra == \"testing\"", "requests; extra == \"testing\"", "setuptools; extra == \"testing\"", - "tomli>=1.0.0; python_version < \"3.11\"", + "tomli>=1; python_version < \"3.11\"", "xmlschema; extra == \"testing\"" ], "requires_python": ">=3.8", - "version": "8.0.0" + "version": "8.1.0" }, { "artifacts": [ @@ -3242,13 +3204,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", - "url": "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl" + "hash": "a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", + "url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "url": "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz" + "hash": "37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "url": "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz" } ], "project_name": "python-dateutil", @@ -3256,7 +3218,7 @@ "six>=1.5" ], "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7", - "version": "2.8.2" + "version": "2.9.0.post0" }, { "artifacts": [ @@ -3361,57 +3323,61 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "a180dbd5ea5d47c2d3b716d5c19cc3fb162d1c8db93b21a1295d69585bfddac1", - "url": "https://files.pythonhosted.org/packages/cb/e1/6441995e97a3fdd7cb9c85791d95a4f902b328fe5ef295f9fb3b039facdf/pyzmq-24.0.1-cp311-cp311-musllinux_1_1_x86_64.whl" + "hash": "9880078f683466b7f567b8624bfc16cad65077be046b6e8abb53bed4eeb82dd3", + "url": "https://files.pythonhosted.org/packages/1d/6d/0cbd8dd5b8979fd6b9cf1852ed067b9d2cd6fa0c09c3bafe6874d2d2e03c/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_x86_64.whl" }, { "algorithm": "sha256", - "hash": "7abddb2bd5489d30ffeb4b93a428130886c171b4d355ccd226e83254fcb6b9ef", - "url": "https://files.pythonhosted.org/packages/0c/5e/d6966d962f9dfe8c98fd1b182e63cc5379c89a586fc83387bece3eeef5e0/pyzmq-24.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + "hash": "4e6f689880d5ad87918430957297c975203a082d9a036cc426648fcbedae769b", + "url": "https://files.pythonhosted.org/packages/14/9b/341cdfb47440069010101403298dc24d449150370c6cb322e73bfa1949bd/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_aarch64.whl" }, { "algorithm": "sha256", - "hash": "216f5d7dbb67166759e59b0479bca82b8acf9bed6015b526b8eb10143fb08e77", - "url": "https://files.pythonhosted.org/packages/46/0d/b06cf99a64d4187632f4ac9ddf6be99cd35de06fe72d75140496a8e0eef5/pyzmq-24.0.1.tar.gz" + "hash": "d1299d7e964c13607efd148ca1f07dcbf27c3ab9e125d1d0ae1d580a1682399d", + "url": "https://files.pythonhosted.org/packages/18/d1/b3d1e985318ed7287737ea9e6b6e21748cc7c89accc2443347cd2c8d5f0f/pyzmq-25.1.2-cp311-cp311-manylinux_2_28_x86_64.whl" }, { "algorithm": "sha256", - "hash": "ccb94342d13e3bf3ffa6e62f95b5e3f0bc6bfa94558cb37f4b3d09d6feb536ff", - "url": "https://files.pythonhosted.org/packages/47/68/3d75e48e898034e878ba99729715f4e92c6b82d2f36fe5c033c60c7ac9b0/pyzmq-24.0.1-cp311-cp311-musllinux_1_1_aarch64.whl" + "hash": "82544e0e2d0c1811482d37eef297020a040c32e0687c1f6fc23a75b75db8062c", + "url": "https://files.pythonhosted.org/packages/35/de/7579518bc58cebf92568b48e354a702fb52525d0fab166dc544f2a0615dc/pyzmq-25.1.2-cp311-cp311-macosx_10_15_universal2.whl" }, { "algorithm": "sha256", - "hash": "8242543c522d84d033fe79be04cb559b80d7eb98ad81b137ff7e0a9020f00ace", - "url": "https://files.pythonhosted.org/packages/52/c5/ca38e710e645de5bec3425706d37be6fb9c977f604d81526276b2418c08b/pyzmq-24.0.1-cp311-cp311-manylinux_2_28_x86_64.whl" + "hash": "93f1aa311e8bb912e34f004cf186407a4e90eec4f0ecc0efd26056bf7eda0226", + "url": "https://files.pythonhosted.org/packages/3a/33/1a3683fc9a4bd64d8ccc0290da75c8f042184a1a49c146d28398414d3341/pyzmq-25.1.2.tar.gz" }, { "algorithm": "sha256", - "hash": "838812c65ed5f7c2bd11f7b098d2e5d01685a3f6d1f82849423b570bae698c00", - "url": "https://files.pythonhosted.org/packages/73/49/6d80a45d8ff25a7e30752c49a4a73a0642341b2364aa993473b5a0f4e003/pyzmq-24.0.1-cp311-cp311-macosx_10_15_universal2.whl" + "hash": "3e124e6b1dd3dfbeb695435dff0e383256655bb18082e094a8dd1f6293114642", + "url": "https://files.pythonhosted.org/packages/77/b7/8cee519b11bdd3f76c1a6eb537ab13c1bfef2964d725717705c86f524e4c/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl" }, { "algorithm": "sha256", - "hash": "94010bd61bc168c103a5b3b0f56ed3b616688192db7cd5b1d626e49f28ff51b3", - "url": "https://files.pythonhosted.org/packages/8e/3e/f27100244e96d78c9cfafa1cb457be760f756abd700ac6790f97a6eef271/pyzmq-24.0.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl" + "hash": "7598d2ba821caa37a0f9d54c25164a4fa351ce019d64d0b44b45540950458840", + "url": "https://files.pythonhosted.org/packages/b6/1d/c35a956a44b333b064ae1b1c588c2dfa0e01b7ec90884c1972bfcef119c3/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, { "algorithm": "sha256", - "hash": "dfb992dbcd88d8254471760879d48fb20836d91baa90f181c957122f9592b3dc", - "url": "https://files.pythonhosted.org/packages/93/03/edb302ae50adfbed34570c8d3c251fe71c57f9d42a4fc086152f1a390415/pyzmq-24.0.1-cp311-cp311-macosx_10_9_x86_64.whl" + "hash": "bc69c96735ab501419c432110016329bf0dea8898ce16fab97c6d9106dc0b348", + "url": "https://files.pythonhosted.org/packages/bc/4a/ac6469c01813cb3652ab4e30ec4a37815cc9949afc18af33f64e2ec704aa/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, { "algorithm": "sha256", - "hash": "6640f83df0ae4ae1104d4c62b77e9ef39be85ebe53f636388707d532bee2b7b8", - "url": "https://files.pythonhosted.org/packages/a8/bc/1d5ffcba5f33ddb321645add42f76bfdb66e529797cad05c551186a81362/pyzmq-24.0.1-cp311-cp311-musllinux_1_1_i686.whl" + "hash": "01171fc48542348cd1a360a4b6c3e7d8f46cdcf53a8d40f84db6707a6768acc1", + "url": "https://files.pythonhosted.org/packages/ce/f9/58b6cc9a110b1832f666fa6b5a67dc4d520fabfc680ca87a8167b2061d5d/pyzmq-25.1.2-cp311-cp311-macosx_10_9_x86_64.whl" + }, + { + "algorithm": "sha256", + "hash": "cc69949484171cc961e6ecd4a8911b9ce7a0d1f738fcae717177c231bf77437b", + "url": "https://files.pythonhosted.org/packages/fa/52/c6d4e76e020c554e965459d41a98201b4d45277a288648f53a4e5a2429cc/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_i686.whl" } ], "project_name": "pyzmq", "requires_dists": [ - "cffi; implementation_name == \"pypy\"", - "py; implementation_name == \"pypy\"" + "cffi; implementation_name == \"pypy\"" ], "requires_python": ">=3.6", - "version": "24.0.1" + "version": "25.1.2" }, { "artifacts": [ @@ -3510,13 +3476,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235", - "url": "https://files.pythonhosted.org/packages/be/be/1520178fa01eabe014b16e72a952b9f900631142ccd03dc36cf93e30c1ce/rich-13.7.0-py3-none-any.whl" + "hash": "4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", + "url": "https://files.pythonhosted.org/packages/87/67/a37f6214d0e9fe57f6ae54b2956d550ca8365857f42a1ce0392bb21d9410/rich-13.7.1-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", - "url": "https://files.pythonhosted.org/packages/a7/ec/4a7d80728bd429f7c0d4d51245287158a1516315cadbb146012439403a9d/rich-13.7.0.tar.gz" + "hash": "9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", + "url": "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz" } ], "project_name": "rich", @@ -3527,7 +3493,7 @@ "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"" ], "requires_python": ">=3.7.0", - "version": "13.7.0" + "version": "13.7.1" }, { "artifacts": [ @@ -3639,13 +3605,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6", - "url": "https://files.pythonhosted.org/packages/bb/0a/203797141ec9727344c7649f6d5f6cf71b89a6c28f8f55d4f18de7a1d352/setuptools-69.1.0-py3-none-any.whl" + "hash": "02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56", + "url": "https://files.pythonhosted.org/packages/c0/7a/3da654f49c95d0cc6e9549a855b5818e66a917e852ec608e77550c8dc08b/setuptools-69.1.1-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401", - "url": "https://files.pythonhosted.org/packages/c9/3d/74c56f1c9efd7353807f8f5fa22adccdba99dc72f34311c30a69627a0fad/setuptools-69.1.0.tar.gz" + "hash": "5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8", + "url": "https://files.pythonhosted.org/packages/c8/1f/e026746e5885a83e1af99002ae63650b7c577af5c424d4c27edcf729ab44/setuptools-69.1.1.tar.gz" } ], "project_name": "setuptools", @@ -3664,7 +3630,8 @@ "jaraco.path>=3.2.0; extra == \"testing\"", "jaraco.path>=3.2.0; extra == \"testing-integration\"", "jaraco.tidelift>=1.4; extra == \"docs\"", - "packaging>=23.1; extra == \"testing-integration\"", + "packaging>=23.2; extra == \"testing\"", + "packaging>=23.2; extra == \"testing-integration\"", "pip>=19.1; extra == \"testing\"", "pygments-github-lexers==0.0.5; extra == \"docs\"", "pytest-checkdocs>=2.4; extra == \"testing\"", @@ -3697,7 +3664,7 @@ "wheel; extra == \"testing-integration\"" ], "requires_python": ">=3.8", - "version": "69.1.0" + "version": "69.1.1" }, { "artifacts": [ @@ -3871,13 +3838,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "c25c8d5f462ca169fa50add10f4d3604d98409b6a9f8dadff6a269cc7027516c", - "url": "https://files.pythonhosted.org/packages/14/7b/736c5945c3e41fcf331f360dcadbd83448f54c1aa44bd9f23fe4c5c6d63d/textual-0.51.0-py3-none-any.whl" + "hash": "960a19df2319482918b4a58736d9552cdc1ab65d170ba0bc15273ce0e1922b7a", + "url": "https://files.pythonhosted.org/packages/8a/f0/ab4e1045af86f051ebcb64b964b00b3b52a1c99304f357dd2ea0af3ed1a4/textual-0.52.1-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "ca3d58c00a360ef1988a9be2dbb34d8a8526f2b9fe40c2ed7ac6687875422efd", - "url": "https://files.pythonhosted.org/packages/64/b3/d33af0cacb5d8838e65b9d591ce5e47a063e1a0eba736568f3c222aa004f/textual-0.51.0.tar.gz" + "hash": "4232e5c2b423ed7c63baaeb6030355e14e1de1b9df096c9655b68a1e60e4de5f", + "url": "https://files.pythonhosted.org/packages/bb/ce/b224ccc05260871da8df640e7cd8ca0a5e38721fddb6733650195402841e/textual-0.52.1.tar.gz" } ], "project_name": "textual", @@ -3889,7 +3856,7 @@ "typing-extensions<5.0.0,>=4.4.0" ], "requires_python": "<4.0,>=3.8", - "version": "0.51.0" + "version": "0.52.1" }, { "artifacts": [ @@ -4173,13 +4140,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "24a255458b5b8a7fca8139cf56f2a8ad5a4f1a5f711b73a5bb9cb50dc688fab5", - "url": "https://files.pythonhosted.org/packages/98/57/71e684aca84e553bff28298932541218e9fff1d99849f1875ff55a4ba4fe/types_pyOpenSSL-24.0.0.20240130-py3-none-any.whl" + "hash": "a472cf877a873549175e81972f153f44e975302a3cf17381eb5f3d41ccfb75a4", + "url": "https://files.pythonhosted.org/packages/3b/be/90df9b4654cd43344da7ca6cf003d3b5c710b49bb89658372d13cdda686e/types_pyOpenSSL-24.0.0.20240228-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "c812e5c1c35249f75ef5935708b2a997d62abf9745be222e5f94b9595472ab25", - "url": "https://files.pythonhosted.org/packages/dd/0f/48f415fbb620610d03abfec01172b44094eb04c234273846060134f107a0/types-pyOpenSSL-24.0.0.20240130.tar.gz" + "hash": "cd990717d8aa3743ef0e73e0f462e64b54d90c304249232d48fece4f0f7c3c6a", + "url": "https://files.pythonhosted.org/packages/01/d3/8e3f365204734e4772cc264f6c933ef6a261450bf1ea5172d5bbec8e634f/types-pyOpenSSL-24.0.0.20240228.tar.gz" } ], "project_name": "types-pyopenssl", @@ -4187,7 +4154,7 @@ "cryptography>=35.0.0" ], "requires_python": ">=3.8", - "version": "24.0.0.20240130" + "version": "24.0.0.20240228" }, { "artifacts": [ @@ -4229,13 +4196,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "912de6507b631934bd225cdac310b04a58def94391003ba83939e5a10e99568d", - "url": "https://files.pythonhosted.org/packages/d1/a1/d4b177e9dbcdca2f6e881bbe255a2d3880f534eb64416bb27d17ee6c98e0/types_redis-4.6.0.20240106-py3-none-any.whl" + "hash": "dc9c45a068240e33a04302aec5655cf41e80f91eecffccbb2df215b2f6fc375d", + "url": "https://files.pythonhosted.org/packages/36/55/db25993603a9b3bcc1f7ab7e4cedb105b5b7fd2307f226eb67740ce82d73/types_redis-4.6.0.20240218-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "2b2fa3a78f84559616242d23f86de5f4130dfd6c3b83fb2d8ce3329e503f756e", - "url": "https://files.pythonhosted.org/packages/62/c0/696140b04c5f61ddb28f0a398093952d72e205b565fd8e9a286b235f2e41/types-redis-4.6.0.20240106.tar.gz" + "hash": "5103d7e690e5c74c974a161317b2d59ac2303cf8bef24175b04c2a4c3486cb39", + "url": "https://files.pythonhosted.org/packages/82/b6/be2f938dfbe879ce07671f02b5b331426671168cbf03343c078988fce481/types-redis-4.6.0.20240218.tar.gz" } ], "project_name": "types-redis", @@ -4244,43 +4211,43 @@ "types-pyOpenSSL" ], "requires_python": ">=3.8", - "version": "4.6.0.20240106" + "version": "4.6.0.20240218" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "7b450d7a853f40488ae13630d9fa29abc10458e5b2d81e9534f34a842b38fd65", - "url": "https://files.pythonhosted.org/packages/ed/66/66f59cdc7adc46afcc708635f2cc7b9b74f09c54d070569e52a491befc95/types_setuptools-69.1.0.20240215-py3-none-any.whl" + "hash": "99c1053920a6fa542b734c9ad61849c3993062f80963a4034771626528e192a0", + "url": "https://files.pythonhosted.org/packages/77/c8/1d97ba3731a7d5ae346b2c5b9c1445dc8954e9f8c59acb092b9124494eb4/types_setuptools-69.1.0.20240302-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "2a4fdd3a3ca643c57359e584db2dd2b5db7481a82ecc21ea368bc4317bfc0505", - "url": "https://files.pythonhosted.org/packages/ae/ce/6d3b66ea6be73f740eb3f9b5202770e6dbd254bdaa4921a2aa0accad272c/types-setuptools-69.1.0.20240215.tar.gz" + "hash": "ed5462cf8470831d1bdbf300e1eeea876040643bfc40b785109a5857fa7d3c3f", + "url": "https://files.pythonhosted.org/packages/3f/53/b56bf23a9f44fcf8cd711e68877210a17231c5b6d52bf3c42be03b737032/types-setuptools-69.1.0.20240302.tar.gz" } ], "project_name": "types-setuptools", "requires_dists": [], "requires_python": ">=3.8", - "version": "69.1.0.20240215" + "version": "69.1.0.20240302" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "3658c9e36e9cb003e522655b01b9ca39bd0db61b6383b3e7d0d10d14f873b338", - "url": "https://files.pythonhosted.org/packages/2c/eb/dc5d230d27323d72f2c387cd73a71229cf4687c908caa93e8674d7e5f163/types_six-1.16.21.20240106-py3-none-any.whl" + "hash": "4d5bbf07e521f0cb52cc880de71047bc9b5c2a5059211811e15423872d403c4c", + "url": "https://files.pythonhosted.org/packages/be/91/568c535a99994b2089bc9f346a2cf21ea2e45924d525cfed52002c1db2a0/types_six-1.16.21.20240301-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "c83908b4925583e973eb9971ef2bd60dbab647611e10e9cd588d2bef415bfe68", - "url": "https://files.pythonhosted.org/packages/08/0a/f677c7cb11b5dd7ae8281b06ef4b3c86043acf5fe18ee169c421b5e23f90/types-six-1.16.21.20240106.tar.gz" + "hash": "c877c0fa3dbe696860e571bfc23f987ce084bf4de13dfed343ef61ed49826686", + "url": "https://files.pythonhosted.org/packages/17/f6/98c4c8aac02b6628940a54a43c0c9bbc41956a729fa8234bf404729cc6a8/types-six-1.16.21.20240301.tar.gz" } ], "project_name": "types-six", "requires_dists": [], "requires_python": ">=3.8", - "version": "1.16.21.20240106" + "version": "1.16.21.20240301" }, { "artifacts": [ @@ -4304,19 +4271,19 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd", - "url": "https://files.pythonhosted.org/packages/b7/f4/6a90020cd2d93349b442bfcb657d0dc91eee65491600b2cb1d388bc98e6b/typing_extensions-4.9.0-py3-none-any.whl" + "hash": "69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", + "url": "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "url": "https://files.pythonhosted.org/packages/0c/1d/eb26f5e75100d531d7399ae800814b069bc2ed2a7410834d57374d010d96/typing_extensions-4.9.0.tar.gz" + "hash": "b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", + "url": "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz" } ], "project_name": "typing-extensions", "requires_dists": [], "requires_python": ">=3.8", - "version": "4.9.0" + "version": "4.10.0" }, { "artifacts": [ @@ -4503,73 +4470,78 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "6d88056a04860a98341a0cf53e950e3ac9f4e51d1b6f61a53b0609df342cc8b2", - "url": "https://files.pythonhosted.org/packages/7f/13/a4b6ffff8f3278c020d6b7c42fee53133f6165d347db94bdd9ae394ba4f8/yarl-1.8.2-cp311-cp311-musllinux_1_1_x86_64.whl" + "hash": "928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", + "url": "https://files.pythonhosted.org/packages/4d/05/4d79198ae568a92159de0f89e710a8d19e3fa267b719a236582eee921f4a/yarl-1.9.4-py3-none-any.whl" + }, + { + "algorithm": "sha256", + "hash": "35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", + "url": "https://files.pythonhosted.org/packages/12/65/4c7f3676209a569405c9f0f492df2bc3a387c253f5d906e36944fdd12277/yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl" }, { "algorithm": "sha256", - "hash": "2a1fca9588f360036242f379bfea2b8b44cae2721859b1c56d033adfd5893634", - "url": "https://files.pythonhosted.org/packages/02/51/c33498373d2e0ff5d7f3e76038b7057003927d481405780d22f140906b24/yarl-1.8.2-cp311-cp311-macosx_10_9_universal2.whl" + "hash": "d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", + "url": "https://files.pythonhosted.org/packages/20/3d/7dabf580dfc0b588e48830486b488858122b10a61f33325e0d7cf1d6180b/yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl" }, { "algorithm": "sha256", - "hash": "0978f29222e649c351b173da2b9b4665ad1feb8d1daa9d971eb90df08702668a", - "url": "https://files.pythonhosted.org/packages/20/23/9ed12660f860962f179bca719b1d8a895c572c5dbb75098d35d074d35703/yarl-1.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + "hash": "49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", + "url": "https://files.pythonhosted.org/packages/28/c7/249a3a903d500ca7369eb542e2847a14f12f249638dcc10371db50cd17ff/yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl" }, { "algorithm": "sha256", - "hash": "4d04acba75c72e6eb90745447d69f84e6c9056390f7a9724605ca9c56b4afcc6", - "url": "https://files.pythonhosted.org/packages/52/e4/7bcabff7bc7b8421bef266bc955367f586d48667eb0a6ae812852feb51e8/yarl-1.8.2-cp311-cp311-musllinux_1_1_i686.whl" + "hash": "4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", + "url": "https://files.pythonhosted.org/packages/38/45/7c669999f5d350f4f8f74369b94e0f6705918eee18e38610bfe44af93d4f/yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, { "algorithm": "sha256", - "hash": "75c16b2a900b3536dfc7014905a128a2bea8fb01f9ee26d2d7d8db0a08e7cb2c", - "url": "https://files.pythonhosted.org/packages/5f/3c/59d045a4094f11dea659b69bfca8338803f6cb161da5b039d35cb415a84d/yarl-1.8.2-cp311-cp311-musllinux_1_1_s390x.whl" + "hash": "4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", + "url": "https://files.pythonhosted.org/packages/3b/c5/81e3dbf5271ab1510860d2ae7a704ef43f93f7cb9326bf7ebb1949a7260b/yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl" }, { "algorithm": "sha256", - "hash": "42430ff511571940d51e75cf42f1e4dbdded477e71c1b7a17f4da76c1da8ea76", - "url": "https://files.pythonhosted.org/packages/7c/9a/6f2039a5578f2af2d36f22173abf22c957970e908617b90e12552f7dfc9a/yarl-1.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + "hash": "03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", + "url": "https://files.pythonhosted.org/packages/4a/70/5c744d67cad3d093e233cb02f37f2830cb89abfcbb7ad5b5af00ff21d14d/yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl" }, { "algorithm": "sha256", - "hash": "c15163b6125db87c8f53c98baa5e785782078fbd2dbeaa04c6141935eb6dab7a", - "url": "https://files.pythonhosted.org/packages/9b/d2/7813ebb6fe192e568c78ba56658f7a26caeceff6302a96808512f5ee40fd/yarl-1.8.2-cp311-cp311-musllinux_1_1_aarch64.whl" + "hash": "549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", + "url": "https://files.pythonhosted.org/packages/50/49/aa04effe2876cced8867bf9d89b620acf02b733c62adfe22a8218c35d70b/yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" }, { "algorithm": "sha256", - "hash": "3150078118f62371375e1e69b13b48288e44f6691c1069340081c3fd12c94d5b", - "url": "https://files.pythonhosted.org/packages/ba/85/f9e3350daa31161d1f4dfb10e195951df4ac22d8f201603cf894b37510c8/yarl-1.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" + "hash": "c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", + "url": "https://files.pythonhosted.org/packages/59/50/715bbc7bda65291f9295e757f67854206f4d8be9746d39187724919ac14d/yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl" }, { "algorithm": "sha256", - "hash": "388a45dc77198b2460eac0aca1efd6a7c09e976ee768b0d5109173e521a19daf", - "url": "https://files.pythonhosted.org/packages/c0/fd/2f7a640dc157cc2d111114106e3036a69ba3408460d38580f681377c122d/yarl-1.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + "hash": "d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", + "url": "https://files.pythonhosted.org/packages/6d/be/9d4885e2725f5860833547c9e4934b6e0f44a355b24ffc37957264761e3e/yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl" }, { "algorithm": "sha256", - "hash": "49d43402c6e3013ad0978602bf6bf5328535c48d192304b91b97a3c6790b1562", - "url": "https://files.pythonhosted.org/packages/c4/1e/1b204050c601d5cd82b45d5c8f439cb6f744a2ce0c0a6f83be0ddf0dc7b2/yarl-1.8.2.tar.gz" + "hash": "a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", + "url": "https://files.pythonhosted.org/packages/7d/95/4310771fb9c71599d8466f43347ac18fafd501621e65b93f4f4f16899b1d/yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl" }, { "algorithm": "sha256", - "hash": "e7fd20d6576c10306dea2d6a5765f46f0ac5d6f53436217913e952d19237efc4", - "url": "https://files.pythonhosted.org/packages/c6/1c/4a992306ad86a8ae5a1fb4745bd76c590e7bcdb01ce2203dfd38dc830dfe/yarl-1.8.2-cp311-cp311-musllinux_1_1_ppc64le.whl" + "hash": "e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", + "url": "https://files.pythonhosted.org/packages/9f/ea/94ad7d8299df89844e666e4aa8a0e9b88e02416cd6a7dd97969e9eae5212/yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, { "algorithm": "sha256", - "hash": "f37db05c6051eff17bc832914fe46869f8849de5b92dc4a3466cd63095d23dfd", - "url": "https://files.pythonhosted.org/packages/ca/f3/2ef9870409b3ea57a5890e4e49b6bfd6e347a45fe92f6b2e9567abfefb59/yarl-1.8.2-cp311-cp311-macosx_10_9_x86_64.whl" + "hash": "ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", + "url": "https://files.pythonhosted.org/packages/a8/af/ca9962488027576d7162878a1864cbb1275d298af986ce96bdfd4807d7b2/yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl" }, { "algorithm": "sha256", - "hash": "2305517e332a862ef75be8fad3606ea10108662bc6fe08509d5ca99503ac2aee", - "url": "https://files.pythonhosted.org/packages/cb/82/91f74496b653ac9a6220ee499510377c03a4ff768c5bd945f6759b23ebb8/yarl-1.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl" + "hash": "4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", + "url": "https://files.pythonhosted.org/packages/c2/80/8b38d8fed958ac37afb8b81a54bf4f767b107e2c2004dab165edb58fc51b/yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl" }, { "algorithm": "sha256", - "hash": "77e913b846a6b9c5f767b14dc1e759e5aff05502fe73079f6f4176359d832581", - "url": "https://files.pythonhosted.org/packages/e6/d3/5f5b39db2c8c09f6923418c9329a04fe6f68825f27d2cb9776871d75ddc3/yarl-1.8.2-cp311-cp311-macosx_11_0_arm64.whl" + "hash": "566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", + "url": "https://files.pythonhosted.org/packages/e0/ad/bedcdccbcbf91363fd425a948994f3340924145c2bc8ccb296f4a1e52c28/yarl-1.9.4.tar.gz" } ], "project_name": "yarl", @@ -4579,7 +4551,7 @@ "typing-extensions>=3.7.4; python_version < \"3.8\"" ], "requires_python": ">=3.7", - "version": "1.8.2" + "version": "1.9.4" }, { "artifacts": [ @@ -4633,9 +4605,10 @@ "attrs>=20.3", "backend.ai-krunner-alpine==5.1.0", "backend.ai-krunner-static-gnu==4.1.1", + "bcrypt>=4.1.2", "boto3~=1.26", "cachetools~=5.2.0", - "callosum~=1.0.1", + "callosum~=1.0.3", "cattrs~=22.2.0", "click~=8.1.7", "colorama>=0.4.4", @@ -4661,7 +4634,6 @@ "namedlist~=1.8", "networkx~=2.8.7", "packaging>=21.3", - "passlib[bcrypt]>=1.7.4", "pexpect~=4.8", "psutil~=5.9.1", "pycryptodome>=3.14.1", @@ -4671,7 +4643,7 @@ "python-dateutil>=2.8", "python-dotenv~=0.20.0", "python-json-logger>=2.0.1", - "pyzmq~=24.0.1", + "pyzmq~=25.1.2", "redis[hiredis]==4.5.5", "rich~=13.6", "setproctitle~=1.3.2", @@ -4697,7 +4669,7 @@ "types-tabulate", "typing_extensions~=4.3", "uvloop~=0.17.0; sys_platform != \"Windows\"", - "yarl~=1.8.2", + "yarl!=1.9.0,!=1.9.1,!=1.9.2,<2.0,>=1.8.2", "zipstream-new~=1.1.8" ], "requires_python": [ diff --git a/requirements.txt b/requirements.txt index e5a4ec77d2..a6b8aed041 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,9 +17,10 @@ asyncpg>=0.27.0 asynctest>=0.13.0 asyncudp>=0.4 attrs>=20.3 +bcrypt>=4.1.2 boto3~=1.26 cachetools~=5.2.0 -callosum~=1.0.1 +callosum~=1.0.3 cattrs~=22.2.0 click~=8.1.7 coloredlogs~=15.0 @@ -43,14 +44,13 @@ msgpack>=1.0.5rc1 multidict>=6.0 namedlist~=1.8 networkx~=2.8.7 -passlib[bcrypt]>=1.7.4 pexpect~=4.8 psutil~=5.9.1 pycryptodome>=3.14.1 python-dateutil>=2.8 python-dotenv~=0.20.0 python-json-logger>=2.0.1 -pyzmq~=24.0.1 +pyzmq~=25.1.2 PyJWT~=2.0 PyYAML~=6.0 pydantic~=2.4.2 @@ -73,7 +73,7 @@ typeguard~=2.10 typing_extensions~=4.3 textual~=0.41 uvloop~=0.17.0; sys_platform != "Windows" # 0.18 breaks the API and adds Python 3.12 support -yarl~=1.8.2 # FIXME: revert to >=1.7 after aio-libs/yarl#862 is resolved +yarl>=1.8.2,<2.0,!=1.9.0,!=1.9.1,!=1.9.2 zipstream-new~=1.1.8 # required by ai.backend.test (integration test suite) diff --git a/scripts/agent/build-dropbear.sh b/scripts/agent/build-dropbear.sh index 8fe26c6d38..b1d3e9a527 100755 --- a/scripts/agent/build-dropbear.sh +++ b/scripts/agent/build-dropbear.sh @@ -2,15 +2,8 @@ set -e arch=$(uname -m) -distros=("ubuntu16.04" "ubuntu18.04" "ubuntu20.04" "ubuntu22.04" "alpine3.8") +distros=("ubuntu18.04" "ubuntu20.04" "ubuntu22.04" "alpine3.8") -ubuntu1604_builder_dockerfile=$(cat <<'EOF' -FROM ubuntu:16.04 -RUN apt-get update -RUN apt-get install -y make gcc -RUN apt-get install -y autoconf automake zlib1g-dev -EOF -) ubuntu1804_builder_dockerfile=$(cat <<'EOF' FROM ubuntu:18.04 RUN apt-get update @@ -51,6 +44,7 @@ autoreconf sed -i 's/\(DEFAULT_RECV_WINDOW\) [0-9][0-9]*/\1 2097152/' default_options.h sed -i 's/\(RECV_MAX_PAYLOAD_LEN\) [0-9][0-9]*/\1 2621440/' default_options.h sed -i 's/\(TRANS_MAX_PAYLOAD_LEN\) [0-9][0-9]*/\1 2621440/' default_options.h +sed -i '/channel->transwindow -= len;/s/^/\/\//' common-channel.c sed -i 's/DEFAULT_PATH/getenv("PATH")/' svr-chansession.c # Disable clearing environment variables for new pty sessions and remote commands @@ -69,7 +63,6 @@ temp_dir=$(mktemp -d -t dropbear-build.XXXXX) echo "Using temp directory: $temp_dir" echo "$build_script" > "$temp_dir/build.sh" chmod +x $temp_dir/*.sh -echo "$ubuntu1604_builder_dockerfile" > "$SCRIPT_DIR/dropbear-builder.ubuntu16.04.dockerfile" echo "$ubuntu1804_builder_dockerfile" > "$SCRIPT_DIR/dropbear-builder.ubuntu18.04.dockerfile" echo "$ubuntu2004_builder_dockerfile" > "$SCRIPT_DIR/dropbear-builder.ubuntu20.04.dockerfile" echo "$ubuntu2204_builder_dockerfile" > "$SCRIPT_DIR/dropbear-builder.ubuntu22.04.dockerfile" diff --git a/scripts/install-dev.sh b/scripts/install-dev.sh index f8e3f5237a..dadf935279 100755 --- a/scripts/install-dev.sh +++ b/scripts/install-dev.sh @@ -937,7 +937,7 @@ configure_backendai() { install_editable_webui sed_inplace "s@\(#\)\{0,1\}static_path = .*@static_path = "'"src/ai/backend/webui/build/rollup"'"@" ./webserver.conf else - webui_version=$(jq -r '.package + " (built at " + .build + ", rev " + .revision + ")"' src/ai/backend/web/static/version.json) + webui_version=$(jq -r '.package + " (built at " + .buildDate + ", rev " + .revision + ")"' src/ai/backend/web/static/version.json) show_note "The currently embedded webui version: $webui_version" fi diff --git a/src/ai/backend/accelerator/cuda_open/plugin.py b/src/ai/backend/accelerator/cuda_open/plugin.py index fd3e4c89df..9cb7a1da47 100644 --- a/src/ai/backend/accelerator/cuda_open/plugin.py +++ b/src/ai/backend/accelerator/cuda_open/plugin.py @@ -248,7 +248,7 @@ async def gather_node_measures( return [ NodeMeasurement( MetricKey("cuda_mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_node=Measurement(Decimal(mem_used_total), Decimal(mem_avail_total)), @@ -256,7 +256,7 @@ async def gather_node_measures( ), NodeMeasurement( MetricKey("cuda_util"), - MetricTypes.USAGE, + MetricTypes.UTILIZATION, unit_hint="percent", stats_filter=frozenset({"avg", "max"}), per_node=Measurement(Decimal(util_total), Decimal(dev_count * 100)), diff --git a/src/ai/backend/accelerator/mock/defs.py b/src/ai/backend/accelerator/mock/defs.py index 47b29c3043..44ecadf498 100644 --- a/src/ai/backend/accelerator/mock/defs.py +++ b/src/ai/backend/accelerator/mock/defs.py @@ -1,6 +1,6 @@ import enum -class AllocationModes(str, enum.Enum): +class AllocationModes(enum.StrEnum): DISCRETE = "discrete" FRACTIONAL = "fractional" diff --git a/src/ai/backend/accelerator/mock/plugin.py b/src/ai/backend/accelerator/mock/plugin.py index 5a4f4fcb23..3e1d15ede9 100644 --- a/src/ai/backend/accelerator/mock/plugin.py +++ b/src/ai/backend/accelerator/mock/plugin.py @@ -412,7 +412,7 @@ async def gather_node_measures(self, ctx: StatContext) -> Sequence[NodeMeasureme return [ NodeMeasurement( MetricKey(f"{self.key}_mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_node=Measurement(Decimal(mem_used_total), Decimal(mem_avail_total)), @@ -420,7 +420,7 @@ async def gather_node_measures(self, ctx: StatContext) -> Sequence[NodeMeasureme ), NodeMeasurement( MetricKey(f"{self.key}_util"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="percent", stats_filter=frozenset({"avg", "max"}), per_node=Measurement(Decimal(util_total), Decimal(dev_count * 100)), @@ -428,7 +428,7 @@ async def gather_node_measures(self, ctx: StatContext) -> Sequence[NodeMeasureme ), NodeMeasurement( MetricKey(f"{self.key}_power"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="Watts", stats_filter=frozenset({"avg", "max"}), per_node=Measurement(Decimal(power_usage_total), Decimal(power_max_total)), @@ -436,7 +436,7 @@ async def gather_node_measures(self, ctx: StatContext) -> Sequence[NodeMeasureme ), NodeMeasurement( MetricKey(f"{self.key}_temperature"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="Celsius", stats_filter=frozenset({"avg", "max"}), per_node=Measurement( @@ -508,7 +508,7 @@ async def gather_container_measures( return [ ContainerMeasurement( MetricKey(f"{self.key}_mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_container={ @@ -521,7 +521,7 @@ async def gather_container_measures( ), ContainerMeasurement( MetricKey(f"{self.key}_util"), - MetricTypes.USAGE, + MetricTypes.UTILIZATION, unit_hint="percent", stats_filter=frozenset({"avg", "max"}), per_container={ diff --git a/src/ai/backend/agent/agent.py b/src/ai/backend/agent/agent.py index f212103678..342edefff5 100644 --- a/src/ai/backend/agent/agent.py +++ b/src/ai/backend/agent/agent.py @@ -1395,7 +1395,7 @@ async def _get( "status_info": str(result), "metadata": {}, } - case HardwareMetadata(): + case dict(): # HardwareMetadata hwinfo[device_name] = result return hwinfo diff --git a/src/ai/backend/agent/docker/intrinsic.py b/src/ai/backend/agent/docker/intrinsic.py index a1fee08200..ad2ea04482 100644 --- a/src/ai/backend/agent/docker/intrinsic.py +++ b/src/ai/backend/agent/docker/intrinsic.py @@ -270,7 +270,7 @@ async def api_impl(container_id): ), ContainerMeasurement( MetricKey("cpu_used"), - MetricTypes.USAGE, + MetricTypes.ACCUMULATION, unit_hint="msec", per_container=per_container_cpu_used, ), @@ -336,7 +336,7 @@ async def api_impl(cid: str, pids: List[int]) -> List[Optional[Decimal]]: ), ProcessMeasurement( MetricKey("cpu_used"), - MetricTypes.USAGE, + MetricTypes.ACCUMULATION, unit_hint="msec", per_process=per_process_cpu_used, ), @@ -512,7 +512,7 @@ def get_disk_stat(): return [ NodeMeasurement( MetricKey("mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_node=Measurement(total_mem_used_bytes, total_mem_capacity_bytes), @@ -522,7 +522,7 @@ def get_disk_stat(): ), NodeMeasurement( MetricKey("disk"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", per_node=Measurement(total_disk_usage, total_disk_capacity), per_device=per_disk_stat, @@ -714,21 +714,21 @@ async def api_impl(container_id): return [ ContainerMeasurement( MetricKey("mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_container=per_container_mem_used_bytes, ), ContainerMeasurement( MetricKey("io_read"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"rate"}), per_container=per_container_io_read_bytes, ), ContainerMeasurement( MetricKey("io_write"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"rate"}), per_container=per_container_io_write_bytes, @@ -749,7 +749,7 @@ async def api_impl(container_id): ), ContainerMeasurement( MetricKey("io_scratch_size"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_container=per_container_io_scratch_size, @@ -817,21 +817,21 @@ async def api_impl( return [ ProcessMeasurement( MetricKey("mem"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"max"}), per_process=per_process_mem_used_bytes, ), ProcessMeasurement( MetricKey("io_read"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"rate"}), per_process=per_process_io_read_bytes, ), ProcessMeasurement( MetricKey("io_write"), - MetricTypes.USAGE, + MetricTypes.GAUGE, unit_hint="bytes", stats_filter=frozenset({"rate"}), per_process=per_process_io_write_bytes, diff --git a/src/ai/backend/agent/server.py b/src/ai/backend/agent/server.py index fb83b8b46f..65b2907ce0 100644 --- a/src/ai/backend/agent/server.py +++ b/src/ai/backend/agent/server.py @@ -868,15 +868,15 @@ async def server_main( ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context def main( cli_ctx: click.Context, config_path: Path, - log_level: str, + log_level: LogSeverity, debug: bool = False, ) -> int: """Start the agent service as a foreground process.""" @@ -907,10 +907,10 @@ def main( config.override_with_env(raw_cfg, ("container", "scratch-root"), "BACKEND_SCRATCH_ROOT") if debug: - log_level = "DEBUG" - config.override_key(raw_cfg, ("debug", "enabled"), log_level == "DEBUG") - config.override_key(raw_cfg, ("logging", "level"), log_level.upper()) - config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level.upper()) + log_level = LogSeverity.DEBUG + config.override_key(raw_cfg, ("debug", "enabled"), log_level == LogSeverity.DEBUG) + config.override_key(raw_cfg, ("logging", "level"), log_level) + config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level) # Validate and fill configurations # (allow_extra will make configs to be forward-copmatible) diff --git a/src/ai/backend/agent/stats.py b/src/ai/backend/agent/stats.py index 9a1f940c59..bcc7542bc2 100644 --- a/src/ai/backend/agent/stats.py +++ b/src/ai/backend/agent/stats.py @@ -82,12 +82,39 @@ def get_preferred_mode(): class MetricTypes(enum.Enum): - USAGE = 0 # for instant snapshot (e.g., used memory bytes, used cpu msec) - RATE = 1 # for rate of increase (e.g., I/O bps) - UTILIZATION = ( - 2 # for ratio of resource occupation time per measurement interval (e.g., CPU util) - ) - ACCUMULATED = 3 # for accumulated value (e.g., total number of events) + """ + Specifies the type of a metric value. + + Currently this DOES NOT affect calculation and processing of the metric, + but serves as a metadata for code readers. + The actual calculation and formatting is controlled by :meth:`Metric.current_hook()`, + :attr:`Metric.unit_hint` and :attr:`Metric.stats_filter`. + """ + + GAUGE = 0 + """ + Represents an instantly measured occupancy value. + (e.g., used space as bytes, occupied amount as the number of items or a bandwidth) + """ + USAGE = 0 + """ + This is same to GAUGE, but just kept for backward compatibility of compute plugins. + """ + RATE = 1 + """ + Represents a rate of changes calculated from underlying gauge/accumulation values + (e.g., I/O bps calculated from RX/TX accum.bytes) + """ + UTILIZATION = 2 + """ + Represents a ratio of resource occupation time per each measurement interval + (e.g., CPU utilization) + """ + ACCUMULATION = 3 + """ + Represents an accumulated value + (e.g., total number of events, total period of occupation) + """ @attrs.define(auto_attribs=True, slots=True) @@ -108,9 +135,9 @@ class NodeMeasurement: type: MetricTypes per_node: Measurement per_device: Mapping[DeviceId, Measurement] = attrs.Factory(dict) - unit_hint: Optional[str] = None stats_filter: FrozenSet[str] = attrs.Factory(frozenset) current_hook: Optional[Callable[["Metric"], Decimal]] = None + unit_hint: str = "count" @attrs.define(auto_attribs=True, slots=True) @@ -122,9 +149,9 @@ class ContainerMeasurement: key: MetricKey type: MetricTypes per_container: Mapping[str, Measurement] = attrs.Factory(dict) - unit_hint: Optional[str] = None stats_filter: FrozenSet[str] = attrs.Factory(frozenset) current_hook: Optional[Callable[["Metric"], Decimal]] = None + unit_hint: str = "count" @attrs.define(auto_attribs=True, slots=True) @@ -136,9 +163,9 @@ class ProcessMeasurement: key: MetricKey type: MetricTypes per_process: Mapping[int, Measurement] = attrs.Factory(dict) - unit_hint: Optional[str] = None stats_filter: FrozenSet[str] = attrs.Factory(frozenset) current_hook: Optional[Callable[["Metric"], Decimal]] = None + unit_hint: str = "count" class MovingStatistics: @@ -228,11 +255,11 @@ def to_serializable_dict(self) -> MovingStatValue: class Metric: key: str type: MetricTypes + unit_hint: str stats: MovingStatistics stats_filter: FrozenSet[str] current: Decimal capacity: Optional[Decimal] = None - unit_hint: Optional[str] = None current_hook: Optional[Callable[["Metric"], Decimal]] = None def update(self, value: Measurement): @@ -274,12 +301,10 @@ def to_serializable_dict(self) -> MetricValue: class StatContext: agent: "AbstractAgent" mode: StatModes - node_metrics: Mapping[MetricKey, Metric] - device_metrics: Mapping[MetricKey, MutableMapping[DeviceId, Metric]] - kernel_metrics: MutableMapping[KernelId, MutableMapping[MetricKey, Metric]] - process_metrics: MutableMapping[ - ContainerId, MutableMapping[PID, MutableMapping[MetricKey, Metric]] - ] + node_metrics: dict[MetricKey, Metric] + device_metrics: dict[MetricKey, dict[DeviceId, Metric]] + kernel_metrics: dict[KernelId, dict[MetricKey, Metric]] + process_metrics: dict[ContainerId, dict[PID, dict[MetricKey, Metric]]] def __init__( self, agent: "AbstractAgent", mode: StatModes = None, *, cache_lifespan: int = 120 @@ -322,12 +347,12 @@ async def collect_node_stat(self): # Here we use asyncio.gather() instead of aiotools.TaskGroup # to keep methods of other plugins running when a plugin raises an error # instead of cancelling them. - _tasks = [] + _tasks: list[asyncio.Task[Sequence[NodeMeasurement]]] = [] for computer in self.agent.computers.values(): - _tasks.append(computer.instance.gather_node_measures(self)) + _tasks.append(asyncio.create_task(computer.instance.gather_node_measures(self))) results = await asyncio.gather(*_tasks, return_exceptions=True) for result in results: - if isinstance(result, Exception): + if isinstance(result, BaseException): log.error("collect_node_stat(): gather_node_measures() error", exc_info=result) continue for node_measure in result: @@ -347,9 +372,7 @@ async def collect_node_stat(self): else: self.node_metrics[metric_key].update(node_measure.per_node) # update per-device metric - # NOTE: device IDs are defined by each metric keys. for dev_id, measure in node_measure.per_device.items(): - dev_id = str(dev_id) if metric_key not in self.device_metrics: self.device_metrics[metric_key] = {} if dev_id not in self.device_metrics[metric_key]: @@ -371,7 +394,7 @@ async def collect_node_stat(self): "node": {key: obj.to_serializable_dict() for key, obj in self.node_metrics.items()}, "devices": { metric_key: { - dev_id: obj.to_serializable_dict() for dev_id, obj in per_device.items() + str(dev_id): obj.to_serializable_dict() for dev_id, obj in per_device.items() } for metric_key, per_device in self.device_metrics.items() }, @@ -418,7 +441,7 @@ async def collect_container_stat( # Here we use asyncio.gather() instead of aiotools.TaskGroup # to keep methods of other plugins running when a plugin raises an error # instead of cancelling them. - _tasks = [] + _tasks: list[asyncio.Task[Sequence[ContainerMeasurement]]] = [] kernel_id = None for computer in self.agent.computers.values(): _tasks.append( @@ -466,7 +489,7 @@ async def _pipe_builder(r: Redis) -> Pipeline: for kernel_id in updated_kernel_ids: metrics = self.kernel_metrics[kernel_id] serializable_metrics = { - key: obj.to_serializable_dict() for key, obj in metrics.items() + str(key): obj.to_serializable_dict() for key, obj in metrics.items() } if self.agent.local_config["debug"]["log-stats"]: log.debug("kernel_updates: {0}: {1}", kernel_id, serializable_metrics) @@ -513,7 +536,7 @@ async def collect_per_container_process_stat( # Here we use asyncio.gather() instead of aiotools.TaskGroup # to keep methods of other plugins running when a plugin raises an error # instead of cancelling them. - _tasks = [] + _tasks: list[asyncio.Task[Sequence[ProcessMeasurement]]] = [] for computer in self.agent.computers.values(): _tasks.append( asyncio.create_task( @@ -525,7 +548,7 @@ async def collect_per_container_process_stat( results = await asyncio.gather(*_tasks, return_exceptions=True) updated_cids: Set[ContainerId] = set() for result in results: - if isinstance(result, Exception): + if isinstance(result, BaseException): log.error( "collect_per_container_process_stat(): gather_process_measures() error", exc_info=result, @@ -563,7 +586,7 @@ async def _pipe_builder(r: Redis) -> Pipeline: for pid in self.process_metrics[cid].keys(): metrics = self.process_metrics[cid][pid] serializable_metrics = { - key: obj.to_serializable_dict() for key, obj in metrics.items() + str(key): obj.to_serializable_dict() for key, obj in metrics.items() } serializable_table[pid] = serializable_metrics if self.agent.local_config["debug"]["log-stats"]: diff --git a/src/ai/backend/agent/types.py b/src/ai/backend/agent/types.py index 72da9b7f25..730beb291a 100644 --- a/src/ai/backend/agent/types.py +++ b/src/ai/backend/agent/types.py @@ -11,7 +11,7 @@ from ai.backend.common.types import ContainerId, KernelId, MountTypes, SessionId -class AgentBackend(enum.Enum): +class AgentBackend(enum.StrEnum): # The list of importable backend names under "ai.backend.agent" pkg namespace. DOCKER = "docker" KUBERNETES = "kubernetes" @@ -45,7 +45,7 @@ class AgentEventData: data: dict[str, Any] -class ContainerStatus(str, enum.Enum): +class ContainerStatus(enum.StrEnum): RUNNING = "running" RESTARTING = "restarting" PAUSED = "paused" @@ -64,7 +64,7 @@ class Container: backend_obj: Any # used to keep the backend-specific data -class LifecycleEvent(int, enum.Enum): +class LifecycleEvent(enum.IntEnum): DESTROY = 0 CLEAN = 1 START = 2 diff --git a/src/ai/backend/agent/watcher.py b/src/ai/backend/agent/watcher.py index 12ed49ad51..e21a5c4467 100644 --- a/src/ai/backend/agent/watcher.py +++ b/src/ai/backend/agent/watcher.py @@ -332,12 +332,17 @@ async def watcher_server(loop, pidx, args): ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context -def main(ctx: click.Context, config_path: str, log_level: str, debug: bool) -> None: +def main( + ctx: click.Context, + config_path: str, + log_level: LogSeverity, + debug: bool, +) -> None: watcher_config_iv = ( t.Dict({ t.Key("watcher"): t.Dict({ @@ -370,10 +375,10 @@ def main(ctx: click.Context, config_path: str, log_level: str, debug: bool) -> N raw_cfg, ("watcher", "service-addr", "port"), "BACKEND_WATCHER_SERVICE_PORT" ) if debug: - log_level = "DEBUG" - config.override_key(raw_cfg, ("debug", "enabled"), log_level == "DEBUG") - config.override_key(raw_cfg, ("logging", "level"), log_level.upper()) - config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level.upper()) + log_level = LogSeverity.DEBUG + config.override_key(raw_cfg, ("debug", "enabled"), log_level == LogSeverity.DEBUG) + config.override_key(raw_cfg, ("logging", "level"), log_level) + config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level) try: cfg = config.check(raw_cfg, watcher_config_iv) diff --git a/src/ai/backend/client/cli/service.py b/src/ai/backend/client/cli/service.py index 42eb3b07a4..e55a36c97f 100644 --- a/src/ai/backend/client/cli/service.py +++ b/src/ai/backend/client/cli/service.py @@ -1,3 +1,4 @@ +import json import sys from typing import Literal, Optional, Sequence from uuid import UUID @@ -7,13 +8,15 @@ from ai.backend.cli.main import main from ai.backend.cli.types import ExitCode from ai.backend.client.cli.session.execute import prepare_env_arg, prepare_resource_arg -from ai.backend.client.session import Session +from ai.backend.client.compat import asyncio_run +from ai.backend.client.session import AsyncSession, Session from ai.backend.common.arch import DEFAULT_IMAGE_ARCH +from ai.backend.common.types import ClusterMode from ..output.fields import routing_fields, service_fields from ..output.types import FieldSpec from .extensions import pass_ctx_obj -from .pretty import print_done +from .pretty import ProgressViewer, print_done, print_fail, print_warn from .types import CLIContext _default_detail_fields: Sequence[FieldSpec] = ( @@ -179,8 +182,8 @@ def info(ctx: CLIContext, service_name_or_id: str): @click.option( "--cluster-mode", metavar="MODE", - type=click.Choice(["single-node", "multi-node"]), - default="single-node", + type=click.Choice([*ClusterMode], case_sensitive=False), + default=ClusterMode.SINGLE_NODE, help="The mode of clustering.", ) @click.option("-d", "--domain", type=str, default="default") @@ -306,6 +309,225 @@ def create( sys.exit(ExitCode.FAILURE) +@service.command() +@pass_ctx_obj +@click.argument("image", metavar="IMAGE", type=str) +@click.argument("model_name_or_id", metavar="MODEL_NAME_OR_ID", type=str) +@click.option("-t", "--name", metavar="NAME", type=str, default=None) +@click.option("--model-version", metavar="VERSION", type=int, default=1) +@click.option("--model-mount-destination", metavar="PATH", type=str, default="/models") +# execution environment +@click.option( + "-e", + "--env", + metavar="KEY=VAL", + type=str, + multiple=True, + help="Environment variable (may appear multiple times)", +) +# extra options +@click.option( + "--bootstrap-script", + metavar="PATH", + type=click.File("r"), + default=None, + help="A user-defined script to execute on startup.", +) +@click.option( + "-c", + "--startup-command", + metavar="COMMAND", + default=None, + help="Set the command to execute for batch-type sessions.", +) +@click.option( + "-r", + "--resources", + metavar="KEY=VAL", + type=str, + multiple=True, + help=( + "Set computation resources used by the session " + "(e.g: -r cpu=2 -r mem=256 -r gpu=1)." + "1 slot of cpu/gpu represents 1 core. " + "The unit of mem(ory) is MiB." + ), +) +@click.option( + "--resource-opts", + metavar="KEY=VAL", + type=str, + multiple=True, + help="Resource options for creating compute session (e.g: shmem=64m)", +) +@click.option( + "--cluster-size", + metavar="NUMBER", + type=int, + default=1, + help="The size of cluster in number of containers.", +) +@click.option( + "--cluster-mode", + metavar="MODE", + type=click.Choice([*ClusterMode], case_sensitive=False), + default=ClusterMode.SINGLE_NODE, + help="The mode of clustering.", +) +@click.option("-d", "--domain", type=str, default="default") +@click.option("-p", "--project", type=str, default="default") +# extra options +@click.option( + "--bootstrap-script", + metavar="PATH", + type=click.File("r"), + default=None, + help="A user-defined script to execute on startup.", +) +# extra options +@click.option("--tag", type=str, default=None, help="User-defined tag string to annotate sessions.") +@click.option( + "--arch", + "--architecture", + "architecture", + metavar="ARCH_NAME", + type=str, + default=DEFAULT_IMAGE_ARCH, + help="Architecture of the image to use.", +) +@click.option( + "--scaling-group", + "--sgroup", + type=str, + default="default", + help=( + "The scaling group to execute session. If not specified, " + "all available scaling groups are included in the scheduling." + ), +) +@click.option( + "-o", + "--owner", + "--owner-access-key", + metavar="ACCESS_KEY", + default=None, + help="Set the owner of the target session explicitly.", +) +@click.option( + "--public", + "--expose-to-public", + is_flag=True, + help=( + "Visibility of API Endpoint which serves inference workload." + "If set to true, no authentication will be required to access the endpoint." + ), +) +def try_start( + ctx: CLIContext, + image: str, + model_name_or_id: str, + *, + name: Optional[str], + model_version: int, + model_mount_destination: Optional[str], + env: Sequence[str], + startup_command: Optional[str], + resources: Sequence[str], + resource_opts: Sequence[str], + cluster_size: int, + cluster_mode: Literal["single-node", "multi-node"], + domain: Optional[str], + project: Optional[str], + bootstrap_script: Optional[str], + tag: Optional[str], + architecture: Optional[str], + scaling_group: Optional[str], + owner: Optional[str], + public: bool, +): + """ + Tries to create a model service session and return whether the server has successfully started or not. + + \b + MODEL_ID: The model ID + + """ + envs = prepare_env_arg(env) + parsed_resources = prepare_resource_arg(resources) + parsed_resource_opts = prepare_resource_arg(resource_opts) + body = { + "service_name": name, + "model_version": model_version, + "envs": envs, + "startup_command": startup_command, + "resources": parsed_resources, + "resource_opts": parsed_resource_opts, + "cluster_size": cluster_size, + "cluster_mode": cluster_mode, + "bootstrap_script": bootstrap_script, + "tag": tag, + "architecture": architecture, + "scaling_group": scaling_group, + "expose_to_public": public, + } + if model_mount_destination: + body["model_mount_destination"] = model_mount_destination + if domain: + body["domain_name"] = domain + if project: + body["group_name"] = project + if owner: + body["owner_access_key"] = owner + + with Session() as session: + try: + result = session.Service.try_start( + image, + model_name_or_id, + **body, + ) + ctx.output.print_item( + result, + [*service_fields.values()], + ) + except Exception as e: + ctx.output.print_error(e) + sys.exit(ExitCode.FAILURE) + + async def try_start_tracker(bgtask_id): + async with AsyncSession() as session: + try: + bgtask = session.BackgroundTask(bgtask_id) + completion_msg_func = lambda: print_done("Model service validation started.") + async with ( + bgtask.listen_events() as response, + ProgressViewer("Starting the session...") as viewer, + ): + async for ev in response: + data = json.loads(ev.data) + if ev.event == "bgtask_updated": + print(data["message"]) + if viewer.tqdm is None: + pbar = await viewer.to_tqdm() + else: + pbar.total = data["total_progress"] + pbar.update(data["current_progress"] - pbar.n) + elif ev.event == "bgtask_failed": + error_msg = data["message"] + completion_msg_func = lambda: print_fail( + f"Error during the operation: {error_msg}", + ) + elif ev.event == "bgtask_cancelled": + completion_msg_func = lambda: print_warn( + "The operation has been cancelled in the middle. " + "(This may be due to server shutdown.)", + ) + finally: + completion_msg_func() + + asyncio_run(try_start_tracker(result["task_id"])) + + @service.command() @pass_ctx_obj @click.argument("service_name_or_id", metavar="SERVICE_NAME_OR_ID", type=str) diff --git a/src/ai/backend/client/cli/session/args.py b/src/ai/backend/client/cli/session/args.py index 08037d6140..a82f41193c 100644 --- a/src/ai/backend/client/cli/session/args.py +++ b/src/ai/backend/client/cli/session/args.py @@ -2,20 +2,24 @@ import click +from ai.backend.common.types import SessionTypes + START_OPTION = [ click.option( "-t", "--name", "--client-token", metavar="NAME", + type=str, + default=None, help="Specify a human-readable session name. If not set, a random hex string is used.", ), # job scheduling options click.option( "--type", metavar="SESSTYPE", - type=click.Choice(["batch", "interactive"]), - default="interactive", + type=click.Choice([*SessionTypes], case_sensitive=False), + default=SessionTypes.INTERACTIVE, help="Either batch or interactive", ), click.option( @@ -38,7 +42,9 @@ help="The maximum duration to wait until the session starts.", ), click.option( - "--no-reuse", is_flag=True, help="Do not reuse existing sessions but return an error." + "--no-reuse", + is_flag=True, + help="Do not reuse existing sessions but return an error.", ), click.option( "--callback-url", @@ -58,7 +64,10 @@ ), # extra options click.option( - "--tag", type=str, default=None, help="User-defined tag string to annotate sessions." + "--tag", + type=str, + default=None, + help="User-defined tag string to annotate sessions.", ), # resource spec click.option( @@ -82,6 +91,7 @@ click.option( "--scaling-group", "--sgroup", + metavar="SCALING_GROUP", type=str, default=None, help=( diff --git a/src/ai/backend/client/cli/session/execute.py b/src/ai/backend/client/cli/session/execute.py index f0dba07e72..e5f663b40e 100644 --- a/src/ai/backend/client/cli/session/execute.py +++ b/src/ai/backend/client/cli/session/execute.py @@ -20,7 +20,7 @@ from ai.backend.cli.params import CommaSeparatedListType, RangeExprOptionType from ai.backend.cli.types import ExitCode from ai.backend.common.arch import DEFAULT_IMAGE_ARCH -from ai.backend.common.types import MountExpression +from ai.backend.common.types import ClusterMode, MountExpression from ...compat import asyncio_run, current_loop from ...config import local_cache_path @@ -192,20 +192,20 @@ def format_stats(stats): max_fraction_len = 0 for key, metric in stats.items(): unit = metric["unit_hint"] - if unit == "bytes": - val = metric.get("stats.max", metric["current"]) - val = naturalsize(val, binary=True) - val, unit = val.rsplit(" ", maxsplit=1) - val = "{:,}".format(Decimal(val)) - elif unit == "msec": - val = "{:,}".format(Decimal(metric["current"])) - unit = "msec" - elif unit == "percent": - val = metric["pct"] - unit = "%" - else: - val = metric["current"] - unit = "" + match unit: + case "bytes": + val = metric.get("stats.max", metric["current"]) + val = naturalsize(val, binary=True) + val, unit = val.rsplit(" ", maxsplit=1) + val = "{:,}".format(Decimal(val)) + case "msec" | "usec" | "sec": + val = "{:,}".format(Decimal(metric["current"])) + case "percent" | "pct" | "%": + val = metric["pct"] + unit = "%" + case _: + val = metric["current"] + unit = "" if val is None: continue ip, _, fp = val.partition(".") @@ -386,8 +386,8 @@ def prepare_mount_arg_v2( @click.option( "--cluster-mode", metavar="MODE", - type=click.Choice(["single-node", "multi-node"]), - default="single-node", + type=click.Choice([*ClusterMode], case_sensitive=False), + default=ClusterMode.SINGLE_NODE, help="The mode of clustering.", ) @click.option( @@ -449,7 +449,7 @@ def run( scaling_group, # click_start_option resources, # click_start_option cluster_size, # click_start_option - cluster_mode, + cluster_mode: ClusterMode, resource_opts, # click_start_option architecture, domain, # click_start_option diff --git a/src/ai/backend/client/cli/session/lifecycle.py b/src/ai/backend/client/cli/session/lifecycle.py index b0c36e5fb0..729732e21e 100644 --- a/src/ai/backend/client/cli/session/lifecycle.py +++ b/src/ai/backend/client/cli/session/lifecycle.py @@ -26,6 +26,7 @@ from ai.backend.cli.params import CommaSeparatedListType, OptionalType from ai.backend.cli.types import ExitCode, Undefined, undefined from ai.backend.common.arch import DEFAULT_IMAGE_ARCH +from ai.backend.common.types import ClusterMode from ...compat import asyncio_run from ...exceptions import BackendAPIError @@ -103,8 +104,8 @@ def _create_cmd(docs: str = None): @click.option( "--cluster-mode", metavar="MODE", - type=click.Choice(["single-node", "multi-node"]), - default="single-node", + type=click.Choice([*ClusterMode], case_sensitive=False), + default=ClusterMode.SINGLE_NODE, help="The mode of clustering.", ) @click.option("--preopen", default=None, type=list_expr, help="Pre-open service ports") @@ -271,6 +272,7 @@ def create( def _create_from_template_cmd(docs: str = None): @click.argument("template_id") + @click_start_option() @click.option( "-o", "--owner", @@ -281,7 +283,14 @@ def _create_from_template_cmd(docs: str = None): help="Set the owner of the target session explicitly.", ) # job scheduling options - @click.option("-i", "--image", default=undefined, help="Set compute_session image to run.") + @click.option( + "-i", + "--image", + metavar="IMAGE", + type=OptionalType(str), + default=undefined, + help="Set compute_session image to run.", + ) @click.option( "-c", "--startup-command", @@ -300,17 +309,18 @@ def _create_from_template_cmd(docs: str = None): "The session will get scheduled after all of them successfully finish." ), ) + # resource spec @click.option( - "--depends", - metavar="SESSION_ID", - type=str, - multiple=True, + "--scaling-group", + "--sgroup", + metavar="SCALING_GROUP", + type=OptionalType(str), + default=undefined, help=( - "Set the list of session ID or names that the newly created session depends on. " - "The session will get scheduled after all of them successfully finish." + "The scaling group to execute session. If not specified " + "all available scaling groups are included in the scheduling." ), ) - # resource spec @click.option( "--cluster-size", metavar="NUMBER", @@ -318,12 +328,28 @@ def _create_from_template_cmd(docs: str = None): default=undefined, help="The size of cluster in number of containers.", ) + # resource grouping @click.option( - "--resource-opts", - metavar="KEY=VAL", - type=str, - multiple=True, - help="Resource options for creating compute session (e.g: shmem=64m)", + "-d", + "--domain", + metavar="DOMAIN_NAME", + type=OptionalType(str), + default=undefined, + help=( + "Domain name where the session will be spawned. " + "If not specified, config's domain name will be used." + ), + ) + @click.option( + "-g", + "--group", + metavar="GROUP_NAME", + type=OptionalType(str), + default=undefined, + help=( + "Group name where the session is spawned. " + "User should be a member of the group to execute the code." + ), ) # template overrides @click.option( @@ -350,35 +376,34 @@ def _create_from_template_cmd(docs: str = None): "any resource specified at template," ), ) - @click_start_option() def create_from_template( # base args template_id: str, - name: str | Undefined, # click_start_option + name: str | None, # click_start_option owner: str | Undefined, # job scheduling options - type_: Literal["batch", "interactive"] | Undefined, # click_start_option + type: Literal["batch", "interactive"], # click_start_option starts_at: str | None, # click_start_option image: str | Undefined, startup_command: str | Undefined, enqueue_only: bool, # click_start_option - max_wait: int | Undefined, # click_start_option + max_wait: int, # click_start_option no_reuse: bool, # click_start_option depends: Sequence[str], - callback_url: str, # click_start_option + callback_url: str | None, # click_start_option # execution environment env: Sequence[str], # click_start_option # extra options - tag: str | Undefined, # click_start_option + tag: str | None, # click_start_option # resource spec mount: Sequence[str], # click_start_option - scaling_group: str | Undefined, # click_start_option + scaling_group: str | Undefined, resources: Sequence[str], # click_start_option - cluster_size: int | Undefined, # click_start_option + cluster_size: int | Undefined, resource_opts: Sequence[str], # click_start_option # resource grouping - domain: str | None, # click_start_option - group: str | None, # click_start_option + domain: str | Undefined, + group: str | Undefined, # template overrides no_mount: bool, no_env: bool, @@ -393,7 +418,7 @@ def create_from_template( \b TEMPLATE_ID: The template ID to create a session from. """ - if name is undefined: + if name is None: name = f"pysdk-{secrets.token_hex(5)}" else: name = name @@ -410,31 +435,35 @@ def create_from_template( prepared_mount, prepared_mount_map = ( prepare_mount_arg(mount) if len(mount) > 0 or no_mount else (undefined, undefined) ) + kwargs = { + "name": name, + "type_": type, + "starts_at": starts_at, + "enqueue_only": enqueue_only, + "max_wait": max_wait, + "no_reuse": no_reuse, + "dependencies": depends, + "callback_url": callback_url, + "cluster_size": cluster_size, + "mounts": prepared_mount, + "mount_map": prepared_mount_map, + "envs": envs, + "startup_command": startup_command, + "resources": parsed_resources, + "resource_opts": parsed_resource_opts, + "owner_access_key": owner, + "domain_name": domain, + "group_name": group, + "scaling_group": scaling_group, + "tag": tag, + } + kwargs = {key: value for key, value in kwargs.items() if value is not undefined} with Session() as session: try: compute_session = session.ComputeSession.create_from_template( template_id, image=image, - name=name, - type_=type_, - starts_at=starts_at, - enqueue_only=enqueue_only, - max_wait=max_wait, - no_reuse=no_reuse, - dependencies=depends, - callback_url=callback_url, - cluster_size=cluster_size, - mounts=prepared_mount, - mount_map=prepared_mount_map, - envs=envs, - startup_command=startup_command, - resources=parsed_resources, - resource_opts=parsed_resource_opts, - owner_access_key=owner, - domain_name=domain, - group_name=group, - scaling_group=scaling_group, - tag=tag, + **kwargs, ) except Exception as e: print_error(e) diff --git a/src/ai/backend/client/cli/vfolder.py b/src/ai/backend/client/cli/vfolder.py index eb4f0f3083..bf0e65d22f 100644 --- a/src/ai/backend/client/cli/vfolder.py +++ b/src/ai/backend/client/cli/vfolder.py @@ -447,8 +447,9 @@ def cp(filenames): @vfolder.command() +@pass_ctx_obj @click.argument("name", type=str) -@click.argument("path", type=str) +@click.argument("paths", type=str, nargs=-1) @click.option( "-p", "--parents", @@ -461,22 +462,30 @@ def cp(filenames): "--exist-ok", default=False, is_flag=True, - help="Skip an error caused by file not found", + help="Allow specifying already existing directories", ) -def mkdir(name, path, parents, exist_ok): +def mkdir( + ctx: CLIContext, + name: str, + paths: list[str], + parents: bool, + exist_ok: bool, +) -> None: """Create an empty directory in the virtual folder. \b NAME: Name of a virtual folder. - PATH: The name or path of directory. Parent directories are created automatically - if they do not exist. + PATHS: Relative directory paths to create in the vfolder. + Use '-p' option to auto-create parent directories. + + Example: backend.ai vfolder mkdir my_vfolder "dir1" "dir2" "dir3" """ with Session() as session: try: - session.VFolder(name).mkdir(path, parents=parents, exist_ok=exist_ok) - print_done("Done.") + results = session.VFolder(name).mkdir(paths, parents=parents, exist_ok=exist_ok) + ctx.output.print_result_set(results) except Exception as e: - print_error(e) + ctx.output.print_error(e) sys.exit(ExitCode.FAILURE) diff --git a/src/ai/backend/client/config.py b/src/ai/backend/client/config.py index 18693402bc..0066ecd3cf 100644 --- a/src/ai/backend/client/config.py +++ b/src/ai/backend/client/config.py @@ -39,8 +39,8 @@ class Undefined(enum.Enum): _config = None _undefined = Undefined.token -API_VERSION = (7, "20230615") -MIN_API_VERSION = (5, "20191215") +API_VERSION = (8, "20240315") +MIN_API_VERSION = (7, "20230615") DEFAULT_CHUNK_SIZE = 16 * (2**20) # 16 MiB MAX_INFLIGHT_CHUNKS = 4 diff --git a/src/ai/backend/client/func/service.py b/src/ai/backend/client/func/service.py index ddae08ad7e..2a75d75b71 100644 --- a/src/ai/backend/client/func/service.py +++ b/src/ai/backend/client/func/service.py @@ -179,6 +179,96 @@ async def create( "is_public": expose_to_public, } + @api_function + @classmethod + async def try_start( + cls, + image: str, + model_id_or_name: str, + *, + service_name: Optional[str] = None, + model_version: Optional[str] = None, + dependencies: Optional[Sequence[str]] = None, + model_mount_destination: Optional[str] = None, + envs: Optional[Mapping[str, str]] = None, + startup_command: Optional[str] = None, + resources: Optional[Mapping[str, str | int]] = None, + resource_opts: Optional[Mapping[str, str | int]] = None, + cluster_size: int = 1, + cluster_mode: Literal["single-node", "multi-node"] = "single-node", + domain_name: Optional[str] = None, + group_name: Optional[str] = None, + bootstrap_script: Optional[str] = None, + tag: Optional[str] = None, + architecture: Optional[str] = DEFAULT_IMAGE_ARCH, + scaling_group: Optional[str] = None, + owner_access_key: Optional[str] = None, + expose_to_public=False, + ) -> Any: + """ + Tries to start an inference session and terminates immediately. + + :param image: The image name and tag for the infernence session. + Example: ``python:3.6-ubuntu``. + Check out the full list of available images in your server using (TODO: + new API). + :param service_name: A client-side (user-defined) identifier to distinguish the session among currently + running sessions. + It may be used to seamlessly reuse the session already created. + :param mounts: The ID of model type vFolder which contains model files required + to start inference session. + :param model_mount_destination: Path inside the container to mount model vFolder, + defaults to /models + :param envs: The environment variables which always bypasses the jail policy. + :param resources: The resource specification. (TODO: details) + :param cluster_size: The number of containers in this compute session. + Must be at least 1. + :param cluster_mode: Set the clustering mode whether to use distributed + nodes or a single node to spawn multiple containers for the new session. + :param tag: An optional string to annotate extra information. + :param owner: An optional access key that owns the created session. (Only + available to administrators) + :param expose_to_public: Visibility of API Endpoint which serves inference workload. + If set to true, no authentication will be required to access the endpoint. + + :returns: The :class:`ComputeSession` instance. + """ + if service_name is None: + faker = Faker() + service_name = f"bai-serve-{faker.user_name()}" + + rqst = Request("POST", "/services/_/try") + rqst.set_json({ + "name": service_name, + "desired_session_count": 1, + "image": image, + "arch": architecture, + "group": group_name, + "domain": domain_name, + "cluster_size": cluster_size, + "cluster_mode": cluster_mode, + "tag": tag, + "startup_command": startup_command, + "bootstrap_script": bootstrap_script, + "owner_access_key": owner_access_key, + "open_to_public": expose_to_public, + "config": { + "model": model_id_or_name, + "model_version": model_version, + "model_mount_destination": model_mount_destination, + "environ": envs, + "scaling_group": scaling_group, + "resources": resources, + "resource_opts": resource_opts, + }, + }) + async with rqst.fetch() as resp: + body = await resp.json() + return { + "task_id": body["task_id"], + "name": service_name, + } + def __init__(self, id: str | UUID) -> None: super().__init__() self.id = id if isinstance(id, UUID) else UUID(id) diff --git a/src/ai/backend/client/func/session.py b/src/ai/backend/client/func/session.py index 4919dff469..837f6b3ca3 100644 --- a/src/ai/backend/client/func/session.py +++ b/src/ai/backend/client/func/session.py @@ -16,7 +16,6 @@ Mapping, Optional, Sequence, - Union, cast, ) from uuid import UUID @@ -29,7 +28,7 @@ from ai.backend.client.output.fields import session_fields from ai.backend.client.output.types import FieldSpec, PaginatedResult from ai.backend.common.arch import DEFAULT_IMAGE_ARCH -from ai.backend.common.types import SessionTypes +from ai.backend.common.types import ClusterMode, SessionTypes from ...cli.types import Undefined, undefined from ..compat import current_loop @@ -183,7 +182,7 @@ async def get_or_create( resources: Mapping[str, str | int] = None, resource_opts: Mapping[str, str | int] = None, cluster_size: int = 1, - cluster_mode: Literal["single-node", "multi-node"] = "single-node", + cluster_mode: ClusterMode = ClusterMode.SINGLE_NODE, domain_name: str = None, group_name: str = None, bootstrap_script: str = None, @@ -354,21 +353,21 @@ async def create_from_template( *, name: str | Undefined = undefined, type_: str | Undefined = undefined, - starts_at: str = None, + starts_at: str | None = None, # not included in templates enqueue_only: bool | Undefined = undefined, max_wait: int | Undefined = undefined, - dependencies: Sequence[str] = None, # cannot be stored in templates + dependencies: Sequence[str] | None = None, # cannot be stored in templates callback_url: str | Undefined = undefined, no_reuse: bool | Undefined = undefined, image: str | Undefined = undefined, - mounts: Union[List[str], Undefined] = undefined, - mount_map: Union[Mapping[str, str], Undefined] = undefined, - envs: Union[Mapping[str, str], Undefined] = undefined, + mounts: List[str] | Undefined = undefined, + mount_map: Mapping[str, str] | Undefined = undefined, + envs: Mapping[str, str] | Undefined = undefined, startup_command: str | Undefined = undefined, - resources: Union[Mapping[str, str | int], Undefined] = undefined, - resource_opts: Union[Mapping[str, str | int], Undefined] = undefined, + resources: Mapping[str, str | int] | Undefined = undefined, + resource_opts: Mapping[str, str | int] | Undefined = undefined, cluster_size: int | Undefined = undefined, - cluster_mode: Union[Literal["single-node", "multi-node"], Undefined] = undefined, + cluster_mode: ClusterMode | Undefined = undefined, domain_name: str | Undefined = undefined, group_name: str | Undefined = undefined, bootstrap_script: str | Undefined = undefined, @@ -1214,7 +1213,7 @@ async def get_or_create( resources: Optional[Mapping[str, str]] = None, resource_opts: Optional[Mapping[str, str]] = None, cluster_size: int = 1, - cluster_mode: Literal["single-node", "multi-node"] = "single-node", + cluster_mode: ClusterMode = ClusterMode.SINGLE_NODE, domain_name: Optional[str] = None, group_name: Optional[str] = None, bootstrap_script: Optional[str] = None, @@ -1251,7 +1250,7 @@ async def create_from_template( resources: Mapping[str, int] | Undefined = undefined, resource_opts: Mapping[str, int] | Undefined = undefined, cluster_size: int | Undefined = undefined, - cluster_mode: Literal["single-node", "multi-node"] | Undefined = undefined, + cluster_mode: ClusterMode | Undefined = undefined, domain_name: str | Undefined = undefined, group_name: str | Undefined = undefined, bootstrap_script: str | Undefined = undefined, diff --git a/src/ai/backend/client/func/user.py b/src/ai/backend/client/func/user.py index 94ef68d863..ce401df26b 100644 --- a/src/ai/backend/client/func/user.py +++ b/src/ai/backend/client/func/user.py @@ -58,7 +58,7 @@ ) -class UserRole(str, enum.Enum): +class UserRole(enum.StrEnum): """ The role (privilege level) of users. """ @@ -69,7 +69,7 @@ class UserRole(str, enum.Enum): MONITOR = "monitor" -class UserStatus(enum.Enum): +class UserStatus(enum.StrEnum): """ The detailed status of users to represent the signup process and account lifecycles. """ diff --git a/src/ai/backend/client/func/vfolder.py b/src/ai/backend/client/func/vfolder.py index 8db961ba43..13e735b715 100644 --- a/src/ai/backend/client/func/vfolder.py +++ b/src/ai/backend/client/func/vfolder.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import asyncio import uuid from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Sequence, Union +from typing import Any, Mapping, Optional, Sequence, TypeAlias, TypeVar, Union import aiohttp import janus @@ -20,6 +22,7 @@ from ai.backend.client.output.fields import vfolder_fields from ai.backend.client.output.types import FieldSpec, PaginatedResult +from ai.backend.common.types import ResultSet from ..compat import current_loop from ..config import DEFAULT_CHUNK_SIZE, MAX_INFLIGHT_CHUNKS @@ -42,6 +45,9 @@ vfolder_fields["status"], ) +T = TypeVar("T") +list_: TypeAlias = list[T] + class ResponseFailed(Exception): pass @@ -322,7 +328,7 @@ async def _download_file( if_range: str | None = None file_unit = "bytes" file_mode = "wb" - file_req_hdrs: Dict[str, str] = {} + file_req_hdrs: dict[str, str] = {} try: async for session_attempt in AsyncRetrying( wait=wait_exponential(multiplier=0.02, min=0.02, max=5.0), @@ -514,7 +520,7 @@ async def _upload_recursively( if path.is_file(): file_list.append(path) else: - await self._mkdir(path.relative_to(base_path)) + await self._mkdir([path.relative_to(base_path)]) dir_list.append(path) await self._upload_files(file_list, basedir, dst_dir, chunk_size, address_map) for dir in dir_list: @@ -545,10 +551,10 @@ async def upload( async def _mkdir( self, - path: Union[str, Path], + path: str | Path | list_[str | Path], parents: Optional[bool] = False, exist_ok: Optional[bool] = False, - ) -> str: + ) -> ResultSet: rqst = Request("POST", "/folders/{}/mkdir".format(self.name)) rqst.set_json({ "path": path, @@ -556,15 +562,16 @@ async def _mkdir( "exist_ok": exist_ok, }) async with rqst.fetch() as resp: - return await resp.text() + reply = await resp.json() + return reply["results"] @api_function async def mkdir( self, - path: Union[str, Path], + path: str | Path | list_[str | Path], parents: Optional[bool] = False, exist_ok: Optional[bool] = False, - ) -> str: + ) -> ResultSet: return await self._mkdir(path, parents, exist_ok) @api_function diff --git a/src/ai/backend/client/output/console.py b/src/ai/backend/client/output/console.py index 864cb34b08..5f220d4309 100644 --- a/src/ai/backend/client/output/console.py +++ b/src/ai/backend/client/output/console.py @@ -6,7 +6,8 @@ from tabulate import tabulate from ai.backend.client.cli.pagination import echo_via_pager, get_preferred_page_size, tabulate_items -from ai.backend.client.cli.pretty import print_error, print_fail +from ai.backend.client.cli.pretty import print_done, print_error, print_fail +from ai.backend.common.types import ResultSet from .types import BaseOutputHandler, FieldSpec, PaginatedResult @@ -64,6 +65,27 @@ def print_items( ) ) + def print_result_set( + self, + result_set: ResultSet, + ) -> None: + if result_set["success"]: + print_done("Successfully created:") + print( + tabulate( + map(lambda item: [item["item"]], result_set["success"]), + tablefmt="plain", + ) + ) + if result_set["failed"]: + print_fail("Failed to create:") + print( + tabulate( + map(lambda item: [item["item"], item["msg"]], result_set["failed"]), + tablefmt="plain", + ) + ) + def print_list( self, items: Sequence[_Item], diff --git a/src/ai/backend/client/output/formatters.py b/src/ai/backend/client/output/formatters.py index 4e3d5025a8..637deefc85 100644 --- a/src/ai/backend/client/output/formatters.py +++ b/src/ai/backend/client/output/formatters.py @@ -4,10 +4,12 @@ import json import textwrap from collections import defaultdict -from typing import Any, Mapping, Optional +from typing import Any, Callable, Mapping, Optional import humanize +from ai.backend.common.types import MetricValue + from .types import AbstractOutputFormatter, FieldSpec @@ -174,10 +176,15 @@ def format_console(self, value: Any, field: FieldSpec) -> str: except TypeError: return "" - value_formatters = { + percent_formatter = lambda metric, _: "{} %".format(metric["pct"]) + value_formatters: Mapping[str, Callable[[MetricValue, bool], str]] = { "bytes": lambda metric, binary: "{} / {}".format( - humanize.naturalsize(int(metric["current"]), binary, gnu=binary), - humanize.naturalsize(int(metric["capacity"]), binary, gnu=binary), + humanize.naturalsize(int(metric["current"]), binary=binary, gnu=binary), + ( + humanize.naturalsize(int(metric["capacity"]), binary=binary, gnu=binary) + if metric["capacity"] is not None + else "(unknown)" + ), ), "Celsius": lambda metric, _: "{:,} C".format( float(metric["current"]), @@ -185,18 +192,20 @@ def format_console(self, value: Any, field: FieldSpec) -> str: "bps": lambda metric, _: "{}/s".format( humanize.naturalsize(float(metric["current"])), ), - "pct": lambda metric, _: "{} %".format( - metric["pct"], - ), + "pct": percent_formatter, + "percent": percent_formatter, + "%": percent_formatter, } - def format_value(metric, binary): + def format_value(metric: MetricValue, binary: bool) -> str: + unit_hint = metric["unit_hint"] formatter = value_formatters.get( - metric["unit_hint"], - lambda m: "{} / {} {}".format( + unit_hint, + # a fallback implementation + lambda m, _: "{} / {} {}".format( m["current"], - m["capacity"], - m["unit_hint"], + "(unknown)" if m["capacity"] is None else m["capacity"], + "" if unit_hint == "count" else unit_hint, ), ) return formatter(metric, binary) diff --git a/src/ai/backend/client/output/json.py b/src/ai/backend/client/output/json.py index a837ee6ff6..b0a4b517c2 100644 --- a/src/ai/backend/client/output/json.py +++ b/src/ai/backend/client/output/json.py @@ -3,6 +3,9 @@ import json from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar +from ai.backend.client.exceptions import BackendAPIError +from ai.backend.common.types import ResultSet + from .types import BaseOutputHandler, FieldSpec, PaginatedResult _json_opts: Mapping[str, Any] = {"indent": 2} @@ -71,6 +74,12 @@ def print_items( ) ) + def print_result_set( + self, + result_set: ResultSet, + ) -> None: + print(json.dumps(result_set)) + def print_list( self, items: Sequence[Mapping[str, Any]], @@ -189,14 +198,30 @@ def print_error( self, error: Exception, ) -> None: - print( - json.dumps( - { - "error": str(error), - }, - **_json_opts, - ) - ) + match error: + case BackendAPIError(): + print( + json.dumps( + { + "error": error.data["title"], + "api": { + "status": error.status, + "reason": error.reason, + **error.data, + }, + }, + **_json_opts, + ) + ) + case _: + print( + json.dumps( + { + "error": str(error), + }, + **_json_opts, + ) + ) def print_fail( self, diff --git a/src/ai/backend/client/output/types.py b/src/ai/backend/client/output/types.py index fc0709c54f..d51e23a971 100644 --- a/src/ai/backend/client/output/types.py +++ b/src/ai/backend/client/output/types.py @@ -6,6 +6,8 @@ import attr +from ai.backend.common.types import ResultSet + if TYPE_CHECKING: from ai.backend.client.cli.types import CLIContext @@ -139,6 +141,13 @@ def print_items( ) -> None: raise NotImplementedError + @abstractmethod + def print_result_set( + self, + result_set: ResultSet, + ) -> None: + raise NotImplementedError + @abstractmethod def print_list( self, diff --git a/src/ai/backend/client/session.py b/src/ai/backend/client/session.py index d03d0c6a9e..dfcd8f534b 100644 --- a/src/ai/backend/client/session.py +++ b/src/ai/backend/client/session.py @@ -68,7 +68,7 @@ async def _negotiate_api_version( ) if server_version < MIN_API_VERSION: warnings.warn( - "The server is too old and does not meet the minimum API version" + f"The server is too old ({server_version}) and does not meet the minimum API version" f" requirement: v{MIN_API_VERSION[0]}.{MIN_API_VERSION[1]}\nPlease upgrade" " the server or downgrade/reinstall the client SDK with the same" " major.minor release of the server.", diff --git a/src/ai/backend/common/events.py b/src/ai/backend/common/events.py index 2b15cfb7c5..7b4ae3acb6 100644 --- a/src/ai/backend/common/events.py +++ b/src/ai/backend/common/events.py @@ -206,7 +206,7 @@ def deserialize(cls, value: tuple): ) -class KernelLifecycleEventReason(str, enum.Enum): +class KernelLifecycleEventReason(enum.StrEnum): AGENT_TERMINATION = "agent-termination" ALREADY_TERMINATED = "already-terminated" ANOMALY_DETECTED = "anomaly-detected" @@ -238,6 +238,8 @@ class KernelLifecycleEventReason(str, enum.Enum): @classmethod def from_value(cls, value: Optional[str]) -> Optional[KernelLifecycleEventReason]: + if value is None: + return None try: return cls(value) except ValueError: @@ -748,6 +750,7 @@ class EventHandler(Generic[TContext, TEvent]): callback: EventCallback[TContext, TEvent] coalescing_opts: Optional[CoalescingOptions] coalescing_state: CoalescingState + args_matcher: Callable[[tuple], bool] | None class CoalescingOptions(TypedDict): @@ -903,11 +906,27 @@ def consume( coalescing_opts: CoalescingOptions = None, *, name: str | None = None, + args_matcher: Callable[[tuple], bool] | None = None, ) -> EventHandler[TContext, TEvent]: + """ + Register a callback as a consumer. When multiple callback registers as a consumer + on a single event, only one callable among those will be called. + + args_matcher: + Optional. A callable which accepts event argument and supplies a bool as a return value. + When specified, EventDispatcher will only execute callback when this lambda returns True. + """ + if name is None: name = f"evh-{secrets.token_urlsafe(16)}" handler = EventHandler( - event_cls, name, context, callback, coalescing_opts, CoalescingState() + event_cls, + name, + context, + callback, + coalescing_opts, + CoalescingState(), + args_matcher, ) self.consumers[event_cls.name].add(cast(EventHandler[Any, AbstractEvent], handler)) return handler @@ -928,11 +947,26 @@ def subscribe( coalescing_opts: CoalescingOptions | None = None, *, name: str | None = None, + args_matcher: Callable[[tuple], bool] | None = None, ) -> EventHandler[TContext, TEvent]: + """ + Subscribes to given event. All handlers will be called when certain event pops up. + + args_matcher: + Optional. A callable which accepts event argument and supplies a bool as a return value. + When specified, EventDispatcher will only execute callback when this lambda returns True. + """ + if name is None: name = f"evh-{secrets.token_urlsafe(16)}" handler = EventHandler( - event_cls, name, context, callback, coalescing_opts, CoalescingState() + event_cls, + name, + context, + callback, + coalescing_opts, + CoalescingState(), + args_matcher, ) self.subscribers[event_cls.name].add(cast(EventHandler[Any, AbstractEvent], handler)) return handler @@ -946,6 +980,8 @@ def unsubscribe( ) async def handle(self, evh_type: str, evh: EventHandler, source: AgentId, args: tuple) -> None: + if evh.args_matcher and not evh.args_matcher(args): + return coalescing_opts = evh.coalescing_opts coalescing_state = evh.coalescing_state cb = evh.callback diff --git a/src/ai/backend/common/types.py b/src/ai/backend/common/types.py index 521c6d9f59..f6d9bf6de9 100644 --- a/src/ai/backend/common/types.py +++ b/src/ai/backend/common/types.py @@ -226,7 +226,7 @@ def check_typed_dict(value: Mapping[Any, Any], expected_type: Type[TD]) -> TD: SecretKey = NewType("SecretKey", str) -class AbstractPermission(str, enum.Enum): +class AbstractPermission(enum.StrEnum): """ Abstract enum type for permissions """ @@ -247,15 +247,15 @@ class VFolderHostPermission(AbstractPermission): SET_USER_PERM = "set-user-specific-permission" # override permission of group-type vfolder -class LogSeverity(str, enum.Enum): - CRITICAL = "critical" - ERROR = "error" - WARNING = "warning" - INFO = "info" - DEBUG = "debug" +class LogSeverity(enum.StrEnum): + CRITICAL = "CRITICAL" + ERROR = "ERROR" + WARNING = "WARNING" + INFO = "INFO" + DEBUG = "DEBUG" -class SlotTypes(str, enum.Enum): +class SlotTypes(enum.StrEnum): COUNT = "count" BYTES = "bytes" UNIQUE = "unique" @@ -267,42 +267,52 @@ class HardwareMetadata(TypedDict): metadata: Dict[str, str] -class AutoPullBehavior(str, enum.Enum): +class AutoPullBehavior(enum.StrEnum): DIGEST = "digest" TAG = "tag" NONE = "none" -class ServicePortProtocols(str, enum.Enum): +class ServicePortProtocols(enum.StrEnum): HTTP = "http" TCP = "tcp" PREOPEN = "preopen" INTERNAL = "internal" -class SessionTypes(str, enum.Enum): +class SessionTypes(enum.StrEnum): INTERACTIVE = "interactive" BATCH = "batch" INFERENCE = "inference" -class SessionResult(str, enum.Enum): +class SessionResult(enum.StrEnum): UNDEFINED = "undefined" SUCCESS = "success" FAILURE = "failure" -class ClusterMode(str, enum.Enum): +class ClusterMode(enum.StrEnum): SINGLE_NODE = "single-node" MULTI_NODE = "multi-node" -class CommitStatus(str, enum.Enum): +class CommitStatus(enum.StrEnum): READY = "ready" ONGOING = "ongoing" -class AbuseReportValue(str, enum.Enum): +class ItemResult(TypedDict): + msg: Optional[str] + item: Optional[str] + + +class ResultSet(TypedDict): + success: list[ItemResult] + failed: list[ItemResult] + + +class AbuseReportValue(enum.StrEnum): DETECTED = "detected" CLEANING = "cleaning" @@ -327,7 +337,7 @@ class MovingStatValue(TypedDict): { "current": str, "capacity": Optional[str], - "pct": Optional[str], + "pct": str, "unit_hint": str, "stats.min": str, "stats.max": str, @@ -335,8 +345,8 @@ class MovingStatValue(TypedDict): "stats.avg": str, "stats.diff": str, "stats.rate": str, + "stats.version": Optional[int], }, - total=False, ) @@ -345,12 +355,12 @@ class IntrinsicSlotNames(enum.Enum): MEMORY = SlotName("mem") -class DefaultForUnspecified(str, enum.Enum): +class DefaultForUnspecified(enum.StrEnum): LIMITED = "LIMITED" UNLIMITED = "UNLIMITED" -class HandlerForUnknownSlotName(str, enum.Enum): +class HandlerForUnknownSlotName(enum.StrEnum): DROP = "drop" ERROR = "error" @@ -904,7 +914,7 @@ def __eq__(self, other) -> bool: return self.quota_scope_id == other.quota_scope_id and self.folder_id == other.folder_id -class VFolderUsageMode(str, enum.Enum): +class VFolderUsageMode(enum.StrEnum): """ Usage mode of virtual folder. diff --git a/src/ai/backend/common/validators.py b/src/ai/backend/common/validators.py index e630d663e5..8296bec495 100644 --- a/src/ai/backend/common/validators.py +++ b/src/ai/backend/common/validators.py @@ -245,7 +245,11 @@ def __init__( self._relative_only = relative_only def check_and_return(self, value: Any) -> _PurePath: - p = _PurePath(value) + try: + p = _PurePath(value) + except (TypeError, ValueError): + self._failure("cannot parse value as a path", value=value) + if self._relative_only and p.is_absolute(): self._failure("expected relative path but the value is absolute", value=value) if self._base_path is not None: diff --git a/src/ai/backend/manager/api/__init__.py b/src/ai/backend/manager/api/__init__.py index 1a2c3184c1..fbe6ca4e62 100644 --- a/src/ai/backend/manager/api/__init__.py +++ b/src/ai/backend/manager/api/__init__.py @@ -1,14 +1,14 @@ import enum -class ManagerStatus(str, enum.Enum): +class ManagerStatus(enum.StrEnum): TERMINATED = "terminated" # deprecated PREPARING = "preparing" # deprecated RUNNING = "running" FROZEN = "frozen" -class SchedulerEvent(str, enum.Enum): +class SchedulerEvent(enum.StrEnum): SCHEDULE = "schedule" PREPARE = "prepare" SCALE_SERVICES = "scale_services" diff --git a/src/ai/backend/manager/api/auth.py b/src/ai/backend/manager/api/auth.py index a168454dce..bbdaef66f2 100644 --- a/src/ai/backend/manager/api/auth.py +++ b/src/ai/backend/manager/api/auth.py @@ -25,7 +25,7 @@ from ai.backend.common.plugin.hook import ALL_COMPLETED, FIRST_COMPLETED, PASSED from ai.backend.common.types import ReadableCIDR -from ..models import keypair_resource_policies, keypairs, users +from ..models import keypair_resource_policies, keypairs, user_resource_policies, users from ..models.group import association_groups_users, groups from ..models.keypair import generate_keypair as _gen_keypair from ..models.keypair import generate_ssh_keypair as _gen_ssh_keypair @@ -447,7 +447,40 @@ async def auth_middleware(request: web.Request, handler) -> web.StreamResponse: (request,), return_when=FIRST_COMPLETED, ) - row = None + user_row = None + keypair_row = None + + async def _query_cred(access_key): + async with root_ctx.db.begin_readonly() as conn: + j = keypairs.join( + keypair_resource_policies, + keypairs.c.resource_policy == keypair_resource_policies.c.name, + ) + query = ( + sa.select([keypairs, keypair_resource_policies], use_labels=True) + .select_from(j) + .where( + (keypairs.c.access_key == access_key) & (keypairs.c.is_active.is_(True)), + ) + ) + result = await conn.execute(query) + keypair_row = result.first() + if keypair_row is None: + return None, None + + j = users.join( + user_resource_policies, + users.c.resource_policy == user_resource_policies.c.name, + ) + query = ( + sa.select([users, user_resource_policies], use_labels=True) + .select_from(j) + .where((users.c.main_access_key == access_key)) + ) + result = await conn.execute(query) + user_row = result.first() + return user_row, keypair_row + if hook_result.status != PASSED: raise RejectedByHook.from_hook_result(hook_result) elif hook_result.result: @@ -455,26 +488,10 @@ async def auth_middleware(request: web.Request, handler) -> web.StreamResponse: # The "None" access_key means that the hook has allowed anonymous access. access_key = hook_result.result if access_key is not None: - - async def _query_cred(): - async with root_ctx.db.begin_readonly() as conn: - j = keypairs.join(users, keypairs.c.user == users.c.uuid).join( - keypair_resource_policies, - keypairs.c.resource_policy == keypair_resource_policies.c.name, - ) - query = ( - sa.select([users, keypairs, keypair_resource_policies], use_labels=True) - .select_from(j) - .where( - (keypairs.c.access_key == access_key) - & (keypairs.c.is_active.is_(True)), - ) - ) - result = await conn.execute(query) - return result.first() - - row = await execute_with_retry(_query_cred) - if row is None: + user_row, keypair_row = await execute_with_retry( + functools.partial(_query_cred, access_key) + ) + if keypair_row is None: raise AuthorizationFailed("Access key not found") now = await redis_helper.execute(root_ctx.redis_stat, lambda r: r.time()) @@ -500,28 +517,14 @@ async def _pipe_builder(r: Redis) -> RedisPipeline: params = _extract_auth_params(request) if params: sign_method, access_key, signature = params - - async def _query_cred(): - async with root_ctx.db.begin_readonly() as conn: - j = keypairs.join(users, keypairs.c.user == users.c.uuid).join( - keypair_resource_policies, - keypairs.c.resource_policy == keypair_resource_policies.c.name, - ) - query = ( - sa.select([users, keypairs, keypair_resource_policies], use_labels=True) - .select_from(j) - .where( - (keypairs.c.access_key == access_key) - & (keypairs.c.is_active.is_(True)), - ) - ) - result = await conn.execute(query) - return result.first() - - row = await execute_with_retry(_query_cred) - if row is None: + user_row, keypair_row = await execute_with_retry( + functools.partial(_query_cred, access_key) + ) + if keypair_row is None: raise AuthorizationFailed("Access key not found") - my_signature = await sign_request(sign_method, request, row["keypairs_secret_key"]) + my_signature = await sign_request( + sign_method, request, keypair_row["keypairs_secret_key"] + ) if not secrets.compare_digest(my_signature, signature): raise AuthorizationFailed("Signature mismatch") @@ -543,28 +546,32 @@ async def _pipe_builder(r: Redis) -> RedisPipeline: # unsigned requests may be still accepted for public APIs pass - if row is not None: + if user_row and keypair_row: auth_result = { "is_authorized": True, "keypair": { - col.name: row[f"keypairs_{col.name}"] + col.name: keypair_row[f"keypairs_{col.name}"] for col in keypairs.c if col.name != "secret_key" }, "user": { - col.name: row[f"users_{col.name}"] + col.name: user_row[f"users_{col.name}"] for col in users.c if col.name not in ("password", "description", "created_at") }, - "is_admin": row["keypairs_is_admin"], + "is_admin": keypair_row["keypairs_is_admin"], } validate_ip(request, auth_result["user"]) auth_result["keypair"]["resource_policy"] = { - col.name: row[f"keypair_resource_policies_{col.name}"] + col.name: keypair_row[f"keypair_resource_policies_{col.name}"] for col in keypair_resource_policies.c } - auth_result["user"]["id"] = row["keypairs_user_id"] # legacy + auth_result["user"]["resource_policy"] = { + col.name: user_row[f"user_resource_policies_{col.name}"] + for col in user_resource_policies.c + } + auth_result["user"]["id"] = keypair_row["keypairs_user_id"] # legacy auth_result["is_superadmin"] = auth_result["user"]["role"] == "superadmin" # Populate the result to the per-request state dict. request.update(auth_result) diff --git a/src/ai/backend/manager/api/exceptions.py b/src/ai/backend/manager/api/exceptions.py index ebd0303370..3a1166f7ba 100644 --- a/src/ai/backend/manager/api/exceptions.py +++ b/src/ai/backend/manager/api/exceptions.py @@ -284,6 +284,10 @@ class VFolderAlreadyExists(BackendError, web.HTTPBadRequest): error_title = "The virtual folder already exists with the same name." +class ModelServiceDependencyNotCleared(BackendError, web.HTTPBadRequest): + error_title = "Cannot delete model VFolders bound to alive model services." + + class VFolderOperationFailed(BackendError, web.HTTPBadRequest): error_type = "https://api.backend.ai/probs/vfolder-operation-failed" error_title = "Virtual folder operation has failed." diff --git a/src/ai/backend/manager/api/image.py b/src/ai/backend/manager/api/image.py index 0dfcac2649..5422914b3f 100644 --- a/src/ai/backend/manager/api/image.py +++ b/src/ai/backend/manager/api/image.py @@ -1,485 +1,9 @@ -import base64 -import secrets -from typing import TYPE_CHECKING, Any, Iterable, Tuple +from typing import Iterable, Tuple -import aiohttp_cors -import jinja2 -import sqlalchemy as sa -import trafaret as t +# import aiohttp_cors from aiohttp import web -from ai.backend.common import validators as tx -from ai.backend.common.docker import ImageRef -from ai.backend.common.etcd import quote as etcd_quote -from ai.backend.common.types import SessionTypes - -from ..defs import DEFAULT_IMAGE_ARCH, DEFAULT_ROLE -from ..models import association_groups_users as agus -from ..models import domains, groups, query_allowed_sgroups -from ..types import UserScope -from .auth import admin_required -from .exceptions import InvalidAPIParameters -from .manager import ALL_ALLOWED, READ_ALLOWED, server_status_required from .types import CORSOptions, WebMiddleware -from .utils import check_api_params - -if TYPE_CHECKING: - from .context import RootContext - - -DOCKERFILE_TEMPLATE = r"""# syntax = docker/dockerfile:1.0-experimental -FROM {{ src }} -MAINTAINER Backend.AI Manager - -USER root - -{% if runtime_type == 'python' -%} -ENV PYTHONUNBUFFERED=1 \ - LANG=C.UTF-8 - -RUN --mount=type=bind,source=wheelhouse,target=/root/wheelhouse \ - PIP_OPTS="--no-cache-dir --no-index --find-links=/root/wheelhouse" && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} -U pip setuptools && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} Pillow && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} h5py && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} ipython && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} jupyter && \ - {{ runtime_path }} -m pip install ${PIP_OPTS} jupyterlab - -# Install ipython kernelspec -RUN {{ runtime_path }} -m ipykernel install \ - --prefix={{ runtime_path.parent.parent }} \ - --display-name "{{ brand }} on Backend.AI" -{%- endif %} - -LABEL ai.backend.kernelspec="1" \ - ai.backend.envs.corecount="{{ cpucount_envvars | join(',') }}" \ - ai.backend.features="{% if has_ipykernel %}query batch {% endif %}uid-match" \ - ai.backend.resource.min.cpu="{{ min_cpu }}" \ - ai.backend.resource.min.mem="{{ min_mem }}" \ - ai.backend.resource.preferred.shmem="{{ pref_shmem }}" \ - ai.backend.accelerators="{{ accelerators | join(',') }}" \ -{%- if 'cuda' is in accelerators %} - ai.backend.resource.min.cuda.device=1 \ - ai.backend.resource.min.cuda.shares=0.1 \ -{%- endif %} - ai.backend.base-distro="{{ base_distro }}" \ -{%- if service_ports %} - ai.backend.service-ports="{% for item in service_ports -%} - {{- item['name'] }}: - {{- item['protocol'] }}: - {%- if (item['ports'] | length) > 1 -%} - [{{ item['ports'] | join(',') }}] - {%- else -%} - {{ item['ports'][0] }} - {%- endif -%} - {{- ',' if not loop.last }} - {%- endfor %}" \ -{%- endif %} - ai.backend.runtime-type="{{ runtime_type }}" \ - ai.backend.runtime-path="{{ runtime_path }}" -""" # noqa - - -@server_status_required(READ_ALLOWED) -@admin_required -async def get_import_image_form(request: web.Request) -> web.Response: - root_ctx: RootContext = request.app["_root.context"] - async with root_ctx.db.begin() as conn: - query = ( - sa.select([groups.c.name]) - .select_from( - sa.join( - groups, - domains, - groups.c.domain_name == domains.c.name, - ), - ) - .where( - (domains.c.name == request["user"]["domain_name"]) - & (domains.c.is_active) - & (groups.c.is_active), - ) - ) - result = await conn.execute(query) - rows = result.fetchall() - accessible_groups = [row["name"] for row in rows] - - # FIXME: Currently this only consider domain-level scaling group associations, - # thus ignoring the group name query. - rows = await query_allowed_sgroups( - conn, - request["user"]["domain_name"], - "", - request["keypair"]["access_key"], - ) - accessible_scaling_groups = [row["name"] for row in rows] - - return web.json_response({ - "fieldGroups": [ - { - "name": "Import options", - "fields": [ - { - "name": "src", - "type": "string", - "label": "Source Docker image", - "placeholder": "index.docker.io/lablup/tensorflow:2.0-source", - "help": ( - "The full Docker image name to import from. " - "The registry must be accessible by the client." - ), - }, - { - "name": "target", - "type": "string", - "label": "Target Docker image", - "placeholder": "index.docker.io/lablup/tensorflow:2.0-target", - "help": ( - "The full Docker image name of the imported image." - "The registry must be accessible by the client." - ), - }, - { - "name": "brand", - "type": "string", - "label": "Name of Jupyter kernel", - "placeholder": "TensorFlow 2.0", - "help": ( - "The name of kernel to be shown in the Jupyter's kernel menu. " - 'This will be suffixed with "on Backend.AI".' - ), - }, - { - "name": "baseDistro", - "type": "choice", - "choices": ["ubuntu", "centos"], - "default": "ubuntu", - "label": "Base LINUX distribution", - "help": "The base Linux distribution used by the source image", - }, - { - "name": "minCPU", - "type": "number", - "min": 1, - "max": None, - "label": "Minimum required CPU core(s)", - "help": "The minimum number of CPU cores required by the image", - }, - { - "name": "minMemory", - "type": "binarysize", - "min": "64m", - "max": None, - "label": "Minimum required memory size", - "help": "The minimum size of the main memory required by the image", - }, - { - "name": "preferredSharedMemory", - "type": "binarysize", - "min": "64m", - "max": None, - "label": "Preferred shared memory size", - "help": "The preferred (default) size of the shared memory", - }, - { - "name": "supportedAccelerators", - "type": "multichoice[str]", - "choices": ["cuda"], - "default": "cuda", - "label": "Supported accelerators", - "help": "The list of accelerators supported by the image", - }, - { - "name": "runtimeType", - "type": "choice", - "choices": ["python"], - "default": "python", - "label": "Runtime type of the image", - "help": ( - "The runtime type of the image. Currently, the source image must" - " have installed Python 2.7, 3.5, 3.6, or 3.7 at least to import." - " This will be used as the kernel of Jupyter service in this image." - ), - }, - { - "name": "runtimePath", - "type": "string", - "default": "/usr/local/bin/python", - "label": "Path of the runtime", - "placeholder": "/usr/local/bin/python", - "help": ( - "The path to the main executalbe of runtime language of the image." - ' Even for the same "python"-based images, this may differ' - " significantly image by image. (e.g., /usr/bin/python," - " /usr/local/bin/python, /opt/something/bin/python, ...) Please" - " check this carefully not to get confused with OS-default ones and" - " custom-installed ones." - ), - }, - { - "name": "CPUCountEnvs", - "type": "list[string]", - "default": ["NPROC", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS"], - "label": "CPU count environment variables", - "help": ( - "The name of environment variables to be overriden to the number of" - " CPU cores actually allocated to the container. Required for" - " legacy computation libraries." - ), - }, - { - "name": "servicePorts", - "type": "multichoice[template]", - "templates": [ - {"name": "jupyter", "protocol": "http", "ports": [8080]}, - {"name": "jupyterlab", "protocol": "http", "ports": [8090]}, - {"name": "tensorboard", "protocol": "http", "ports": [6006]}, - {"name": "digits", "protocol": "http", "ports": [5000]}, - {"name": "vscode", "protocol": "http", "ports": [8180]}, - {"name": "h2o-dai", "protocol": "http", "ports": [12345]}, - ], - "label": "Supported service ports", - "help": ( - "The list of service ports supported by this image. " - "Note that sshd (port 2200) and ttyd (port 7681) are intrinsic; " - "they are always included regardless of the source image. " - "The port number 2000-2003 are reserved by Backend.AI, and " - "all port numbers must be larger than 1024 and smaller than 65535." - ), - }, - ], - }, - { - "name": "Import Task Options", - "help": "The import task uses 1 CPU core and 2 GiB of memory.", - "fields": [ - { - "name": "group", - "type": "choice", - "choices": accessible_groups, - "label": "Group to build image", - "help": "The user group where the import task will be executed.", - }, - { - "name": "scalingGroup", - "type": "choice", - "choices": accessible_scaling_groups, - "label": "Scaling group to build image", - "help": "The scaling group where the import task will take resources from.", - }, - ], - }, - ], - }) - - -@server_status_required(ALL_ALLOWED) -@admin_required -@check_api_params( - t.Dict({ - t.Key("src"): t.String, - t.Key("target"): t.String, - t.Key("architecture", default=DEFAULT_IMAGE_ARCH): t.String, - t.Key("launchOptions", default={}): t.Dict({ - t.Key("scalingGroup", default="default"): t.String, - t.Key("group", default="default"): t.String, - }).allow_extra("*"), - t.Key("brand"): t.String, - t.Key("baseDistro"): t.Enum("ubuntu", "centos"), - t.Key("minCPU", default=1): t.Int[1:], - t.Key("minMemory", default="64m"): tx.BinarySize, - t.Key("preferredSharedMemory", default="64m"): tx.BinarySize, - t.Key("supportedAccelerators"): t.List(t.String), - t.Key("runtimeType"): t.Enum("python"), - t.Key("runtimePath"): tx.Path(type="file", allow_nonexisting=True, resolve=False), - t.Key("CPUCountEnvs"): t.List(t.String), - t.Key("servicePorts", default=[]): t.List( - t.Dict({ - t.Key("name"): t.String, - t.Key("protocol"): t.Enum("http", "tcp", "pty"), - t.Key("ports"): t.List(t.Int[1:65535], min_length=1), - }) - ), - }).allow_extra("*") -) -async def import_image(request: web.Request, params: Any) -> web.Response: - """ - Import a docker image and convert it to a Backend.AI-compatible one, - by automatically installing a few packages and adding image labels. - - Currently we only support auto-conversion of Python-based kernels (e.g., - NGC images) which has its own Python version installed. - - Internally, it launches a temporary kernel in an arbitrary agent within - the client's domain, the "default" group, and the "default" scaling group. - (The client may change the group and scaling group using *launchOptions.* - If the client is a super-admin, it uses the "default" domain.) - - This temporary kernel occupies only 1 CPU core and 1 GiB memory. - The kernel concurrency limit is not applied here, but we choose an agent - based on their resource availability. - The owner of this kernel is always the client that makes the API request. - - This API returns immediately after launching the temporary kernel. - The client may check the progress of the import task using session logs. - """ - - tpl = jinja2.Template(DOCKERFILE_TEMPLATE) - root_ctx: RootContext = request.app["_root.context"] - - async with root_ctx.db.begin() as conn: - query = ( - sa.select([domains.c.allowed_docker_registries]) - .select_from(domains) - .where(domains.c.name == request["user"]["domain_name"]) - ) - result = await conn.execute(query) - allowed_docker_registries = result.scalar() - - # TODO: select agent to run image builder based on image architecture - source_image = ImageRef(params["src"], allowed_docker_registries, params["architecture"]) - target_image = ImageRef(params["target"], allowed_docker_registries, params["architecture"]) - - # TODO: validate and convert arguments to template variables - dockerfile_content = tpl.render({ - "base_distro": params["baseDistro"], - "cpucount_envvars": ["NPROC", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS"], - "runtime_type": params["runtimeType"], - "runtime_path": params["runtimePath"], - "service_ports": params["servicePorts"], - "min_cpu": params["minCPU"], - "min_mem": params["minMemory"], - "pref_shmem": params["preferredSharedMemory"], - "accelerators": params["supportedAccelerators"], - "src": params["src"], - "brand": params["brand"], - "has_ipykernel": ( - True - ), # TODO: in the future, we may allow import of service-port only kernels. - }) - - session_creation_id = secrets.token_urlsafe(32) - session_id = f"image-import-{secrets.token_urlsafe(8)}" - access_key = request["keypair"]["access_key"] - resource_policy = request["keypair"]["resource_policy"] - - async with root_ctx.db.begin() as conn: - query = ( - sa.select([groups.c.id]) - .select_from( - sa.join( - groups, - domains, - groups.c.domain_name == domains.c.name, - ), - ) - .where( - (domains.c.name == request["user"]["domain_name"]) - & (groups.c.name == params["launchOptions"]["group"]) - & (domains.c.is_active) - & (groups.c.is_active), - ) - ) - result = await conn.execute(query) - group_id = result.scalar() - if group_id is None: - raise InvalidAPIParameters("Invalid domain or group.") - - query = ( - sa.select([agus]) - .select_from(agus) - .where( - (agus.c.user_id == request["user"]["uuid"]) & (agus.c.group_id == group_id), - ) - ) - result = await conn.execute(query) - row = result.first() - if row is None: - raise InvalidAPIParameters("You do not belong to the given group.") - - importer_image = ImageRef( - root_ctx.local_config["manager"]["importer-image"], - allowed_docker_registries, - params["architecture"], - ) - - docker_creds = {} - for img_ref in (source_image, target_image): - registry_info = await root_ctx.shared_config.etcd.get_prefix_dict( - f"config/docker/registry/{etcd_quote(img_ref.registry)}" - ) - docker_creds[img_ref.registry] = { - "username": registry_info.get("username"), - "password": registry_info.get("password"), - } - - kernel_id = await root_ctx.registry.enqueue_session( - session_creation_id, - session_id, - access_key, - { - "creation_config": { - "scaling_group": params["launchOptions"]["scalingGroup"], - "environ": { - "SRC_IMAGE": source_image.canonical, - "TARGET_IMAGE": target_image.canonical, - "RUNTIME_PATH": params["runtimePath"], - "BUILD_SCRIPT": base64.b64encode(dockerfile_content.encode("utf8")).decode( - "ascii" - ), - }, - }, - "kernel_configs": [ - { - "image_ref": importer_image, - "cluster_role": DEFAULT_ROLE, - "cluster_idx": 1, - "local_rank": 0, - "cluster_hostname": f"{DEFAULT_ROLE}1", - "creation_config": { - "resources": {"cpu": "1", "mem": "2g"}, - "scaling_group": params["launchOptions"]["scalingGroup"], - "environ": { - "SRC_IMAGE": source_image.canonical, - "TARGET_IMAGE": target_image.canonical, - "RUNTIME_PATH": params["runtimePath"], - "BUILD_SCRIPT": base64.b64encode( - dockerfile_content.encode("utf8") - ).decode("ascii"), - }, - }, - "startup_command": "/root/build-image.sh", - "bootstrap_script": "", - }, - ], - }, - None, - SessionTypes.BATCH, - resource_policy, - user_scope=UserScope( - domain_name=request["user"]["domain_name"], - group_id=group_id, - user_uuid=request["user"]["uuid"], - user_role=request["user"]["role"], - ), - internal_data={ - "domain_socket_proxies": ["/var/run/docker.sock"], - "docker_credentials": docker_creds, - "prevent_vfolder_mounts": True, - "block_service_ports": True, - }, - sudo_session_enabled=False, - ) - return web.json_response( - { - "importTask": { - "sessionId": session_id, - "taskId": str(kernel_id), - }, - }, - status=200, - ) async def init(app: web.Application) -> None: @@ -498,7 +22,5 @@ def create_app( app.on_shutdown.append(shutdown) app["prefix"] = "image" app["api_versions"] = (4,) - cors = aiohttp_cors.setup(app, defaults=default_cors_options) - cors.add(app.router.add_route("GET", "/import", get_import_image_form)) - cors.add(app.router.add_route("POST", "/import", import_image)) + # cors = aiohttp_cors.setup(app, defaults=default_cors_options) return app, [] diff --git a/src/ai/backend/manager/api/logs.py b/src/ai/backend/manager/api/logs.py index 49449ea03d..cd99713250 100644 --- a/src/ai/backend/manager/api/logs.py +++ b/src/ai/backend/manager/api/logs.py @@ -69,7 +69,7 @@ async def append(request: web.Request, params: Any) -> web.Response: "success": True, } query = error_logs.insert().values({ - "severity": params["severity"], + "severity": params["severity"].lower(), "source": params["source"], "user": requester_uuid, "message": params["message"], diff --git a/src/ai/backend/manager/api/service.py b/src/ai/backend/manager/api/service.py index 84db0fa92c..e940ee4aec 100644 --- a/src/ai/backend/manager/api/service.py +++ b/src/ai/backend/manager/api/service.py @@ -1,5 +1,9 @@ +import asyncio +import json import logging +import secrets import uuid +from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Iterable, Tuple @@ -26,11 +30,23 @@ from yarl import URL from ai.backend.common import typed_validators as tv +from ai.backend.common.bgtask import ProgressReporter from ai.backend.common.config import model_definition_iv from ai.backend.common.docker import ImageRef -from ai.backend.common.events import KernelLifecycleEventReason +from ai.backend.common.events import ( + EventHandler, + KernelLifecycleEventReason, + ModelServiceStatusEvent, + SessionCancelledEvent, + SessionEnqueuedEvent, + SessionPreparingEvent, + SessionStartedEvent, + SessionTerminatedEvent, +) from ai.backend.common.logging import BraceStyleAdapter from ai.backend.common.types import ( + AccessKey, + AgentId, ClusterMode, ImageAlias, SessionTypes, @@ -45,8 +61,11 @@ EndpointRow, EndpointTokenRow, ImageRow, + KernelLoadingStrategy, + KeyPairRow, RouteStatus, RoutingRow, + SessionRow, UserRow, query_accessible_vfolders, resolve_group_name_or_id, @@ -60,6 +79,7 @@ from .session import query_userinfo from .types import CORSOptions, WebMiddleware from .utils import ( + BaseResponseModel, get_access_key_scopes, get_user_uuid_scopes, pydantic_params_api_handler, @@ -93,7 +113,7 @@ class ListServeRequestModel(BaseModel): name: str | None = Field(default=None) -class SuccessResponseModel(BaseModel): +class SuccessResponseModel(BaseResponseModel): success: bool = Field(default=True) @@ -173,7 +193,7 @@ class RouteInfoModel(BaseModel): traffic_ratio: NonNegativeFloat -class ServeInfoModel(BaseModel): +class ServeInfoModel(BaseResponseModel): endpoint_id: uuid.UUID = Field(description="Unique ID referencing the model service.") name: str = Field(description="Name of the model service.") desired_session_count: NonNegativeInt = Field( @@ -322,21 +342,30 @@ class NewServiceRequestModel(BaseModel): config: ServiceConfigModel -@auth_required -@server_status_required(ALL_ALLOWED) -@pydantic_params_api_handler(NewServiceRequestModel) -async def create(request: web.Request, params: NewServiceRequestModel) -> SuccessResponseModel: - """ - Creates a new model service. If `desired_session_count` is greater than zero, - then inference sessions will be automatically scheduled upon successful creation of model service. - """ +@dataclass +class ValidationResult: + model_id: uuid.UUID + requester_access_key: AccessKey + owner_access_key: AccessKey + owner_uuid: uuid.UUID + group_id: uuid.UUID + resource_policy: dict + scaling_group: str + + +async def _validate(request: web.Request, params: NewServiceRequestModel) -> ValidationResult: root_ctx: RootContext = request.app["_root.context"] scopes_param = { "owner_access_key": ( None if params.owner_access_key is undefined else params.owner_access_key ), } + requester_access_key, owner_access_key = await get_access_key_scopes(request, scopes_param) + if params.desired_session_count > ( + _m := request["user"]["resource_policy"]["max_session_count_per_model_session"] + ): + raise InvalidAPIParameters(f"Cannot spawn more than {_m} sessions for a single service") async with root_ctx.db.begin_readonly() as conn: checked_scaling_group = await check_scaling_group( @@ -457,6 +486,29 @@ async def create(request: web.Request, params: NewServiceRequestModel) -> Succes except yaml.error.YAMLError as e: raise InvalidAPIParameters(f"Invalid YAML syntax: {e}") from e + return ValidationResult( + model_id, + requester_access_key, + owner_access_key, + owner_uuid, + group_id, + resource_policy, + checked_scaling_group, + ) + + +@auth_required +@server_status_required(ALL_ALLOWED) +@pydantic_params_api_handler(NewServiceRequestModel) +async def create(request: web.Request, params: NewServiceRequestModel) -> SuccessResponseModel: + """ + Creates a new model service. If `desired_session_count` is greater than zero, + then inference sessions will be automatically scheduled upon successful creation of model service. + """ + root_ctx: RootContext = request.app["_root.context"] + + validation_result = await _validate(request, params) + async with root_ctx.db.begin_readonly_session() as session: image_row = await ImageRow.resolve( session, @@ -467,7 +519,9 @@ async def create(request: web.Request, params: NewServiceRequestModel) -> Succes ) creation_config = params.config.model_dump() - creation_config["mount_map"] = {model_id: params.config.model_mount_destination} + creation_config["mount_map"] = { + validation_result.model_id: params.config.model_mount_destination + } sudo_session_enabled = request["user"]["sudo_session_enabled"] # check if session is valid to be created @@ -477,12 +531,12 @@ async def create(request: web.Request, params: NewServiceRequestModel) -> Succes params.architecture, UserScope( domain_name=params.domain, - group_id=group_id, + group_id=validation_result.group_id, user_uuid=request["user"]["uuid"], user_role=request["user"]["role"], ), - owner_access_key, - resource_policy, + validation_result.owner_access_key, + validation_result.resource_policy, SessionTypes.INFERENCE, creation_config, params.cluster_mode, @@ -513,13 +567,13 @@ async def create(request: web.Request, params: NewServiceRequestModel) -> Succes endpoint = EndpointRow( params.service_name, request["user"]["uuid"], - owner_uuid, + validation_result.owner_uuid, params.desired_session_count, image_row, - model_id, + validation_result.model_id, params.domain, project_id, - checked_scaling_group, + validation_result.scaling_group, params.config.resources, params.cluster_mode, params.cluster_size, @@ -538,6 +592,161 @@ async def create(request: web.Request, params: NewServiceRequestModel) -> Succes return SuccessResponseModel() +class TryStartResponseModel(BaseModel): + task_id: str + + +@auth_required +@server_status_required(ALL_ALLOWED) +@pydantic_params_api_handler(NewServiceRequestModel) +async def try_start(request: web.Request, params: NewServiceRequestModel) -> TryStartResponseModel: + root_ctx: RootContext = request.app["_root.context"] + background_task_manager = root_ctx.background_task_manager + + validation_result = await _validate(request, params) + + async with root_ctx.db.begin_readonly_session() as session: + image_row = await ImageRow.resolve( + session, + [ + ImageRef(params.image, ["*"], params.architecture), + ImageAlias(params.image), + ], + ) + query = sa.select(sa.join(UserRow, KeyPairRow, KeyPairRow.user == UserRow.uuid)).where( + UserRow.uuid == request["user"]["uuid"] + ) + created_user = (await session.execute(query)).fetchone() + + creation_config = params.config.model_dump() + creation_config["mount_map"] = { + validation_result.model_id: params.config.model_mount_destination + } + sudo_session_enabled = request["user"]["sudo_session_enabled"] + + async def _task(reporter: ProgressReporter) -> None: + terminated_event = asyncio.Event() + + result = await root_ctx.registry.create_session( + f"model-eval-{secrets.token_urlsafe(16)}", + image_row.name, + image_row.architecture, + UserScope( + domain_name=params.domain, + group_id=validation_result.group_id, + user_uuid=created_user.uuid, + user_role=created_user.role, + ), + validation_result.owner_access_key, + validation_result.resource_policy, + SessionTypes.INFERENCE, + { + "mounts": [validation_result.model_id], + "mount_map": { + validation_result.model_id: creation_config["model_mount_destination"] + }, + "environ": creation_config["environ"], + "scaling_group": validation_result.scaling_group, + "resources": creation_config["resources"], + "resource_opts": creation_config["resource_opts"], + "preopen_ports": None, + "agent_list": None, + }, + params.cluster_mode, + params.cluster_size, + bootstrap_script=params.bootstrap_script, + startup_command=params.startup_command, + tag=params.tag, + callback_url=URL(params.callback_url.unicode_string()) if params.callback_url else None, + enqueue_only=True, + sudo_session_enabled=sudo_session_enabled, + ) + + await reporter.update( + message=json.dumps({ + "event": "session_enqueued", + "session_id": str(result["sessionId"]), + }) + ) + + async def _handle_event( + context: None, + source: AgentId, + event: SessionEnqueuedEvent + | SessionPreparingEvent + | SessionStartedEvent + | SessionCancelledEvent + | SessionTerminatedEvent + | ModelServiceStatusEvent, + ) -> None: + task_message = {"event": event.name, "session_id": str(event.session_id)} + match event: + case ModelServiceStatusEvent(): + task_message["is_healthy"] = event.new_status.value + await reporter.update(message=json.dumps(task_message)) + + match event: + case SessionTerminatedEvent() | SessionCancelledEvent(): + terminated_event.set() + case ModelServiceStatusEvent(): + async with root_ctx.db.begin_readonly_session() as db_sess: + session = await SessionRow.get_session( + db_sess, + result["sessionId"], + None, + kernel_loading_strategy=KernelLoadingStrategy.ALL_KERNELS, + ) + await root_ctx.registry.destroy_session( + session, + forced=True, + ) + + session_event_matcher = lambda args: args[0] == str(result["sessionId"]) + model_service_event_matcher = lambda args: args[1] == str(result["sessionId"]) + + handlers: list[EventHandler] = [ + root_ctx.event_dispatcher.subscribe( + SessionPreparingEvent, + None, + _handle_event, + args_matcher=session_event_matcher, + ), + root_ctx.event_dispatcher.subscribe( + SessionStartedEvent, + None, + _handle_event, + args_matcher=session_event_matcher, + ), + root_ctx.event_dispatcher.subscribe( + SessionCancelledEvent, + None, + _handle_event, + args_matcher=session_event_matcher, + ), + root_ctx.event_dispatcher.subscribe( + SessionTerminatedEvent, + None, + _handle_event, + args_matcher=session_event_matcher, + ), + root_ctx.event_dispatcher.subscribe( + ModelServiceStatusEvent, + None, + _handle_event, + args_matcher=model_service_event_matcher, + ), + ] + + try: + await terminated_event.wait() + finally: + for handler in handlers: + root_ctx.event_dispatcher.unsubscribe(handler) + + task_id = await background_task_manager.start(_task) + return TryStartResponseModel(task_id=str(task_id)) + + @auth_required @server_status_required(READ_ALLOWED) @pydantic_response_api_handler @@ -613,7 +822,7 @@ class ScaleRequestModel(BaseModel): to: int = Field(description="Ideal number of inference sessions") -class ScaleResponseModel(BaseModel): +class ScaleResponseModel(BaseResponseModel): current_route_count: int target_count: int @@ -643,6 +852,10 @@ async def scale(request: web.Request, params: ScaleRequestModel) -> ScaleRespons if params.to < 0: raise InvalidAPIParameters("Amount of desired session count cannot be a negative number") + elif params.to > ( + _m := request["user"]["resource_policy"]["max_session_count_per_model_session"] + ): + raise InvalidAPIParameters(f"Cannot spawn more than {_m} sessions for a single service") async with root_ctx.db.begin_session() as db_sess: query = ( @@ -759,7 +972,7 @@ class TokenRequestModel(BaseModel): ) -class TokenResponseModel(BaseModel): +class TokenResponseModel(BaseResponseModel): token: str @@ -840,7 +1053,7 @@ class ErrorInfoModel(BaseModel): error: dict[str, Any] -class ErrorListResponseModel(BaseModel): +class ErrorListResponseModel(BaseResponseModel): errors: list[ErrorInfoModel] retries: int @@ -947,6 +1160,7 @@ def create_app( root_resource = cors.add(app.router.add_resource(r"")) cors.add(root_resource.add_route("GET", list_serve)) cors.add(root_resource.add_route("POST", create)) + cors.add(add_route("POST", "/_/try", try_start)) cors.add(add_route("GET", "/{service_id}", get_info)) cors.add(add_route("DELETE", "/{service_id}", delete)) cors.add(add_route("GET", "/{service_id}/errors", list_errors)) diff --git a/src/ai/backend/manager/api/utils.py b/src/ai/backend/manager/api/utils.py index 6f4d4fed12..eaa1a43345 100644 --- a/src/ai/backend/manager/api/utils.py +++ b/src/ai/backend/manager/api/utils.py @@ -13,6 +13,7 @@ from collections import defaultdict from typing import ( TYPE_CHECKING, + Annotated, Any, Awaitable, Callable, @@ -31,9 +32,9 @@ import sqlalchemy as sa import trafaret as t import yaml -from aiohttp import web, web_response +from aiohttp import web from aiohttp.typedefs import Handler -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import BaseModel, Field, TypeAdapter, ValidationError from ai.backend.common.logging import BraceStyleAdapter from ai.backend.common.types import AccessKey @@ -212,9 +213,13 @@ async def wrapped(request: web.Request, *args: P.args, **kwargs: P.kwargs) -> TA return wrap +class BaseResponseModel(BaseModel): + status: Annotated[int, Field(strict=True, ge=100, lt=600)] = 200 + + TParamModel = TypeVar("TParamModel", bound=BaseModel) TQueryModel = TypeVar("TQueryModel", bound=BaseModel) -TResponseModel = TypeVar("TResponseModel", bound=BaseModel) +TResponseModel = TypeVar("TResponseModel", bound=BaseResponseModel) TPydanticResponse: TypeAlias = TResponseModel | list THandlerFuncWithoutParam: TypeAlias = Callable[ @@ -226,16 +231,14 @@ async def wrapped(request: web.Request, *args: P.args, **kwargs: P.kwargs) -> TA def ensure_stream_response_type( - response: TResponseModel | list | TAnyResponse, + response: BaseResponseModel | list[TResponseModel] | web.StreamResponse, ) -> web.StreamResponse: match response: - case BaseModel(): - return web.json_response(response.model_dump(mode="json")) + case BaseResponseModel(status=status): + return web.json_response(response.model_dump(mode="json"), status=status) case list(): - return web.json_response( - TypeAdapter(list[TResponseModel]).dump_python(response, mode="json") - ) - case web_response.StreamResponse(): + return web.json_response(TypeAdapter(type(response)).dump_python(response, mode="json")) + case web.StreamResponse(): return response case _: raise RuntimeError(f"Unsupported response type ({type(response)})") diff --git a/src/ai/backend/manager/api/vfolder.py b/src/ai/backend/manager/api/vfolder.py index 566b51b244..e487bc1a8d 100644 --- a/src/ai/backend/manager/api/vfolder.py +++ b/src/ai/backend/manager/api/vfolder.py @@ -58,6 +58,8 @@ from ..models import ( ACTIVE_USER_STATUSES, AgentStatus, + EndpointLifecycle, + EndpointRow, GroupRow, KernelStatus, ProjectResourcePolicyRow, @@ -103,6 +105,7 @@ InsufficientPrivilege, InternalServerError, InvalidAPIParameters, + ModelServiceDependencyNotCleared, ObjectNotFound, TooManyVFoldersFound, VFolderAlreadyExists, @@ -115,6 +118,7 @@ from .manager import ALL_ALLOWED, READ_ALLOWED, server_status_required from .resource import get_watcher_info from .utils import ( + BaseResponseModel, check_api_params, get_user_scopes, pydantic_params_api_handler, @@ -129,9 +133,8 @@ P = ParamSpec("P") -class SuccessResponseModel(BaseModel): +class SuccessResponseModel(BaseResponseModel): success: bool = Field(default=True) - status: int = Field(default=200) async def ensure_vfolder_status( @@ -579,7 +582,6 @@ async def create(request: web.Request, params: Any) -> web.Response: "usage_mode": params["usage_mode"], "permission": params["permission"], "last_used": None, - "max_size": int(params["quota"] / (2**20)) if params["quota"] else None, # in MBytes "host": folder_host, "creator": request["user"]["email"], "ownership_type": VFolderOwnershipType(ownership_type), @@ -596,7 +598,7 @@ async def create(request: web.Request, params: Any) -> web.Response: "host": folder_host, "usage_mode": params["usage_mode"].value, "permission": params["permission"].value, - "max_size": int(params["quota"] / (2**20)) if params["quota"] else None, # in MBytes + "max_size": 0, # migrated to quota scopes, no longer valid "creator": request["user"]["email"], "ownership_type": ownership_type, "user": str(user_uuid) if ownership_type == "user" else None, @@ -1250,18 +1252,20 @@ async def update_vfolder_options( @vfolder_permission_required(VFolderPermission.READ_WRITE) @check_api_params( t.Dict({ - t.Key("path"): t.String, + t.Key("path"): t.String | t.List(t.String), t.Key("parents", default=True): t.ToBool, t.Key("exist_ok", default=False): t.ToBool, }) ) async def mkdir(request: web.Request, params: Any, row: VFolderRow) -> web.Response: + if isinstance(params["path"], list) and len(params["path"]) > 50: + raise InvalidAPIParameters("Too many directories specified.") await ensure_vfolder_status(request, VFolderAccessStatus.UPDATABLE, request.match_info["name"]) root_ctx: RootContext = request.app["_root.context"] folder_name = request.match_info["name"] access_key = request["keypair"]["access_key"] log.info( - "VFOLDER.MKDIR (email:{}, ak:{}, vf:{}, path:{})", + "VFOLDER.MKDIR (email:{}, ak:{}, vf:{}, paths:{})", request["user"]["email"], access_key, folder_name, @@ -1279,9 +1283,14 @@ async def mkdir(request: web.Request, params: Any, row: VFolderRow) -> web.Respo "parents": params["parents"], "exist_ok": params["exist_ok"], }, - ): - pass - return web.Response(status=201) + ) as (_, storage_resp): + storage_reply = await storage_resp.json() + match storage_resp.status: + case 200 | 207: + return web.json_response(storage_reply, status=storage_resp.status) + # 422 will be wrapped as VFolderOperationFailed by storage_manager + case _: + raise RuntimeError("should not reach here") @auth_required @@ -2203,6 +2212,17 @@ async def _delete( # Folder owner OR user who have DELETE permission can delete folder. if not entry["is_owner"] and entry["permission"] != VFolderPermission.RW_DELETE: raise InvalidAPIParameters("Cannot delete the vfolder that is not owned by myself.") + # perform extra check to make sure records of alive model service not removed by foreign key rule + if entry["usage_mode"] == VFolderUsageMode.MODEL: + async with root_ctx.db._begin_session(conn) as sess: + live_endpoints = await EndpointRow.list_by_model(sess, entry["id"]) + if ( + len([ + e for e in live_endpoints if e.lifecycle_stage == EndpointLifecycle.CREATED + ]) + > 0 + ): + raise ModelServiceDependencyNotCleared folder_host = entry["host"] await ensure_host_permission_allowed( conn, @@ -2319,7 +2339,7 @@ class IDRequestModel(BaseModel): ) -class CompactVFolderInfoModel(BaseModel): +class CompactVFolderInfoModel(BaseResponseModel): id: uuid.UUID = Field(description="Unique ID referencing the vfolder.") name: str = Field(description="Name of the vfolder.") diff --git a/src/ai/backend/manager/cli/__main__.py b/src/ai/backend/manager/cli/__main__.py index a6c20bc564..100a487276 100644 --- a/src/ai/backend/manager/cli/__main__.py +++ b/src/ai/backend/manager/cli/__main__.py @@ -46,15 +46,15 @@ ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context def main( ctx: click.Context, config_path: pathlib.Path, - log_level: str, + log_level: LogSeverity, debug: bool, ) -> None: """ diff --git a/src/ai/backend/manager/cli/context.py b/src/ai/backend/manager/cli/context.py index 5245e7fdb9..baded4bd58 100644 --- a/src/ai/backend/manager/cli/context.py +++ b/src/ai/backend/manager/cli/context.py @@ -15,7 +15,7 @@ from ai.backend.common.etcd import AsyncEtcd, ConfigScopes from ai.backend.common.exception import ConfigurationError from ai.backend.common.logging import AbstractLogger, LocalLogger -from ai.backend.common.types import RedisConnectionInfo +from ai.backend.common.types import LogSeverity, RedisConnectionInfo from ..config import LocalConfig, SharedConfig from ..config import load as load_config @@ -25,7 +25,7 @@ class CLIContext: _local_config: LocalConfig | None _logger: AbstractLogger - def __init__(self, config_path: Path, log_level: str) -> None: + def __init__(self, config_path: Path, log_level: LogSeverity) -> None: self.config_path = config_path self.log_level = log_level self._local_config = None diff --git a/src/ai/backend/manager/config.py b/src/ai/backend/manager/config.py index 7f8d440791..9b3a4ed541 100644 --- a/src/ai/backend/manager/config.py +++ b/src/ai/backend/manager/config.py @@ -211,6 +211,7 @@ from ai.backend.common.logging import BraceStyleAdapter from ai.backend.common.types import ( HostPortPair, + LogSeverity, RoundRobinState, SlotName, SlotTypes, @@ -504,7 +505,10 @@ async def reload(self) -> None: raise NotImplementedError -def load(config_path: Optional[Path] = None, log_level: str = "INFO") -> LocalConfig: +def load( + config_path: Optional[Path] = None, + log_level: LogSeverity = LogSeverity.INFO, +) -> LocalConfig: # Determine where to read configuration. raw_cfg, cfg_src_path = config.read_from_file(config_path, "manager") @@ -535,10 +539,10 @@ def load(config_path: Optional[Path] = None, log_level: str = "INFO") -> LocalCo raw_cfg, ("docker-registry", "ssl-verify"), "BACKEND_SKIP_SSLCERT_VALIDATION" ) - config.override_key(raw_cfg, ("debug", "enabled"), log_level == "DEBUG") - config.override_key(raw_cfg, ("logging", "level"), log_level.upper()) - config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level.upper()) - config.override_key(raw_cfg, ("logging", "pkg-ns", "aiohttp"), log_level.upper()) + config.override_key(raw_cfg, ("debug", "enabled"), log_level == LogSeverity.DEBUG) + config.override_key(raw_cfg, ("logging", "level"), log_level) + config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level) + config.override_key(raw_cfg, ("logging", "pkg-ns", "aiohttp"), log_level) # Validate and fill configurations # (allow_extra will make configs to be forward-copmatible) diff --git a/src/ai/backend/manager/idle.py b/src/ai/backend/manager/idle.py index 32fa2bca85..d0323857ca 100644 --- a/src/ai/backend/manager/idle.py +++ b/src/ai/backend/manager/idle.py @@ -163,7 +163,7 @@ class ThresholdOperator(enum.Enum): OR = "or" -class RemainingTimeType(str, enum.Enum): +class RemainingTimeType(enum.StrEnum): GRACE_PERIOD = "grace_period" EXPIRE_AFTER = "expire_after" diff --git a/src/ai/backend/manager/models/alembic/versions/308bcecec5c2_add_groups_type.py b/src/ai/backend/manager/models/alembic/versions/308bcecec5c2_add_groups_type.py index 03e0324958..4a31a3e69d 100644 --- a/src/ai/backend/manager/models/alembic/versions/308bcecec5c2_add_groups_type.py +++ b/src/ai/backend/manager/models/alembic/versions/308bcecec5c2_add_groups_type.py @@ -27,7 +27,7 @@ MAXIMUM_DOTFILE_SIZE = 64 * 1024 # 61 KiB -class ProjectType(str, enum.Enum): +class ProjectType(enum.StrEnum): GENERAL = "general" MODEL_STORE = "model-store" @@ -107,7 +107,7 @@ class ProjectType(str, enum.Enum): ) -projecttype_choices = list(map(lambda v: v.value, ProjectType)) +projecttype_choices = list(map(str, ProjectType)) projecttype = postgresql.ENUM( *projecttype_choices, name="projecttype", diff --git a/src/ai/backend/manager/models/alembic/versions/3f47af213b05_add_max_pending_session_count.py b/src/ai/backend/manager/models/alembic/versions/3f47af213b05_add_max_pending_session_count.py new file mode 100644 index 0000000000..660f53ee42 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/3f47af213b05_add_max_pending_session_count.py @@ -0,0 +1,41 @@ +"""add_max_pending_session_count + +Revision ID: 3f47af213b05 +Revises: 41f332243bf9 +Create Date: 2023-09-27 15:09:00.419228 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "3f47af213b05" +down_revision = "41f332243bf9" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "keypair_resource_policies", + sa.Column("max_pending_session_count", sa.Integer(), nullable=True), + ) + op.add_column( + "keypair_resource_policies", + sa.Column( + "max_pending_session_resource_slots", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("keypair_resource_policies", "max_pending_session_resource_slots") + op.drop_column("keypair_resource_policies", "max_pending_session_count") + # ### end Alembic commands ### diff --git a/src/ai/backend/manager/models/alembic/versions/41f332243bf9_add_missing_vfolder_indexes.py b/src/ai/backend/manager/models/alembic/versions/41f332243bf9_add_missing_vfolder_indexes.py new file mode 100644 index 0000000000..ce67bb307b --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/41f332243bf9_add_missing_vfolder_indexes.py @@ -0,0 +1,31 @@ +"""add-missing-vfolder-indexes + +Revision ID: 41f332243bf9 +Revises: 7ff52ff68bfc +Create Date: 2024-02-28 17:27:40.387122 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "41f332243bf9" +down_revision = "7ff52ff68bfc" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index(op.f("ix_vfolders_host"), "vfolders", ["host"], unique=False) + op.create_index( + op.f("ix_vfolders_ownership_type"), "vfolders", ["ownership_type"], unique=False + ) + op.create_index(op.f("ix_vfolders_status"), "vfolders", ["status"], unique=False) + op.create_index(op.f("ix_vfolders_usage_mode"), "vfolders", ["usage_mode"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_vfolders_usage_mode"), table_name="vfolders") + op.drop_index(op.f("ix_vfolders_status"), table_name="vfolders") + op.drop_index(op.f("ix_vfolders_ownership_type"), table_name="vfolders") + op.drop_index(op.f("ix_vfolders_host"), table_name="vfolders") diff --git a/src/ai/backend/manager/models/alembic/versions/529113b08c2c_add_vfolder_type_column.py b/src/ai/backend/manager/models/alembic/versions/529113b08c2c_add_vfolder_type_column.py index fe776d8c39..6ceb37291f 100644 --- a/src/ai/backend/manager/models/alembic/versions/529113b08c2c_add_vfolder_type_column.py +++ b/src/ai/backend/manager/models/alembic/versions/529113b08c2c_add_vfolder_type_column.py @@ -24,16 +24,16 @@ branch_labels = None depends_on = None -vfperm_choices = list(map(lambda v: v.value, VFolderPermission)) +vfperm_choices = list(map(str, VFolderPermission)) # vfolderpermission type should already be defined. -vfusagemode_choices = list(map(lambda v: v.value, VFolderUsageMode)) +vfusagemode_choices = list(map(str, VFolderUsageMode)) vfolderusagemode = postgresql.ENUM( *vfusagemode_choices, name="vfolderusagemode", ) -vfownershiptype_choices = list(map(lambda v: v.value, VFolderOwnershipType)) +vfownershiptype_choices = list(map(str, VFolderOwnershipType)) vfolderownershiptype = postgresql.ENUM( *vfownershiptype_choices, name="vfolderownershiptype", diff --git a/src/ai/backend/manager/models/alembic/versions/589c764a18f1_change_endpoint_to_nullable.py b/src/ai/backend/manager/models/alembic/versions/589c764a18f1_change_endpoint_to_nullable.py new file mode 100644 index 0000000000..02e9d145ce --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/589c764a18f1_change_endpoint_to_nullable.py @@ -0,0 +1,98 @@ +"""change_endpoint_to_nullable + +Revision ID: 589c764a18f1 +Revises: 3f47af213b05 +Create Date: 2024-02-27 20:18:55.524946 + +""" + +import sqlalchemy as sa +from alembic import op + +from ai.backend.manager.models.base import GUID, convention + +# revision identifiers, used by Alembic. +revision = "589c764a18f1" +down_revision = "3f47af213b05" +branch_labels = None +depends_on = None + + +metadata = sa.MetaData(naming_convention=convention) + + +BATCH_SIZE = 100 + + +def upgrade(): + op.alter_column("endpoints", "model", nullable=True) + op.drop_constraint( + "fk_endpoint_tokens_endpoint_endpoints", "endpoint_tokens", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_endpoint_tokens_endpoint_endpoints"), + "endpoint_tokens", + "endpoints", + ["endpoint"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint("fk_endpoints_model_vfolders", "endpoints", type_="foreignkey") + op.create_foreign_key( + op.f("fk_endpoints_model_vfolders"), + "endpoints", + "vfolders", + ["model"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade(): + conn = op.get_bind() + + endpoint_tokens = sa.Table( + "endpoint_tokens", + metadata, + sa.Column( + "endpoint", GUID, sa.ForeignKey("endpoints.id", ondelete="SET NULL"), nullable=True + ), + extend_existing=True, + ) + endpoints = sa.Table( + "endpoints", + sa.Column("model", GUID, sa.ForeignKey("vfolders.id", ondelete="SET NULL"), nullable=True), + extend_existing=True, + ) + + def _delete(table, null_field): + while True: + subq = sa.select([table.c.id]).where(null_field.is_(sa.null())).limit(BATCH_SIZE) + delete_stmt = sa.delete(table).where(table.c.id.in_(subq)) + result = conn.execute(delete_stmt) + if result.rowcount == 0: + break + + _delete(endpoint_tokens, endpoint_tokens.c.endpoint) + _delete(endpoints, endpoints.c.model) + + op.drop_constraint(op.f("fk_endpoints_model_vfolders"), "endpoints", type_="foreignkey") + op.create_foreign_key( + "fk_endpoints_model_vfolders", + "endpoints", + "vfolders", + ["model"], + ["id"], + ondelete="RESTRICT", + ) + op.alter_column("endpoints", "model", nullable=False) + op.drop_constraint( + op.f("fk_endpoint_tokens_endpoint_endpoints"), "endpoint_tokens", type_="foreignkey" + ) + op.create_foreign_key( + "fk_endpoint_tokens_endpoint_endpoints", + "endpoint_tokens", + "endpoints", + ["endpoint"], + ["id"], + ) diff --git a/src/ai/backend/manager/models/alembic/versions/6f5fe19894b7_vfolder_invitation_state_to_enum_type.py b/src/ai/backend/manager/models/alembic/versions/6f5fe19894b7_vfolder_invitation_state_to_enum_type.py index a2478d91f3..498be352cc 100644 --- a/src/ai/backend/manager/models/alembic/versions/6f5fe19894b7_vfolder_invitation_state_to_enum_type.py +++ b/src/ai/backend/manager/models/alembic/versions/6f5fe19894b7_vfolder_invitation_state_to_enum_type.py @@ -18,7 +18,7 @@ branch_labels = None depends_on = None -vfinvs_choices = list(map(lambda v: v.value, VFolderInvitationState)) +vfinvs_choices = list(map(str, VFolderInvitationState)) vfolderinvitationstate = postgresql.ENUM( *vfinvs_choices, name="vfolderinvitationstate", diff --git a/src/ai/backend/manager/models/alembic/versions/75ea2b136830_add_user_resource_policies_max_session_.py b/src/ai/backend/manager/models/alembic/versions/75ea2b136830_add_user_resource_policies_max_session_.py new file mode 100644 index 0000000000..5ae0d11dec --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/75ea2b136830_add_user_resource_policies_max_session_.py @@ -0,0 +1,33 @@ +"""add user_resource_policies.max_session_count_per_model_session + +Revision ID: 75ea2b136830 +Revises: 589c764a18f1 +Create Date: 2024-03-04 22:21:49.888739 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "75ea2b136830" +down_revision = "589c764a18f1" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user_resource_policies", + sa.Column("max_session_count_per_model_session", sa.Integer(), nullable=True, default=10), + ) + op.execute("UPDATE user_resource_policies SET max_session_count_per_model_session = 10") + op.alter_column("user_resource_policies", "max_session_count_per_model_session", nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user_resource_policies", "max_session_count_per_model_session") + # ### end Alembic commands ### diff --git a/src/ai/backend/manager/models/alembic/versions/7ff52ff68bfc_detail_vfolder_deletion_status.py b/src/ai/backend/manager/models/alembic/versions/7ff52ff68bfc_detail_vfolder_deletion_status.py index ce02631f4d..1ef890e631 100644 --- a/src/ai/backend/manager/models/alembic/versions/7ff52ff68bfc_detail_vfolder_deletion_status.py +++ b/src/ai/backend/manager/models/alembic/versions/7ff52ff68bfc_detail_vfolder_deletion_status.py @@ -10,11 +10,8 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql as pgsql from sqlalchemy.sql import text -from ai.backend.manager.models.base import EnumValueType, IDColumn, metadata - # revision identifiers, used by Alembic. revision = "7ff52ff68bfc" down_revision = "a5319bfc7d7c" @@ -22,194 +19,112 @@ depends_on = None -BATCH_SIZE = 100 - -ENUM_NAME = "vfolderoperationstatus" - - -class VFolderOperationStatus(enum.StrEnum): - # New enum values - DELETE_PENDING = "delete-pending" # vfolder is in trash bin - DELETE_COMPLETE = "delete-complete" # vfolder is deleted permanently, only DB row remains - DELETE_ERROR = "delete-error" - - # Legacy enum values - LEGACY_DELETE_COMPLETE = "deleted-complete" - PURGE_ONGOING = "purge-ongoing" - - # Original enum values - DELETE_ONGOING = "delete-ongoing" # vfolder is being deleted in storage +class OldVFolderOperationStatus(enum.StrEnum): + READY = "ready" + PERFORMING = "performing" + CLONING = "cloning" + MOUNTED = "mounted" ERROR = "error" + DELETE_ONGOING = "delete-ongoing" # vfolder is being deleted + DELETE_COMPLETE = "deleted-complete" # vfolder is deleted + PURGE_ONGOING = "purge-ongoing" # vfolder is being removed permanently -vfolders = sa.Table( - "vfolders", - metadata, - IDColumn("id"), - sa.Column( - "status", - EnumValueType(VFolderOperationStatus), - nullable=False, - ), - sa.Column("status_history", pgsql.JSONB(), nullable=True, default=sa.null()), - extend_existing=True, -) - - -def add_enum(enum_val: VFolderOperationStatus) -> None: - op.execute(f"ALTER TYPE {ENUM_NAME} ADD VALUE IF NOT EXISTS '{str(enum_val)}'") - - -def delete_enum(enum_val: VFolderOperationStatus) -> None: - op.execute( +def upgrade() -> None: + conn = op.get_bind() + # Relax the colum type from enum to varchar(64). + conn.execute( + text("ALTER TABLE vfolders ALTER COLUMN status TYPE varchar(64) USING status::text;") + ) + conn.execute(text("ALTER TABLE vfolders ALTER COLUMN status SET DEFAULT 'ready';")) + conn.execute( text( - f"""DELETE FROM pg_enum - WHERE enumlabel = '{str(enum_val)}' - AND enumtypid = ( - SELECT oid FROM pg_type WHERE typname = '{ENUM_NAME}' - )""" + """\ + UPDATE vfolders + SET status = CASE + WHEN status = 'deleted-complete' THEN 'delete-pending' + WHEN status = 'purge-ongoing' THEN 'delete-ongoing' + WHEN status = 'error' THEN 'delete-error' + ELSE status + END, + status_history = ( + SELECT jsonb_object_agg(new_key, value) + FROM ( + SELECT + CASE + WHEN key = 'deleted-complete' THEN 'delete-pending' + WHEN key = 'purge-ongoing' THEN 'delete-ongoing' + WHEN key = 'error' THEN 'delete-error' + ELSE key + END AS new_key, + value + FROM jsonb_each(status_history) + ) AS subquery + ); + """ ) ) - - -def update_legacy_to_new( - conn, - vfolder_t, - legacy_enum: VFolderOperationStatus, - new_enum: VFolderOperationStatus, - *, - legacy_enum_name: str | None = None, - new_enum_name: str | None = None, -) -> None: - _legacy_enum_name = legacy_enum_name or legacy_enum.name - _new_enum_name = new_enum_name or new_enum.name - - while True: - stmt = ( - sa.select([vfolder_t.c.id]) - .where( - (vfolder_t.c.status == legacy_enum) - | (vfolder_t.c.status_history.has_key(_legacy_enum_name)) - ) - .limit(BATCH_SIZE) - ) - result = conn.execute(stmt).fetchall() - vfolder_ids = [vf[0] for vf in result] - - if not vfolder_ids: - break - - # Update `status` - update_status = ( - sa.update(vfolder_t) - .values({"status": new_enum}) - .where((vfolder_t.c.id.in_(vfolder_ids)) & (vfolder_t.c.status == legacy_enum)) - ) - conn.execute(update_status) - - # Update `status_history` - update_status_history = ( - sa.update(vfolder_t) - .values({ - "status_history": sa.func.jsonb_build_object( - _new_enum_name, vfolder_t.c.status_history.op("->>")(_legacy_enum_name) - ) - + vfolder_t.c.status_history.op("-")(_legacy_enum_name) - }) - .where( - (vfolder_t.c.id.in_(vfolder_ids)) - & (vfolder_t.c.status_history.has_key(_legacy_enum_name)) - ) - ) - conn.execute(update_status_history) - - -def upgrade() -> None: - conn = op.get_bind() - - add_enum(VFolderOperationStatus.DELETE_PENDING) - add_enum(VFolderOperationStatus.DELETE_COMPLETE) - add_enum(VFolderOperationStatus.DELETE_ERROR) - conn.commit() - - vfolders = sa.Table( + conn.execute(text("DROP TYPE vfolderoperationstatus;")) + op.add_column( "vfolders", - metadata, - IDColumn("id"), sa.Column( - "status", - EnumValueType(VFolderOperationStatus), - nullable=False, + "status_changed", + sa.DateTime(timezone=True), + nullable=True, ), - sa.Column("status_history", pgsql.JSONB(), nullable=True, default=sa.null()), - extend_existing=True, - ) - - update_legacy_to_new( - conn, vfolders, VFolderOperationStatus.PURGE_ONGOING, VFolderOperationStatus.DELETE_ONGOING - ) - update_legacy_to_new( - conn, - vfolders, - VFolderOperationStatus.LEGACY_DELETE_COMPLETE, - VFolderOperationStatus.DELETE_PENDING, - legacy_enum_name="DELETE_COMPLETE", - ) - - delete_enum(VFolderOperationStatus.LEGACY_DELETE_COMPLETE) - delete_enum(VFolderOperationStatus.PURGE_ONGOING) - - op.add_column( - "vfolders", sa.Column("status_changed", sa.DateTime(timezone=True), nullable=True) ) op.create_index( - op.f("ix_vfolders_status_changed"), "vfolders", ["status_changed"], unique=False + op.f("ix_vfolders_status_changed"), + "vfolders", + ["status_changed"], + unique=False, ) - conn.commit() def downgrade() -> None: conn = op.get_bind() - - add_enum(VFolderOperationStatus.LEGACY_DELETE_COMPLETE) - add_enum(VFolderOperationStatus.PURGE_ONGOING) - conn.commit() - - vfolders = sa.Table( - "vfolders", - metadata, - IDColumn("id"), - sa.Column( - "status", - EnumValueType(VFolderOperationStatus), - nullable=False, - ), - sa.Column("status_history", pgsql.JSONB(), nullable=True, default=sa.null()), - extend_existing=True, - ) - - update_legacy_to_new( - conn, vfolders, VFolderOperationStatus.DELETE_COMPLETE, VFolderOperationStatus.PURGE_ONGOING - ) # `deleted` vfolders are not in DB rows in this downgraded version - update_legacy_to_new( - conn, vfolders, VFolderOperationStatus.DELETE_ONGOING, VFolderOperationStatus.PURGE_ONGOING + conn.execute( + text( + """\ + UPDATE vfolders + SET status = CASE + WHEN status = 'delete-pending' THEN 'deleted-complete' + WHEN status = 'delete-complete' THEN 'purge-ongoing' + WHEN status = 'delete-ongoing' THEN 'purge-ongoing' + WHEN status = 'delete-error' THEN 'error' + ELSE status + END, + status_history = ( + SELECT jsonb_object_agg(new_key, value) + FROM ( + SELECT + CASE + WHEN key = 'delete-pending' THEN 'deleted-complete' + WHEN key = 'delete-complete' THEN 'purge-ongoing' + WHEN key = 'delete-ongoing' THEN 'purge-ongoing' + WHEN key = 'delete-error' THEN 'error' + ELSE key + END AS new_key, + value + FROM jsonb_each(status_history) + ) AS subquery + ); + """ + ) ) - update_legacy_to_new( - conn, vfolders, VFolderOperationStatus.DELETE_ERROR, VFolderOperationStatus.ERROR + conn.execute( + text( + "CREATE TYPE vfolderoperationstatus AS ENUM (%s)" + % (",".join(f"'{choice.value}'" for choice in OldVFolderOperationStatus)) + ) ) - update_legacy_to_new( - conn, - vfolders, - VFolderOperationStatus.DELETE_PENDING, - VFolderOperationStatus.LEGACY_DELETE_COMPLETE, - new_enum_name="DELETE_COMPLETE", + conn.execute(text("ALTER TABLE vfolders ALTER COLUMN status DROP DEFAULT;")) + conn.execute( + text( + "ALTER TABLE vfolders ALTER COLUMN status TYPE vfolderoperationstatus " + "USING status::vfolderoperationstatus;" + ) ) - - delete_enum(VFolderOperationStatus.DELETE_PENDING) - delete_enum(VFolderOperationStatus.DELETE_COMPLETE) - delete_enum(VFolderOperationStatus.DELETE_ERROR) - + conn.execute(text("ALTER TABLE vfolders ALTER COLUMN status SET DEFAULT 'ready';")) op.drop_index(op.f("ix_vfolders_status_changed"), table_name="vfolders") op.drop_column("vfolders", "status_changed") - - conn.commit() diff --git a/src/ai/backend/manager/models/alembic/versions/819c2b3830a9_add_user_model.py b/src/ai/backend/manager/models/alembic/versions/819c2b3830a9_add_user_model.py index ce7fe73190..7f17526d56 100644 --- a/src/ai/backend/manager/models/alembic/versions/819c2b3830a9_add_user_model.py +++ b/src/ai/backend/manager/models/alembic/versions/819c2b3830a9_add_user_model.py @@ -30,7 +30,7 @@ depends_on = None -userrole_choices = list(map(lambda v: v.value, UserRole)) +userrole_choices = list(map(str, UserRole)) userrole = postgresql.ENUM(*userrole_choices, name="userrole") diff --git a/src/ai/backend/manager/models/alembic/versions/a1fd4e7b7782_enumerate_vfolder_perms.py b/src/ai/backend/manager/models/alembic/versions/a1fd4e7b7782_enumerate_vfolder_perms.py index e339a48b89..196558fa4f 100644 --- a/src/ai/backend/manager/models/alembic/versions/a1fd4e7b7782_enumerate_vfolder_perms.py +++ b/src/ai/backend/manager/models/alembic/versions/a1fd4e7b7782_enumerate_vfolder_perms.py @@ -19,7 +19,7 @@ depends_on = None # NOTE: VFolderPermission is EnumValueType -vfperm_choices = list(map(lambda v: v.value, VFolderPermission)) +vfperm_choices = list(map(str, VFolderPermission)) vfolderpermission = postgresql.ENUM( *vfperm_choices, name="vfolderpermission", diff --git a/src/ai/backend/manager/models/alembic/versions/d3f8c74bf148_user_main_keypair.py b/src/ai/backend/manager/models/alembic/versions/d3f8c74bf148_user_main_keypair.py index 3bf285a3f4..259aca2424 100644 --- a/src/ai/backend/manager/models/alembic/versions/d3f8c74bf148_user_main_keypair.py +++ b/src/ai/backend/manager/models/alembic/versions/d3f8c74bf148_user_main_keypair.py @@ -31,7 +31,7 @@ PAGE_SIZE = 100 -class UserRole(str, enum.Enum): +class UserRole(enum.StrEnum): """ User's role. """ diff --git a/src/ai/backend/manager/models/alembic/versions/dbc1e053b880_add_keypair_resource_policy.py b/src/ai/backend/manager/models/alembic/versions/dbc1e053b880_add_keypair_resource_policy.py index e33c236b16..d43bd80b2a 100644 --- a/src/ai/backend/manager/models/alembic/versions/dbc1e053b880_add_keypair_resource_policy.py +++ b/src/ai/backend/manager/models/alembic/versions/dbc1e053b880_add_keypair_resource_policy.py @@ -20,7 +20,7 @@ depends_on = None -default_for_unspecified_choices = list(map(lambda v: v.name, DefaultForUnspecified)) +default_for_unspecified_choices = [*map(str, DefaultForUnspecified)] default_for_unspecified = postgresql.ENUM( *default_for_unspecified_choices, name="default_for_unspecified", diff --git a/src/ai/backend/manager/models/base.py b/src/ai/backend/manager/models/base.py index dd89ff241a..77730f1ad0 100644 --- a/src/ai/backend/manager/models/base.py +++ b/src/ai/backend/manager/models/base.py @@ -22,6 +22,7 @@ NamedTuple, Optional, Protocol, + Self, Sequence, Type, TypeVar, @@ -77,6 +78,8 @@ from .minilang.queryfilter import QueryFilterParser, WhereClauseType if TYPE_CHECKING: + from sqlalchemy.engine.interfaces import Dialect + from .gql import GraphQueryContext from .user import UserRole @@ -117,7 +120,11 @@ class FixtureOpModes(enum.StrEnum): UPDATE = "update" -class EnumType(TypeDecorator, SchemaType): +T_Enum = TypeVar("T_Enum", bound=enum.Enum, covariant=True) +T_StrEnum = TypeVar("T_StrEnum", bound=enum.Enum, covariant=True) + + +class EnumType(TypeDecorator, SchemaType, Generic[T_Enum]): """ A stripped-down version of Spoqa's sqlalchemy-enum34. It also handles postgres-specific enum type creation. @@ -128,8 +135,7 @@ class EnumType(TypeDecorator, SchemaType): impl = ENUM cache_ok = True - def __init__(self, enum_cls, **opts): - assert issubclass(enum_cls, enum.Enum) + def __init__(self, enum_cls: type[T_Enum], **opts) -> None: if "name" not in opts: opts["name"] = enum_cls.__name__.lower() self._opts = opts @@ -137,13 +143,21 @@ def __init__(self, enum_cls, **opts): super().__init__(*enums, **opts) self._enum_cls = enum_cls - def process_bind_param(self, value, dialect): + def process_bind_param( + self, + value: Optional[T_Enum], + dialect: Dialect, + ) -> Optional[str]: return value.name if value else None - def process_result_value(self, value: str, dialect): + def process_result_value( + self, + value: str, + dialect: Dialect, + ) -> Optional[T_Enum]: return self._enum_cls[value] if value else None - def copy(self): + def copy(self, **kw) -> type[Self]: return EnumType(self._enum_cls, **self._opts) @property @@ -151,7 +165,7 @@ def python_type(self): return self._enum_class -class EnumValueType(TypeDecorator, SchemaType): +class EnumValueType(TypeDecorator, SchemaType, Generic[T_Enum]): """ A stripped-down version of Spoqa's sqlalchemy-enum34. It also handles postgres-specific enum type creation. @@ -162,8 +176,7 @@ class EnumValueType(TypeDecorator, SchemaType): impl = ENUM cache_ok = True - def __init__(self, enum_cls, **opts): - assert issubclass(enum_cls, enum.Enum) + def __init__(self, enum_cls: type[T_Enum], **opts) -> None: if "name" not in opts: opts["name"] = enum_cls.__name__.lower() self._opts = opts @@ -171,17 +184,60 @@ def __init__(self, enum_cls, **opts): super().__init__(*enums, **opts) self._enum_cls = enum_cls - def process_bind_param(self, value, dialect): + def process_bind_param( + self, + value: Optional[T_Enum], + dialect: Dialect, + ) -> Optional[str]: return value.value if value else None - def process_result_value(self, value: str, dialect): + def process_result_value( + self, + value: str, + dialect: Dialect, + ) -> Optional[T_Enum]: return self._enum_cls(value) if value else None - def copy(self): + def copy(self, **kw) -> type[Self]: return EnumValueType(self._enum_cls, **self._opts) @property - def python_type(self): + def python_type(self) -> T_Enum: + return self._enum_class + + +class StrEnumType(TypeDecorator, Generic[T_StrEnum]): + """ + Maps Postgres VARCHAR(64) column with a Python enum.StrEnum type. + """ + + impl = sa.VARCHAR + cache_ok = True + + def __init__(self, enum_cls: type[T_StrEnum], **opts) -> None: + self._opts = opts + super().__init__(length=64, **opts) + self._enum_cls = enum_cls + + def process_bind_param( + self, + value: Optional[T_StrEnum], + dialect: Dialect, + ) -> Optional[str]: + return value.value if value is not None else None + + def process_result_value( + self, + value: str, + dialect: Dialect, + ) -> Optional[T_StrEnum]: + return self._enum_cls(value) if value is not None else None + + def copy(self, **kw) -> type[Self]: + return StrEnumType(self._enum_cls, **self._opts) + + @property + def python_type(self) -> T_StrEnum: return self._enum_class @@ -203,13 +259,21 @@ class CurvePublicKeyColumn(TypeDecorator): def load_dialect_impl(self, dialect): return dialect.type_descriptor(sa.String(40)) - def process_bind_param(self, value: Optional[PublicKey], dialect) -> Optional[str]: + def process_bind_param( + self, + value: Optional[PublicKey], + dialect: Dialect, + ) -> Optional[str]: return value.decode("ascii") if value else None - def process_result_value(self, raw_value: str | None, dialect) -> Optional[PublicKey]: - if raw_value is None: + def process_result_value( + self, + value: str | None, + dialect: Dialect, + ) -> Optional[PublicKey]: + if value is None: return None - return PublicKey(raw_value.encode("ascii")) + return PublicKey(value.encode("ascii")) class QuotaScopeIDType(TypeDecorator): @@ -223,11 +287,19 @@ class QuotaScopeIDType(TypeDecorator): def load_dialect_impl(self, dialect): return dialect.type_descriptor(sa.String(64)) - def process_bind_param(self, value: Optional[QuotaScopeID], dialect) -> Optional[str]: + def process_bind_param( + self, + value: Optional[QuotaScopeID], + dialect: Dialect, + ) -> Optional[str]: return str(value) if value else None - def process_result_value(self, raw_value: str, dialect) -> QuotaScopeID: - return QuotaScopeID.parse(raw_value) + def process_result_value( + self, + value: Optional[str], + dialect: Dialect, + ) -> Optional[QuotaScopeID]: + return QuotaScopeID.parse(value) if value else None class ResourceSlotColumn(TypeDecorator): @@ -239,15 +311,21 @@ class ResourceSlotColumn(TypeDecorator): cache_ok = True def process_bind_param( - self, value: Union[Mapping, ResourceSlot, None], dialect - ) -> Optional[Mapping]: + self, + value: Optional[ResourceSlot], + dialect: Dialect, + ) -> Optional[Mapping[str, str]]: if value is None: return None if isinstance(value, ResourceSlot): return value.to_json() return value - def process_result_value(self, value: dict[str, str] | None, dialect) -> ResourceSlot | None: + def process_result_value( + self, + value: Optional[dict[str, str]], + dialect: Dialect, + ) -> Optional[ResourceSlot]: if value is None: return None try: @@ -256,9 +334,6 @@ def process_result_value(self, value: dict[str, str] | None, dialect) -> Resourc # for legacy-compat scenario return ResourceSlot.from_user_input(value, None) - def copy(self): - return ResourceSlotColumn() - class StructuredJSONColumn(TypeDecorator): """ @@ -272,13 +347,17 @@ def __init__(self, schema: t.Trafaret) -> None: super().__init__() self._schema = schema - def load_dialect_impl(self, dialect): + def load_dialect_impl(self, dialect: Dialect): if dialect.name == "sqlite": return dialect.type_descriptor(sa.JSON) else: return super().load_dialect_impl(dialect) - def process_bind_param(self, value, dialect): + def process_bind_param( + self, + value: Optional[Any], + dialect: Dialect, + ) -> Optional[Any]: if value is None: return self._schema.check({}) try: @@ -290,12 +369,16 @@ def process_bind_param(self, value, dialect): ) return value - def process_result_value(self, raw_value, dialect): - if raw_value is None: + def process_result_value( + self, + value: Optional[Any], + dialect: Dialect, + ) -> Optional[Any]: + if value is None: return self._schema.check({}) - return self._schema.check(raw_value) + return self._schema.check(value) - def copy(self): + def copy(self, **kw) -> type[Self]: return StructuredJSONColumn(self._schema) @@ -314,10 +397,10 @@ def __init__(self, schema: Type[JSONSerializableMixin]) -> None: def process_bind_param(self, value, dialect): return self._schema.to_json(value) - def process_result_value(self, raw_value, dialect): - return self._schema.from_json(raw_value) + def process_result_value(self, value, dialect): + return self._schema.from_json(value) - def copy(self): + def copy(self, **kw) -> type[Self]: return StructuredJSONObjectColumn(self._schema) @@ -337,12 +420,12 @@ def __init__(self, schema: Type[JSONSerializableMixin]) -> None: def process_bind_param(self, value, dialect): return [self._schema.to_json(item) for item in value] - def process_result_value(self, raw_value, dialect): - if raw_value is None: + def process_result_value(self, value, dialect): + if value is None: return [] - return [self._schema.from_json(item) for item in raw_value] + return [self._schema.from_json(item) for item in value] - def copy(self): + def copy(self, **kw) -> type[Self]: return StructuredJSONObjectListColumn(self._schema) @@ -354,12 +437,10 @@ class URLColumn(TypeDecorator): impl = sa.types.UnicodeText cache_ok = True - def process_bind_param(self, value, dialect): - if isinstance(value, yarl.URL): - return str(value) - return value + def process_bind_param(self, value: Optional[yarl.URL], dialect: Dialect) -> Optional[str]: + return str(value) - def process_result_value(self, value, dialect): + def process_result_value(self, value: Optional[str], dialect: Dialect) -> Optional[yarl.URL]: if value is None: return None if value is not None: @@ -402,16 +483,20 @@ def __init__(self, perm_type: Type[AbstractPermission]) -> None: self._perm_type = perm_type @overload - def process_bind_param(self, value: Sequence[AbstractPermission], dialect) -> List[str]: ... + def process_bind_param( + self, value: Sequence[AbstractPermission], dialect: Dialect + ) -> List[str]: ... @overload - def process_bind_param(self, value: Sequence[str], dialect) -> List[str]: ... + def process_bind_param(self, value: Sequence[str], dialect: Dialect) -> List[str]: ... @overload - def process_bind_param(self, value: None, dialect) -> List[str]: ... + def process_bind_param(self, value: None, dialect: Dialect) -> List[str]: ... def process_bind_param( - self, value: Sequence[AbstractPermission] | Sequence[str] | None, dialect + self, + value: Sequence[AbstractPermission] | Sequence[str] | None, + dialect: Dialect, ) -> List[str]: if value is None: return [] @@ -420,7 +505,11 @@ def process_bind_param( except ValueError: raise InvalidAPIParameters(f"Invalid value for binding to {self._perm_type}") - def process_result_value(self, value: Sequence[str] | None, dialect) -> set[AbstractPermission]: + def process_result_value( + self, + value: Sequence[str] | None, + dialect: Dialect, + ) -> set[AbstractPermission]: if value is None: return set() return set(self._perm_type(perm) for perm in value) @@ -435,7 +524,11 @@ class VFolderHostPermissionColumn(TypeDecorator): cache_ok = True perm_col = PermissionListColumn(VFolderHostPermission) - def process_bind_param(self, value: Mapping[str, Any] | None, dialect) -> Mapping[str, Any]: + def process_bind_param( + self, + value: Mapping[str, Any] | None, + dialect: Dialect, + ) -> Mapping[str, Any]: if value is None: return {} return { @@ -443,7 +536,9 @@ def process_bind_param(self, value: Mapping[str, Any] | None, dialect) -> Mappin } def process_result_value( - self, value: Mapping[str, Any] | None, dialect + self, + value: Mapping[str, Any] | None, + dialect: Dialect, ) -> VFolderHostPermissionMap: if value is None: return VFolderHostPermissionMap() diff --git a/src/ai/backend/manager/models/endpoint.py b/src/ai/backend/manager/models/endpoint.py index 372865cc63..ec24026b95 100644 --- a/src/ai/backend/manager/models/endpoint.py +++ b/src/ai/backend/manager/models/endpoint.py @@ -82,7 +82,10 @@ class EndpointRow(Base): "image", GUID, sa.ForeignKey("images.id", ondelete="RESTRICT"), nullable=False ) model = sa.Column( - "model", GUID, sa.ForeignKey("vfolders.id", ondelete="RESTRICT"), nullable=False + "model", + GUID, + sa.ForeignKey("vfolders.id", ondelete="SET NULL"), + nullable=True, ) model_mount_destiation = sa.Column( "model_mount_destiation", @@ -282,13 +285,56 @@ async def list( result = await session.execute(query) return result.scalars().all() + @classmethod + async def list_by_model( + cls, + session: AsyncSession, + model_id: uuid.UUID, + domain: Optional[str] = None, + project: Optional[uuid.UUID] = None, + user_uuid: Optional[uuid.UUID] = None, + load_routes=False, + load_image=False, + load_tokens=False, + load_created_user=False, + load_session_owner=False, + status_filter=[EndpointLifecycle.CREATED], + ) -> List["EndpointRow"]: + query = ( + sa.select(EndpointRow) + .order_by(sa.desc(EndpointRow.created_at)) + .filter( + EndpointRow.lifecycle_stage.in_(status_filter) & (EndpointRow.model == model_id) + ) + ) + if load_routes: + query = query.options(selectinload(EndpointRow.routings)) + if load_tokens: + query = query.options(selectinload(EndpointRow.tokens)) + if load_image: + query = query.options(selectinload(EndpointRow.image_row)) + if load_created_user: + query = query.options(selectinload(EndpointRow.created_user_row)) + if load_session_owner: + query = query.options(selectinload(EndpointRow.session_owner_row)) + if project: + query = query.filter(EndpointRow.project == project) + if domain: + query = query.filter(EndpointRow.domain == domain) + if user_uuid: + query = query.filter(EndpointRow.session_owner == user_uuid) + result = await session.execute(query) + return result.scalars().all() + class EndpointTokenRow(Base): __tablename__ = "endpoint_tokens" id = IDColumn() token = sa.Column("token", sa.String(), nullable=False) - endpoint = ForeignKeyIDColumn("endpoint", "endpoints.id") + endpoint = sa.Column( + "endpoint", GUID, sa.ForeignKey("endpoints.id", ondelete="SET NULL"), nullable=True + ) session_owner = ForeignKeyIDColumn("session_owner", "users.uuid") domain = sa.Column( "domain", @@ -462,18 +508,7 @@ async def from_row( created_at=row.created_at, destroyed_at=row.destroyed_at, retries=row.retries, - routings=[ - Routing( - routing_id=r.id, - endpoint=row.url, - session=r.session, - status=r.session, - traffic_ratio=r.traffic_ratio, - created_at=r.created_at, - error_data=r.error_data, - ) - for r in row.routings - ], + routings=[await Routing.from_row(None, r, endpoint=row) for r in row.routings], lifecycle_stage=row.lifecycle_stage.name, ) @@ -607,15 +642,21 @@ async def load_item( async def resolve_status(self, info: graphene.ResolveInfo) -> str: if self.retries > SERVICE_MAX_RETRIES: return "UNHEALTHY" - if self.lifecycle_stage == EndpointLifecycle.DESTROYING.name: + if self.lifecycle_stage == EndpointLifecycle.DESTROYING: return "DESTROYING" if len(self.routings) == 0: return "READY" - if ( - len([r for r in self.routings if r.status == RouteStatus.HEALTHY.name]) - == self.desired_session_count - ): - return "HEALTHY" + if (spawned_service_count := len([r for r in self.routings])) > 0: + healthy_service_count = len([ + r for r in self.routings if r.status == RouteStatus.HEALTHY.name + ]) + if healthy_service_count == spawned_service_count: + return "HEALTHY" + unhealthy_service_count = len([ + r for r in self.routings if r.status == RouteStatus.UNHEALTHY.name + ]) + if unhealthy_service_count > 0: + return "DEGRADED" return "PROVISIONING" async def resolve_errors(self, info: graphene.ResolveInfo) -> Any: @@ -827,13 +868,6 @@ async def load_slice( domain_name: Optional[str] = None, user_uuid: Optional[uuid.UUID] = None, ) -> Sequence["EndpointToken"]: - log.debug( - "Endpoint: {}, project: {}, domain: {}, user: {}", - endpoint_id, - project, - domain_name, - user_uuid, - ) query = ( sa.select(EndpointTokenRow) .limit(limit) diff --git a/src/ai/backend/manager/models/kernel.py b/src/ai/backend/manager/models/kernel.py index b556cd7c30..1f4316741b 100644 --- a/src/ai/backend/manager/models/kernel.py +++ b/src/ai/backend/manager/models/kernel.py @@ -469,6 +469,7 @@ async def handle_kernel_exception( default=KernelRole.COMPUTE, server_default=KernelRole.COMPUTE.name, nullable=False, + index=True, ), sa.Column("status_changed", sa.DateTime(timezone=True), nullable=True, index=True), sa.Column("status_info", sa.Unicode(), nullable=True, default=sa.null()), diff --git a/src/ai/backend/manager/models/resource_policy.py b/src/ai/backend/manager/models/resource_policy.py index 9e456ab17a..c28f5ab818 100644 --- a/src/ai/backend/manager/models/resource_policy.py +++ b/src/ai/backend/manager/models/resource_policy.py @@ -71,6 +71,8 @@ sa.Column("total_resource_slots", ResourceSlotColumn(), nullable=False), sa.Column("max_session_lifetime", sa.Integer(), nullable=False, server_default=sa.text("0")), sa.Column("max_concurrent_sessions", sa.Integer(), nullable=False), + sa.Column("max_pending_session_count", sa.Integer(), nullable=True), + sa.Column("max_pending_session_resource_slots", ResourceSlotColumn(), nullable=True), sa.Column( "max_concurrent_sftp_sessions", sa.Integer(), nullable=False, server_default=sa.text("1") ), @@ -99,6 +101,7 @@ class KeyPairResourcePolicyRow(Base): sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column("max_vfolder_count", sa.Integer(), nullable=False), sa.Column("max_quota_scope_size", sa.BigInteger(), nullable=False), + sa.Column("max_session_count_per_model_session", sa.Integer(), nullable=False), ) @@ -106,10 +109,13 @@ class UserResourcePolicyRow(Base): __table__ = user_resource_policies users = relationship("UserRow", back_populates="resource_policy_row") - def __init__(self, name, max_vfolder_count, max_quota_scope_size) -> None: + def __init__( + self, name, max_vfolder_count, max_quota_scope_size, max_session_count_per_model_session + ) -> None: self.name = name self.max_vfolder_count = max_vfolder_count self.max_quota_scope_size = max_quota_scope_size + self.max_session_count_per_model_session = max_session_count_per_model_session project_resource_policies = sa.Table( @@ -431,6 +437,9 @@ class UserResourcePolicy(graphene.ObjectType): description="Added since 24.03.1. Limitation of the quota size of user vfolders." ) max_vfolder_size = BigInt(deprecation_reason="Deprecated since 23.09.1") + max_session_count_per_model_session = graphene.Int( + description="Added since 23.09.10. Maximum available number of sessions per single model service which the user is in charge of." + ) @classmethod def from_row( @@ -446,6 +455,7 @@ def from_row( created_at=row.created_at, max_vfolder_count=row.max_vfolder_count, max_quota_scope_size=row.max_quota_scope_size, + max_session_count_per_model_session=row.max_session_count_per_model_session, ) @classmethod @@ -508,6 +518,9 @@ class CreateUserResourcePolicyInput(graphene.InputObjectType): max_quota_scope_size = BigInt( description="Added since 24.03.1. Limitation of the quota size of user vfolders." ) + max_session_count_per_model_session = graphene.Int( + description="Added since 24.03.1. Maximum available number of sessions per single model service which the user is in charge of." + ) class ModifyUserResourcePolicyInput(graphene.InputObjectType): @@ -517,6 +530,9 @@ class ModifyUserResourcePolicyInput(graphene.InputObjectType): max_quota_scope_size = BigInt( description="Added since 24.03.1. Limitation of the quota size of user vfolders." ) + max_session_count_per_model_session = graphene.Int( + description="Added since 24.03.1. Maximum available number of sessions per single model service which the user is in charge of." + ) class CreateUserResourcePolicy(graphene.Mutation): @@ -543,7 +559,10 @@ async def mutate( async def _do_mutate() -> UserResourcePolicy: async with graph_ctx.db.begin_session() as sess: row = UserResourcePolicyRow( - name, props.max_vfolder_count, props.max_quota_scope_size + name, + props.max_vfolder_count, + props.max_quota_scope_size, + props.max_session_count_per_model_session, ) sess.add(row) await sess.flush() @@ -578,6 +597,7 @@ async def mutate( data: Dict[str, Any] = {} set_if_set(props, data, "max_vfolder_count") set_if_set(props, data, "max_quota_scope_size") + set_if_set(props, data, "max_session_count_per_model_session") update_query = ( sa.update(UserResourcePolicyRow).values(data).where(UserResourcePolicyRow.name == name) ) diff --git a/src/ai/backend/manager/models/routing.py b/src/ai/backend/manager/models/routing.py index c8bfaba29b..f51355a086 100644 --- a/src/ai/backend/manager/models/routing.py +++ b/src/ai/backend/manager/models/routing.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: # from .gql import GraphQueryContext - pass + from .endpoint import EndpointRow __all__ = ("RoutingRow", "Routing", "RoutingList", "RouteStatus") @@ -217,10 +217,11 @@ async def from_row( cls, ctx, # ctx: GraphQueryContext, row: RoutingRow, + endpoint: Optional["EndpointRow"] = None, ) -> "Routing": return cls( routing_id=row.id, - endpoint=row.endpoint_row.url, + endpoint=(endpoint or row.endpoint_row).url, session=row.session, status=row.status.name, traffic_ratio=row.traffic_ratio, diff --git a/src/ai/backend/manager/models/session.py b/src/ai/backend/manager/models/session.py index bd23e11eef..359e423f50 100644 --- a/src/ai/backend/manager/models/session.py +++ b/src/ai/backend/manager/models/session.py @@ -510,7 +510,7 @@ async def _match_sessions_by_name( return result.scalars().all() -class SessionOp(str, enum.Enum): +class SessionOp(enum.StrEnum): CREATE = "create_session" DESTROY = "destroy_session" RESTART = "restart_session" @@ -523,7 +523,7 @@ class SessionOp(str, enum.Enum): GET_AGENT_LOGS = "get_logs_from_agent" -class KernelLoadingStrategy(str, enum.Enum): +class KernelLoadingStrategy(enum.StrEnum): ALL_KERNELS = "all" MAIN_KERNEL_ONLY = "main" NONE = "none" diff --git a/src/ai/backend/manager/models/session_template.py b/src/ai/backend/manager/models/session_template.py index 357189f203..0520d41d17 100644 --- a/src/ai/backend/manager/models/session_template.py +++ b/src/ai/backend/manager/models/session_template.py @@ -25,7 +25,7 @@ ) -class TemplateType(str, enum.Enum): +class TemplateType(enum.StrEnum): TASK = "task" CLUSTER = "cluster" @@ -94,8 +94,7 @@ def check_task_template(raw_data: Mapping[str, Any]) -> Mapping[str, Any]: for p in mounts.values(): if p is None: continue - if p.startswith("/home/work/"): - p = p.replace("/home/work/", "") + p = p.removeprefix("/home/work/") if not verify_vfolder_name(p): raise InvalidArgument(f"Path {p} is reserved for internal operations.") return data diff --git a/src/ai/backend/manager/models/storage.py b/src/ai/backend/manager/models/storage.py index 7cc1939c4e..041edd14b9 100644 --- a/src/ai/backend/manager/models/storage.py +++ b/src/ai/backend/manager/models/storage.py @@ -191,7 +191,10 @@ async def request( extra_data=None, ) except VFolderOperationFailed as e: - raise InvalidAPIParameters(e.extra_msg, e.extra_data) + if client_resp.status // 100 == 5: + raise InvalidAPIParameters(e.extra_msg, e.extra_data) + # Raise as-is for semantic failures, not server errors. + raise yield proxy_info.client_api_url, client_resp diff --git a/src/ai/backend/manager/models/user.py b/src/ai/backend/manager/models/user.py index 966ab3236f..488d3de1e7 100644 --- a/src/ai/backend/manager/models/user.py +++ b/src/ai/backend/manager/models/user.py @@ -6,12 +6,12 @@ from uuid import UUID, uuid4 import aiotools +import bcrypt import graphene import sqlalchemy as sa from dateutil.parser import parse as dtparse from graphene.types.datetime import DateTime as GQLDateTime from graphql import Undefined -from passlib.hash import bcrypt from sqlalchemy.dialects import postgresql as pgsql from sqlalchemy.engine.result import Result from sqlalchemy.engine.row import Row @@ -82,7 +82,7 @@ def process_bind_param(self, value, dialect): return _hash_password(value) -class UserRole(str, enum.Enum): +class UserRole(enum.StrEnum): """ User's role. """ @@ -93,7 +93,7 @@ class UserRole(str, enum.Enum): MONITOR = "monitor" -class UserStatus(str, enum.Enum): +class UserStatus(enum.StrEnum): """ User account status. """ @@ -1477,12 +1477,12 @@ class Meta: node = UserNode -def _hash_password(password): - return bcrypt.using(rounds=12).hash(password) +def _hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt(rounds=12)).decode("utf8") -def _verify_password(guess, hashed): - return bcrypt.verify(guess, hashed) +def _verify_password(guess: str, hashed: str) -> bool: + return bcrypt.checkpw(guess.encode("utf8"), hashed.encode("utf8")) def compare_to_hashed_password(raw_password: str, hashed_password: str) -> bool: diff --git a/src/ai/backend/manager/models/utils.py b/src/ai/backend/manager/models/utils.py index 39fdf28289..bc70460fde 100644 --- a/src/ai/backend/manager/models/utils.py +++ b/src/ai/backend/manager/models/utils.py @@ -109,26 +109,32 @@ async def begin_readonly(self, deferrable: bool = False) -> AsyncIterator[SAConn finally: self._readonly_txn_count -= 1 + @actxmgr + async def _begin_session( + self, conn: SAConnection, expire_on_commit=False + ) -> AsyncIterator[SASession]: + self._sess_factory.configure(bind=conn, expire_on_commit=expire_on_commit) + session = self._sess_factory() + yield session + @actxmgr async def begin_session(self, expire_on_commit=False) -> AsyncIterator[SASession]: async with self.begin() as conn: - self._sess_factory.configure(bind=conn, expire_on_commit=expire_on_commit) - session = self._sess_factory() - try: - yield session - await session.commit() - except Exception as e: - await session.rollback() - raise e + async with self._begin_session(conn, expire_on_commit=expire_on_commit) as session: + try: + yield session + await session.commit() + except Exception as e: + await session.rollback() + raise e @actxmgr async def begin_readonly_session( self, deferrable: bool = False, expire_on_commit=False ) -> AsyncIterator[SASession]: async with self.begin_readonly(deferrable=deferrable) as conn: - self._sess_factory.configure(bind=conn, expire_on_commit=expire_on_commit) - session = self._sess_factory() - yield session + async with self._begin_session(conn, expire_on_commit=expire_on_commit) as session: + yield session @actxmgr async def advisory_lock(self, lock_id: LockID) -> AsyncIterator[None]: diff --git a/src/ai/backend/manager/models/vfolder.py b/src/ai/backend/manager/models/vfolder.py index 5a3e0a3533..ca2c4da384 100644 --- a/src/ai/backend/manager/models/vfolder.py +++ b/src/ai/backend/manager/models/vfolder.py @@ -63,6 +63,7 @@ OrderExprArg, PaginatedList, QuotaScopeIDType, + StrEnumType, batch_multiresult, generate_sql_info_for_gql_connection, metadata, @@ -117,7 +118,7 @@ log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore[name-defined] -class VFolderOwnershipType(str, enum.Enum): +class VFolderOwnershipType(enum.StrEnum): """ Ownership type of virtual folder. """ @@ -126,7 +127,7 @@ class VFolderOwnershipType(str, enum.Enum): GROUP = "group" -class VFolderPermission(str, enum.Enum): +class VFolderPermission(enum.StrEnum): """ Permissions for a virtual folder given to a specific access key. RW_DELETE includes READ_WRITE and READ_WRITE includes READ_ONLY. @@ -145,7 +146,7 @@ def check_and_return(self, value: Any) -> VFolderPermission: return VFolderPermission(value) -class VFolderInvitationState(str, enum.Enum): +class VFolderInvitationState(enum.StrEnum): """ Virtual Folder invitation state. """ @@ -228,7 +229,7 @@ class VFolderCloneInfo(NamedTuple): metadata, IDColumn("id"), # host will be '' if vFolder is unmanaged - sa.Column("host", sa.String(length=128), nullable=False), + sa.Column("host", sa.String(length=128), nullable=False, index=True), sa.Column("quota_scope_id", QuotaScopeIDType, nullable=False), sa.Column("name", sa.String(length=64), nullable=False, index=True), sa.Column( @@ -236,6 +237,7 @@ class VFolderCloneInfo(NamedTuple): EnumValueType(VFolderUsageMode), default=VFolderUsageMode.GENERAL, nullable=False, + index=True, ), sa.Column("permission", EnumValueType(VFolderPermission), default=VFolderPermission.READ_WRITE), sa.Column("max_files", sa.Integer(), default=1000), @@ -253,16 +255,18 @@ class VFolderCloneInfo(NamedTuple): EnumValueType(VFolderOwnershipType), default=VFolderOwnershipType.USER, nullable=False, + index=True, ), sa.Column("user", GUID, sa.ForeignKey("users.uuid"), nullable=True), # owner if user vfolder sa.Column("group", GUID, sa.ForeignKey("groups.id"), nullable=True), # owner if project vfolder sa.Column("cloneable", sa.Boolean, default=False, nullable=False), sa.Column( "status", - EnumValueType(VFolderOperationStatus), + StrEnumType(VFolderOperationStatus), default=VFolderOperationStatus.READY, - server_default=VFolderOperationStatus.READY.value, + server_default=VFolderOperationStatus.READY, nullable=False, + index=True, ), # status_history records the most recent status changes for each status # e.g) @@ -782,7 +786,7 @@ async def prepare_vfolder_mounts( params={ "volume": storage_manager.split_host(vfolder["host"])[1], "vfid": str(VFolderID(vfolder["quota_scope_id"], vfolder["id"])), - "relpath": str(user_scope.user_uuid.hex), + "relpaths": [str(user_scope.user_uuid.hex)], "exist_ok": True, }, ): @@ -1233,7 +1237,7 @@ async def resolve_num_files(self, info: graphene.ResolveInfo) -> int: "cloneable": ("vfolders_cloneable", None), "status": ( "vfolders_status", - enum_field_getter(VFolderOperationStatus), + lambda s: VFolderOperationStatus(s), ), } diff --git a/src/ai/backend/manager/plugin/error_monitor.py b/src/ai/backend/manager/plugin/error_monitor.py index b01aa4e0e5..66e10cc336 100644 --- a/src/ai/backend/manager/plugin/error_monitor.py +++ b/src/ai/backend/manager/plugin/error_monitor.py @@ -76,7 +76,7 @@ async def capture_exception( async with self.db.begin() as conn: query = error_logs.insert().values({ - "severity": severity, + "severity": severity.value.lower(), "source": "manager", "user": user, "message": message, @@ -101,7 +101,7 @@ async def handle_agent_error( return async with self.db.begin() as conn: query = error_logs.insert().values({ - "severity": event.severity, + "severity": event.severity.value.lower(), "source": source, "user": event.user, "message": event.message, diff --git a/src/ai/backend/manager/registry.py b/src/ai/backend/manager/registry.py index 7bed3115fc..362b806f64 100644 --- a/src/ai/backend/manager/registry.py +++ b/src/ai/backend/manager/registry.py @@ -46,6 +46,7 @@ from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only, noload, selectinload +from sqlalchemy.orm.exc import NoResultFound from yarl import URL from ai.backend.common import msgpack, redis_helper @@ -3440,6 +3441,8 @@ async def handle_model_service_status_update( route = await RoutingRow.get_by_session(db_sess, session.id, load_endpoint=True) except SessionNotFound: return + except NoResultFound: + return async def _update(): async with context.db.begin_session() as db_sess: @@ -3611,6 +3614,8 @@ async def _clear_error() -> None: await db_sess.execute(query) await execute_with_retry(_clear_error) + except NoResultFound: + pass # Cases when we try to create a inference session for validation (/services/_/try API) except Exception: log.exception("error while updating route status:") diff --git a/src/ai/backend/manager/scheduler/dispatcher.py b/src/ai/backend/manager/scheduler/dispatcher.py index 415353296e..88ca4f2844 100644 --- a/src/ai/backend/manager/scheduler/dispatcher.py +++ b/src/ai/backend/manager/scheduler/dispatcher.py @@ -94,6 +94,8 @@ check_domain_resource_limit, check_group_resource_limit, check_keypair_resource_limit, + check_pending_session_count_limit, + check_pending_session_resource_limit, check_reserved_batch_session, check_user_resource_limit, ) @@ -478,6 +480,14 @@ async def _check_predicates() -> List[Tuple[str, Union[Exception, PredicateResul ] if not sess_ctx.is_private: predicates += [ + ( + "pending_session_resource_limit", + check_pending_session_resource_limit(db_sess, sched_ctx, sess_ctx), + ), + ( + "pending_session_count_limit", + check_pending_session_count_limit(db_sess, sched_ctx, sess_ctx), + ), ( "keypair_resource_limit", check_keypair_resource_limit(db_sess, sched_ctx, sess_ctx), diff --git a/src/ai/backend/manager/scheduler/predicates.py b/src/ai/backend/manager/scheduler/predicates.py index 5686d88698..7c2aaf3bd2 100644 --- a/src/ai/backend/manager/scheduler/predicates.py +++ b/src/ai/backend/manager/scheduler/predicates.py @@ -4,6 +4,7 @@ import sqlalchemy as sa from dateutil.tz import tzutc from sqlalchemy.ext.asyncio import AsyncSession as SASession +from sqlalchemy.orm import load_only, noload from ai.backend.common import redis_helper from ai.backend.common.logging import BraceStyleAdapter @@ -294,3 +295,127 @@ async def check_domain_resource_limit( ), ) return PredicateResult(True) + + +async def check_pending_session_count_limit( + db_sess: SASession, + sched_ctx: SchedulingContext, + sess_ctx: SessionRow, +) -> PredicateResult: + result = True + failure_msgs = [] + + query = ( + sa.select(SessionRow) + .where( + (SessionRow.access_key == sess_ctx.access_key) + & (SessionRow.status == SessionStatus.PENDING) + ) + .options(noload("*"), load_only(SessionRow.requested_slots)) + ) + pending_sessions: list[SessionRow] = (await db_sess.scalars(query)).all() + + # TODO: replace keypair resource policies with user resource policies + j = sa.join( + KeyPairResourcePolicyRow, + KeyPairRow, + KeyPairResourcePolicyRow.name == KeyPairRow.resource_policy, + ) + policy_stmt = ( + sa.select(KeyPairResourcePolicyRow) + .select_from(j) + .where(KeyPairRow.access_key == sess_ctx.access_key) + .options( + noload("*"), + load_only( + KeyPairResourcePolicyRow.max_pending_session_count, + ), + ) + ) + policy: KeyPairResourcePolicyRow = (await db_sess.scalars(policy_stmt)).first() + + pending_count_limit: int | None = policy.max_pending_session_count + if pending_count_limit is not None: + if len(pending_sessions) >= pending_count_limit: + result = False + failure_msgs.append( + f"You cannot create more than {pending_count_limit} pending session(s)." + ) + + log.debug( + "access key:{} number of pending sessions: {} / {}", + sess_ctx.access_key, + len(pending_sessions), + pending_count_limit, + ) + if not result: + return PredicateResult(False, "\n".join(failure_msgs)) + return PredicateResult(True) + + +async def check_pending_session_resource_limit( + db_sess: SASession, + sched_ctx: SchedulingContext, + sess_ctx: SessionRow, +) -> PredicateResult: + result = True + failure_msgs = [] + + query = ( + sa.select(SessionRow) + .where( + (SessionRow.access_key == sess_ctx.access_key) + & (SessionRow.status == SessionStatus.PENDING) + ) + .options(noload("*"), load_only(SessionRow.requested_slots)) + ) + pending_sessions: list[SessionRow] = (await db_sess.scalars(query)).all() + + # TODO: replace keypair resource policies with user resource policies + j = sa.join( + KeyPairResourcePolicyRow, + KeyPairRow, + KeyPairResourcePolicyRow.name == KeyPairRow.resource_policy, + ) + policy_stmt = ( + sa.select(KeyPairResourcePolicyRow) + .select_from(j) + .where(KeyPairRow.access_key == sess_ctx.access_key) + .options( + noload("*"), + load_only( + KeyPairResourcePolicyRow.max_pending_session_resource_slots, + ), + ) + ) + policy: KeyPairResourcePolicyRow = (await db_sess.scalars(policy_stmt)).first() + + pending_resource_limit: ResourceSlot | None = policy.max_pending_session_resource_slots + if pending_resource_limit is not None and pending_resource_limit: + current_pending_session_slots: ResourceSlot = sum( + [session.requested_slots for session in pending_sessions], start=ResourceSlot() + ) + if current_pending_session_slots >= pending_resource_limit: + result = False + msg = "Your pending session quota is exceeded. ({})".format( + " ".join( + f"{k}={v}" + for k, v in current_pending_session_slots.to_humanized( + sched_ctx.known_slot_types + ).items() + ) + ) + failure_msgs.append(msg) + log.debug( + "access key:{} current-occupancy of pending sessions: {}", + sess_ctx.access_key, + current_pending_session_slots, + ) + log.debug( + "access key:{} total-allowed of pending sessions: {}", + sess_ctx.access_key, + pending_resource_limit, + ) + if not result: + return PredicateResult(False, "\n".join(failure_msgs)) + return PredicateResult(True) diff --git a/src/ai/backend/manager/server.py b/src/ai/backend/manager/server.py index 589fa63773..9bb71a5cce 100644 --- a/src/ai/backend/manager/server.py +++ b/src/ai/backend/manager/server.py @@ -112,6 +112,14 @@ # added user & project resource policies # deprecated per-vfolder quota configs (BREAKING) "v7.20230615", + # added /vfolders API set to replace name-based refs to ID-based refs to work with vfolders + # set pending deprecation for the legacy /folders API set + # added vfolder trash bin APIs + # changed the image registry management API to allow per-project registry configs (BREAKING) + # TODO: added an initial version of RBAC for projects and vfolders + # TODO: replaced keypair-based resource policies to user-based resource policies + # TODO: began SSO support using per-external-service keypairs (e.g., for FastTrack) + "v8.20240315", ]) LATEST_REV_DATES: Final = { 1: "20160915", @@ -121,8 +129,9 @@ 5: "20191215", 6: "20230315", 7: "20230615", + 8: "20240315", } -LATEST_API_VERSION: Final = "v7.20230615" +LATEST_API_VERSION: Final = "v8.20240315" log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore[name-defined] @@ -952,16 +961,21 @@ async def server_main_logwrapper( ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context -def main(ctx: click.Context, config_path: Path, log_level: str, debug: bool = False) -> None: +def main( + ctx: click.Context, + config_path: Path, + log_level: LogSeverity, + debug: bool = False, +) -> None: """ Start the manager service as a foreground process. """ - cfg = load_config(config_path, "DEBUG" if debug else log_level) + cfg = load_config(config_path, LogSeverity.DEBUG if debug else log_level) if ctx.invoked_subcommand is None: cfg["manager"]["pid-file"].write_text(str(os.getpid())) diff --git a/src/ai/backend/runner/dropbear.alpine3.8.aarch64.bin b/src/ai/backend/runner/dropbear.alpine3.8.aarch64.bin index ad97138474..01516c7212 100755 --- a/src/ai/backend/runner/dropbear.alpine3.8.aarch64.bin +++ b/src/ai/backend/runner/dropbear.alpine3.8.aarch64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:667033ef151d7c16318a7a768cce374d5d7e6755f3a2a0d404ae06d21ded99da +oid sha256:aa94d6e82eb19988e4357a6b9c38781357063c691fac22584c0e1c55ffc15a6e size 1137816 diff --git a/src/ai/backend/runner/dropbear.alpine3.8.x86_64.bin b/src/ai/backend/runner/dropbear.alpine3.8.x86_64.bin index 6057d3b3c5..dd440dc904 100755 --- a/src/ai/backend/runner/dropbear.alpine3.8.x86_64.bin +++ b/src/ai/backend/runner/dropbear.alpine3.8.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f482388283903faa9c0bca5f2a6db85daffe0f1f916791f263c612026afddeb +oid sha256:0f493414a1026ab892aa06da67474626d601841a5af9566545644081a259327b size 1086024 diff --git a/src/ai/backend/runner/dropbear.ubuntu18.04.aarch64.bin b/src/ai/backend/runner/dropbear.ubuntu18.04.aarch64.bin new file mode 100755 index 0000000000..74ff7a4ae5 --- /dev/null +++ b/src/ai/backend/runner/dropbear.ubuntu18.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd93c07b4a75c36c7c54727f9fd4146f5ed6d7e14eaef20def42dcf0c78adf00 +size 1131952 diff --git a/src/ai/backend/runner/dropbear.ubuntu18.04.x86_64.bin b/src/ai/backend/runner/dropbear.ubuntu18.04.x86_64.bin new file mode 100755 index 0000000000..177ea7c696 --- /dev/null +++ b/src/ai/backend/runner/dropbear.ubuntu18.04.x86_64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9fff60ca44b76209f067d36d0c3f2c45830c460baa0b593ba4842107b233dc8 +size 1556456 diff --git a/src/ai/backend/runner/dropbear.ubuntu20.04.aarch64.bin b/src/ai/backend/runner/dropbear.ubuntu20.04.aarch64.bin index 763b425fc6..0d4ada68d4 100755 --- a/src/ai/backend/runner/dropbear.ubuntu20.04.aarch64.bin +++ b/src/ai/backend/runner/dropbear.ubuntu20.04.aarch64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1555c7cbe29c2791d652035f423103020d1ed7df1d793c80775992730a18c649 -size 1396936 +oid sha256:23dc4c3ef324e3cc9ef75a05c7e0fe92dba6963a198440a47b7ee3170f74acdf +size 1398704 diff --git a/src/ai/backend/runner/dropbear.ubuntu20.04.x86_64.bin b/src/ai/backend/runner/dropbear.ubuntu20.04.x86_64.bin index 45cf5283ae..9036e14120 100755 --- a/src/ai/backend/runner/dropbear.ubuntu20.04.x86_64.bin +++ b/src/ai/backend/runner/dropbear.ubuntu20.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:520a7b596c19131d3545121ebc9d89ccd43995019c8a42828041b8e2521507cc -size 1750056 +oid sha256:af2b19382d598c3b3ff9fbda3daa2e9f1ddba6a65d00b1b03055bdd4153b9d04 +size 1750168 diff --git a/src/ai/backend/runner/dropbear.ubuntu22.04.aarch64.bin b/src/ai/backend/runner/dropbear.ubuntu22.04.aarch64.bin new file mode 100755 index 0000000000..d1313086b8 --- /dev/null +++ b/src/ai/backend/runner/dropbear.ubuntu22.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83e6a5e80a7110c21b816bc27b10f5bbdfcb3064ce7cfc1dc6e86234aece823c +size 1523232 diff --git a/src/ai/backend/runner/dropbear.ubuntu22.04.x86_64.bin b/src/ai/backend/runner/dropbear.ubuntu22.04.x86_64.bin index 0a8bcab7cf..ecc4ddbf46 100755 --- a/src/ai/backend/runner/dropbear.ubuntu22.04.x86_64.bin +++ b/src/ai/backend/runner/dropbear.ubuntu22.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:594a6f349d1bce46bf536f11b1b76f3e288aec4be99f1caa2177e87d5431a625 -size 1880400 +oid sha256:4124394ce08207cf251910c8d6e273c0fcd6510041226bea678d52826361b3ae +size 1880456 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu18.04.aarch64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu18.04.aarch64.bin new file mode 100755 index 0000000000..c2703f61b9 --- /dev/null +++ b/src/ai/backend/runner/dropbearconvert.ubuntu18.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08d62a2db048c384f7bd4dea7984f33d9b9a01dc115c670133d3cb0bff6d156c +size 822792 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu18.04.x86_64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu18.04.x86_64.bin new file mode 100755 index 0000000000..975cdf6697 --- /dev/null +++ b/src/ai/backend/runner/dropbearconvert.ubuntu18.04.x86_64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:888ff666ffd4f6f3883f3403b211ee2722ffacfe57a21e78936f5155873813c2 +size 1185384 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu20.04.aarch64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu20.04.aarch64.bin index 39f0fcf065..e45bf8c86d 100755 --- a/src/ai/backend/runner/dropbearconvert.ubuntu20.04.aarch64.bin +++ b/src/ai/backend/runner/dropbearconvert.ubuntu20.04.aarch64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fef3ec5f750eb79a87d473121b532005e67bdb528327195aef5a0247ed6bb40 -size 905448 +oid sha256:9438c81fb2cfe08a7ddff42fe5a0a318a8ca16dfca2e653134f85cba97944607 +size 907240 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu20.04.x86_64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu20.04.x86_64.bin index ba6f88d4ce..7f173c457e 100755 --- a/src/ai/backend/runner/dropbearconvert.ubuntu20.04.x86_64.bin +++ b/src/ai/backend/runner/dropbearconvert.ubuntu20.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b1eb6ef98e25b4901ae4806c410f8cc233c7cecc3a489a26ad3d3bc8e994fc1 -size 1221640 +oid sha256:f95b38193d91b0eae922411898facf3c69c23ce5657b5cc2bc2b130f3a0b1c12 +size 1221752 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu22.04.aarch64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu22.04.aarch64.bin new file mode 100755 index 0000000000..6789e1946c --- /dev/null +++ b/src/ai/backend/runner/dropbearconvert.ubuntu22.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d387e4f557eae12d90a664f165e9b1163d4f5bf07b72274bc62524f26fdf699 +size 1079728 diff --git a/src/ai/backend/runner/dropbearconvert.ubuntu22.04.x86_64.bin b/src/ai/backend/runner/dropbearconvert.ubuntu22.04.x86_64.bin index 8e51f02ed8..db7d3666f6 100755 --- a/src/ai/backend/runner/dropbearconvert.ubuntu22.04.x86_64.bin +++ b/src/ai/backend/runner/dropbearconvert.ubuntu22.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ee791ffb7a6ab7962c7f75a0f6a3ed40ef1421d44178e42962538bf63bb9a09 -size 1413720 +oid sha256:9f4c53f07f4e2535b00ad5e1367c7126e746946a6ae2cb95544130147932f77b +size 1413752 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu18.04.aarch64.bin b/src/ai/backend/runner/dropbearkey.ubuntu18.04.aarch64.bin new file mode 100755 index 0000000000..d6c177b24b --- /dev/null +++ b/src/ai/backend/runner/dropbearkey.ubuntu18.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d57cee7f7b2d4d4cf6df9694f588dbd52c26da9f5456a93e6039438cab99fddb +size 822328 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu18.04.x86_64.bin b/src/ai/backend/runner/dropbearkey.ubuntu18.04.x86_64.bin new file mode 100755 index 0000000000..0f0b3352e5 --- /dev/null +++ b/src/ai/backend/runner/dropbearkey.ubuntu18.04.x86_64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f5d8abd09966a1c32b01ab7654f05210723eb9c2088f3bd8c8a6c1562cdd8a0 +size 1176744 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu20.04.aarch64.bin b/src/ai/backend/runner/dropbearkey.ubuntu20.04.aarch64.bin index 4033ad3d88..20d35f1606 100755 --- a/src/ai/backend/runner/dropbearkey.ubuntu20.04.aarch64.bin +++ b/src/ai/backend/runner/dropbearkey.ubuntu20.04.aarch64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d3dd9e19e5f963f4bf8bc62dcc4620e802397bd03ac75b59f000904812d324c -size 896616 +oid sha256:a1b2a390c97762ab1b4ee70cc4517b07edbf0684fabbbf4306c549aae8c1363f +size 902528 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu20.04.x86_64.bin b/src/ai/backend/runner/dropbearkey.ubuntu20.04.x86_64.bin index 1761a80862..8074e014ab 100755 --- a/src/ai/backend/runner/dropbearkey.ubuntu20.04.x86_64.bin +++ b/src/ai/backend/runner/dropbearkey.ubuntu20.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7275d8bb392cdcb28011dab9eb318b72cea01321fcc3437a00dffcb992a13e7 -size 1212792 +oid sha256:b12aa2052abbcb9aa7aed944a02e2e1f1c8ee06c863f428e4f2f1559a48bccb6 +size 1217000 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu22.04.aarch64.bin b/src/ai/backend/runner/dropbearkey.ubuntu22.04.aarch64.bin new file mode 100755 index 0000000000..10fe3dcc49 --- /dev/null +++ b/src/ai/backend/runner/dropbearkey.ubuntu22.04.aarch64.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b791d5a296e51b74fb79a82fc97498543578f2509cfb355c3234896cf4c5a619 +size 1074872 diff --git a/src/ai/backend/runner/dropbearkey.ubuntu22.04.x86_64.bin b/src/ai/backend/runner/dropbearkey.ubuntu22.04.x86_64.bin index 3d681a6418..2b6df92144 100755 --- a/src/ai/backend/runner/dropbearkey.ubuntu22.04.x86_64.bin +++ b/src/ai/backend/runner/dropbearkey.ubuntu22.04.x86_64.bin @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ff1aeb40c11445470257d660ee016950b46340e7881a3b76b3993c5f5f67828 -size 1404800 +oid sha256:1bd6718719a585093144d72edde8538208b5a5ff6e33ad29dc428864b3088bd8 +size 1404840 diff --git a/src/ai/backend/storage/abc.py b/src/ai/backend/storage/abc.py index 970df8afa8..87367091bd 100644 --- a/src/ai/backend/storage/abc.py +++ b/src/ai/backend/storage/abc.py @@ -86,7 +86,7 @@ async def describe_quota_scope( async def update_quota_scope( self, quota_scope_id: QuotaScopeID, - options: QuotaConfig, + config: QuotaConfig, ) -> None: """ Update the quota option of the given quota scope. @@ -153,7 +153,8 @@ async def delete_tree( def scan_tree( self, path: Path, - recursive=False, + *, + recursive: bool = True, ) -> AsyncIterator[DirEntry]: """ Iterates over all files within the given path recursively. @@ -320,7 +321,11 @@ async def get_used_bytes(self, vfid: VFolderID) -> BinarySize: @abstractmethod def scandir( - self, vfid: VFolderID, relpath: PurePosixPath, recursive=True + self, + vfid: VFolderID, + relpath: PurePosixPath, + *, + recursive: bool = True, ) -> AsyncIterator[DirEntry]: pass @@ -404,6 +409,7 @@ async def delete_files( self, vfid: VFolderID, relpaths: Sequence[PurePosixPath], + *, recursive: bool = False, ) -> None: pass diff --git a/src/ai/backend/storage/api/client.py b/src/ai/backend/storage/api/client.py index 3bfe2feb8b..cc5be0766b 100644 --- a/src/ai/backend/storage/api/client.py +++ b/src/ai/backend/storage/api/client.py @@ -216,7 +216,7 @@ class Params(TypedDict): async def download_directory_as_archive( request: web.Request, file_path: Path, - zip_filename: str = None, + zip_filename: str | None = None, ) -> web.StreamResponse: """ Serve a directory as a zip archive on the fly. diff --git a/src/ai/backend/storage/api/manager.py b/src/ai/backend/storage/api/manager.py index c397aa873a..9b25e01342 100644 --- a/src/ai/backend/storage/api/manager.py +++ b/src/ai/backend/storage/api/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import json import logging import os @@ -17,7 +18,6 @@ Awaitable, Callable, Iterator, - List, NotRequired, TypedDict, cast, @@ -37,7 +37,7 @@ VolumeUnmounted, ) from ai.backend.common.logging import BraceStyleAdapter -from ai.backend.common.types import AgentId, BinarySize, QuotaScopeID +from ai.backend.common.types import AgentId, BinarySize, ItemResult, QuotaScopeID, ResultSet from ai.backend.storage.exception import ExecutionError from ai.backend.storage.watcher import ChownTask, MountTask, UmountTask @@ -144,7 +144,7 @@ def handle_external_errors() -> Iterator[None]: async def get_volumes(request: web.Request) -> web.Response: - async def _get_caps(ctx: RootContext, volume_name: str) -> List[str]: + async def _get_caps(ctx: RootContext, volume_name: str) -> list[str]: async with ctx.get_volume(volume_name) as volume: return [*await volume.get_capabilities()] @@ -755,7 +755,7 @@ async def mkdir(request: web.Request) -> web.Response: class Params(TypedDict): volume: str vfid: VFolderID - relpath: PurePosixPath + relpath: PurePosixPath | list[PurePosixPath] parents: bool exist_ok: bool @@ -767,7 +767,8 @@ class Params(TypedDict): { t.Key("volume"): t.String(), t.Key("vfid"): tx.VFolderID(), - t.Key("relpath"): tx.PurePath(relative_only=True), + t.Key("relpath"): tx.PurePath(relative_only=True) + | t.List(tx.PurePath(relative_only=True), max_length=50), t.Key("parents", default=True): t.ToBool, t.Key("exist_ok", default=False): t.ToBool, }, @@ -776,15 +777,53 @@ class Params(TypedDict): ) as params: await log_manager_api_entry(log, "mkdir", params) ctx: RootContext = request.app["ctx"] + vfid = params["vfid"] + parents = params["parents"] + exist_ok = params["exist_ok"] + relpath = params["relpath"] + relpaths = relpath if isinstance(relpath, list) else [relpath] + failed_results: list[ItemResult] = [] + success_results: list[ItemResult] = [] + async with ctx.get_volume(params["volume"]) as volume: - with handle_fs_errors(volume, params["vfid"]): - await volume.mkdir( - params["vfid"], - params["relpath"], - parents=params["parents"], - exist_ok=params["exist_ok"], + mkdir_tasks = [ + volume.mkdir(vfid, rpath, parents=parents, exist_ok=exist_ok) for rpath in relpaths + ] + result_group = await asyncio.gather(*mkdir_tasks, return_exceptions=True) + failed_cases = [isinstance(res, BaseException) for res in result_group] + + for relpath, result_or_exception in zip(relpaths, result_group): + if isinstance(result_or_exception, BaseException): + log.error( + "Failed to create the directory {!r} in vol:{}/vfid:{}:", + relpath, + volume, + vfid, + exc_info=result_or_exception, ) - return web.Response(status=204) + failed_results.append({ + "msg": repr(result_or_exception), + "item": str(relpath), + }) + else: + success_results.append({ + "msg": None, + "item": str(relpath), + }) + results: ResultSet = { + "success": success_results, + "failed": failed_results, + } + if all(failed_cases): + status_code = 422 + elif any(failed_cases): + status_code = 207 + else: + status_code = 200 + return web.json_response( + {"results": results}, + status=status_code, + ) async def list_files(request: web.Request) -> web.Response: @@ -1021,7 +1060,7 @@ class Params(TypedDict): await volume.delete_files( params["vfid"], params["relpaths"], - params["recursive"], + recursive=params["recursive"], ) return web.json_response( { diff --git a/src/ai/backend/storage/cephfs/__init__.py b/src/ai/backend/storage/cephfs/__init__.py index de76f22aa0..b942fd7ec6 100644 --- a/src/ai/backend/storage/cephfs/__init__.py +++ b/src/ai/backend/storage/cephfs/__init__.py @@ -87,24 +87,24 @@ async def unset_quota(self, quota_scope_id: QuotaScopeID) -> None: class CephFSOpModel(BaseFSOpModel): - async def scan_tree_usage(self, target_path: Path) -> TreeUsage: + async def scan_tree_usage(self, path: Path) -> TreeUsage: loop = asyncio.get_running_loop() raw_reports = await loop.run_in_executor( None, lambda: ( - os.getxattr(target_path, "ceph.dir.rentries"), # type: ignore[attr-defined] - os.getxattr(target_path, "ceph.dir.rbytes"), # type: ignore[attr-defined] + os.getxattr(path, "ceph.dir.rentries"), # type: ignore[attr-defined] + os.getxattr(path, "ceph.dir.rbytes"), # type: ignore[attr-defined] ), ) file_count = int(raw_reports[0].strip().decode()) used_bytes = int(raw_reports[1].strip().decode()) return TreeUsage(file_count=file_count, used_bytes=used_bytes) - async def scan_tree_size(self, target_path: Path) -> BinarySize: + async def scan_tree_size(self, path: Path) -> BinarySize: loop = asyncio.get_running_loop() raw_report = await loop.run_in_executor( None, - lambda: os.getxattr(target_path, "ceph.dir.rbytes"), # type: ignore[attr-defined] + lambda: os.getxattr(path, "ceph.dir.rbytes"), # type: ignore[attr-defined] ) return BinarySize(raw_report.strip().decode()) diff --git a/src/ai/backend/storage/gpfs/types.py b/src/ai/backend/storage/gpfs/types.py index 57dcea5662..5657fc2be4 100644 --- a/src/ai/backend/storage/gpfs/types.py +++ b/src/ai/backend/storage/gpfs/types.py @@ -173,7 +173,7 @@ class GPFSJobRequest(DataClassJsonMixin): data: Optional[Any] = None -class GPFSJobStatus(str, enum.Enum): +class GPFSJobStatus(enum.StrEnum): RUNNING = "RUNNING" CANCELLING = "CANCELLING" CANCELLED = "CANCELLED" @@ -193,7 +193,7 @@ class GPFSJob(DataClassJsonMixin): pids: Optional[List[int]] = None -class GPFSQuotaType(str, enum.Enum): +class GPFSQuotaType(enum.StrEnum): FILESET = "FILESET" USER = "USR" GROUP = "GRP" diff --git a/src/ai/backend/storage/netapp/__init__.py b/src/ai/backend/storage/netapp/__init__.py index 9dc304104f..5708014f81 100644 --- a/src/ai/backend/storage/netapp/__init__.py +++ b/src/ai/backend/storage/netapp/__init__.py @@ -212,10 +212,11 @@ async def delete_tree(self, path: Path) -> None: def scan_tree( self, - target_path: Path, - recursive=False, + path: Path, + *, + recursive: bool = True, ) -> AsyncIterator[DirEntry]: - target_relpath = target_path.relative_to(self.mount_path) + target_relpath = path.relative_to(self.mount_path) nfspath = f"{self.netapp_nfs_host}:{self.nas_path}/{target_relpath}" # Use a custom formatting scan_cmd = [ @@ -263,7 +264,7 @@ async def read_stdout() -> None: if entry_type == DirEntryType.SYMLINK: try: symlink_dst = Path(item_abspath).resolve() - symlink_dst = symlink_dst.relative_to(target_path) + symlink_dst = symlink_dst.relative_to(path) except (ValueError, RuntimeError): pass else: @@ -319,9 +320,9 @@ async def read_stderr() -> None: async def scan_tree_usage( self, - target_path: Path, + path: Path, ) -> TreeUsage: - target_relpath = target_path.relative_to(self.mount_path) + target_relpath = path.relative_to(self.mount_path) nfspath = f"{self.netapp_nfs_host}:{self.nas_path}/{target_relpath}" total_size = 0 total_count = 0 @@ -367,9 +368,9 @@ async def scan_tree_usage( async def scan_tree_size( self, - target_path: Path, + path: Path, ) -> BinarySize: - usage = await self.scan_tree_usage(target_path) + usage = await self.scan_tree_usage(path) return BinarySize(usage.used_bytes) diff --git a/src/ai/backend/storage/purestorage/__init__.py b/src/ai/backend/storage/purestorage/__init__.py index d614c245c1..0e64056b4a 100644 --- a/src/ai/backend/storage/purestorage/__init__.py +++ b/src/ai/backend/storage/purestorage/__init__.py @@ -54,10 +54,11 @@ async def delete_tree( def scan_tree( self, - target_path: Path, - recursive=False, + path: Path, + *, + recursive: bool = True, ) -> AsyncIterator[DirEntry]: - raw_target_path = os.fsencode(target_path) + raw_target_path = os.fsencode(path) async def _aiter() -> AsyncIterator[DirEntry]: proc = await asyncio.create_subprocess_exec( diff --git a/src/ai/backend/storage/server.py b/src/ai/backend/storage/server.py index 70cbde78f5..8ca0ca7050 100644 --- a/src/ai/backend/storage/server.py +++ b/src/ai/backend/storage/server.py @@ -232,15 +232,15 @@ async def server_main( ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context def main( cli_ctx: click.Context, config_path: Path, - log_level: str, + log_level: LogSeverity, debug: bool = False, ) -> int: """Start the storage-proxy service as a foreground process.""" @@ -254,10 +254,10 @@ def main( print(pformat(e.invalid_data), file=sys.stderr) raise click.Abort() if debug: - log_level = "DEBUG" - override_key(local_config, ("debug", "enabled"), log_level == "DEBUG") - override_key(local_config, ("logging", "level"), log_level.upper()) - override_key(local_config, ("logging", "pkg-ns", "ai.backend"), log_level.upper()) + log_level = LogSeverity.DEBUG + override_key(local_config, ("debug", "enabled"), log_level == LogSeverity.DEBUG) + override_key(local_config, ("logging", "level"), log_level) + override_key(local_config, ("logging", "pkg-ns", "ai.backend"), log_level) multiprocessing.set_start_method("spawn") diff --git a/src/ai/backend/storage/vast/__init__.py b/src/ai/backend/storage/vast/__init__.py index aa49513c5d..9cb0f819d6 100644 --- a/src/ai/backend/storage/vast/__init__.py +++ b/src/ai/backend/storage/vast/__init__.py @@ -101,7 +101,11 @@ async def create_quota_scope( raise ExternalError(str(e)) await self._set_vast_quota_id(quota_scope_id, quota.id) - async def update_quota_scope(self, quota_scope_id: QuotaScopeID, config: QuotaConfig) -> None: + async def update_quota_scope( + self, + quota_scope_id: QuotaScopeID, + config: QuotaConfig, + ) -> None: vast_quota_id = await self._get_vast_quota_id(quota_scope_id) if vast_quota_id is None: raise QuotaScopeNotFoundError diff --git a/src/ai/backend/storage/vfs/__init__.py b/src/ai/backend/storage/vfs/__init__.py index 508c4d0194..12bd614c7a 100644 --- a/src/ai/backend/storage/vfs/__init__.py +++ b/src/ai/backend/storage/vfs/__init__.py @@ -102,7 +102,7 @@ async def describe_quota_scope( async def update_quota_scope( self, quota_scope_id: QuotaScopeID, - options: QuotaConfig, + config: QuotaConfig, ) -> None: # This is a no-op. pass @@ -232,17 +232,18 @@ async def delete_tree( def scan_tree( self, - target_path: Path, + path: Path, + *, recursive: bool = True, ) -> AsyncIterator[DirEntry]: q: janus.Queue[Sentinel | DirEntry] = janus.Queue() loop = asyncio.get_running_loop() - def _scandir(target_path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) -> None: + def _scandir(path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) -> None: count = 0 limit = self.scandir_limit next_paths: deque[Path] = deque() - next_paths.append(target_path) + next_paths.append(path) while next_paths: next_path = next_paths.popleft() with os.scandir(next_path) as scanner: @@ -258,7 +259,7 @@ def _scandir(target_path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) - entry_type = DirEntryType.SYMLINK try: symlink_dst = Path(entry).resolve() - symlink_dst = symlink_dst.relative_to(target_path) + symlink_dst = symlink_dst.relative_to(path) except (ValueError, RuntimeError): # ValueError and ELOOP pass @@ -270,7 +271,7 @@ def _scandir(target_path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) - continue item = DirEntry( name=entry.name, - path=Path(entry.path).relative_to(target_path), + path=Path(entry.path).relative_to(path), type=entry_type, stat=Stat( size=entry_stat.st_size, @@ -288,7 +289,7 @@ def _scandir(target_path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) - async def _scan_task(q: janus.Queue[Sentinel | DirEntry]) -> None: try: - await loop.run_in_executor(None, _scandir, target_path, q.sync_q) + await loop.run_in_executor(None, _scandir, path, q.sync_q) finally: await q.async_q.put(SENTINEL) @@ -313,17 +314,17 @@ async def _aiter() -> AsyncIterator[DirEntry]: async def scan_tree_usage( self, - target_path: Path, + path: Path, ) -> TreeUsage: total_size = 0 total_count = 0 start_time = time.monotonic() _timeout = 30 - def _calc_usage(target_path: Path) -> None: + def _calc_usage(path: Path) -> None: nonlocal total_size, total_count next_paths: deque[Path] = deque() - next_paths.append(target_path) + next_paths.append(path) while next_paths: next_path = next_paths.popleft() with os.scandir(next_path) as scanner: # type: ignore @@ -344,7 +345,7 @@ def _calc_usage(target_path: Path) -> None: loop = asyncio.get_running_loop() try: - await loop.run_in_executor(None, _calc_usage, target_path) + await loop.run_in_executor(None, _calc_usage, path) except TimeoutError: # -1 indicates "too many" total_size = -1 @@ -353,9 +354,9 @@ def _calc_usage(target_path: Path) -> None: async def scan_tree_size( self, - target_path: Path, + path: Path, ) -> BinarySize: - info = await run(["du", "-hs", target_path]) + info = await run(["du", "-hs", path]) used_bytes, _ = info.split() return BinarySize.finite_from_str(used_bytes) @@ -489,7 +490,11 @@ async def get_used_bytes(self, vfid: VFolderID) -> BinarySize: @final def scandir( - self, vfid: VFolderID, relpath: PurePosixPath, recursive=True + self, + vfid: VFolderID, + relpath: PurePosixPath, + *, + recursive: bool = True, ) -> AsyncIterator[DirEntry]: target_path = self.sanitize_vfpath(vfid, relpath) return self.fsop_model.scan_tree(target_path, recursive=recursive) @@ -664,6 +669,7 @@ async def delete_files( self, vfid: VFolderID, relpaths: Sequence[PurePosixPath], + *, recursive: bool = False, ) -> None: target_paths = [self.sanitize_vfpath(vfid, p) for p in relpaths] diff --git a/src/ai/backend/web/server.py b/src/ai/backend/web/server.py index a5a65f61c0..53234600b8 100644 --- a/src/ai/backend/web/server.py +++ b/src/ai/backend/web/server.py @@ -726,15 +726,15 @@ async def on_prepare(request, response): ) @click.option( "--log-level", - type=click.Choice([*LogSeverity.__members__.keys()], case_sensitive=False), - default="INFO", + type=click.Choice([*LogSeverity], case_sensitive=False), + default=LogSeverity.INFO, help="Set the logging verbosity level", ) @click.pass_context def main( ctx: click.Context, config_path: Path, - log_level: str, + log_level: LogSeverity, debug: bool, ) -> None: """Start the webui host service as a foreground process.""" @@ -742,10 +742,10 @@ def main( raw_cfg = tomli.loads(Path(config_path).read_text(encoding="utf-8")) if debug: - log_level = "DEBUG" - config.override_key(raw_cfg, ("debug", "enabled"), log_level == "DEBUG") - config.override_key(raw_cfg, ("logging", "level"), log_level.upper()) - config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level.upper()) + log_level = LogSeverity.DEBUG + config.override_key(raw_cfg, ("debug", "enabled"), log_level == LogSeverity.DEBUG) + config.override_key(raw_cfg, ("logging", "level"), log_level) + config.override_key(raw_cfg, ("logging", "pkg-ns", "ai.backend"), log_level) cfg = config.check(raw_cfg, config_iv) diff --git a/tests/manager/conftest.py b/tests/manager/conftest.py index 6453dfb41f..74e6b82730 100644 --- a/tests/manager/conftest.py +++ b/tests/manager/conftest.py @@ -39,7 +39,7 @@ from ai.backend.common.config import ConfigurationError, etcd_config_iv, redis_config_iv from ai.backend.common.logging import LocalLogger from ai.backend.common.plugin.hook import HookPluginContext -from ai.backend.common.types import HostPortPair +from ai.backend.common.types import HostPortPair, LogSeverity from ai.backend.manager.api.context import RootContext from ai.backend.manager.api.types import CleanupContext from ai.backend.manager.cli.context import CLIContext @@ -237,7 +237,7 @@ def etcd_fixture( redis_addr = local_config["redis"]["addr"] cli_ctx = CLIContext( config_path=Path.cwd() / "dummy-manager.toml", - log_level="DEBUG", + log_level=LogSeverity.DEBUG, ) cli_ctx._local_config = local_config # override the lazy-loaded config with tempfile.NamedTemporaryFile(mode="w", suffix=".etcd.json") as f: @@ -857,7 +857,10 @@ async def session_info(database_engine): db_sess.add(domain) user_resource_policy = UserResourcePolicyRow( - name=resource_policy_name, max_vfolder_count=0, max_quota_scope_size=-1 + name=resource_policy_name, + max_vfolder_count=0, + max_quota_scope_size=-1, + max_session_count_per_model_session=10, ) db_sess.add(user_resource_policy)