diff --git a/.github/workflows/build-image-pr.yml b/.github/workflows/build-image-pr.yml index 5c98277e..c15f7dd8 100644 --- a/.github/workflows/build-image-pr.yml +++ b/.github/workflows/build-image-pr.yml @@ -34,7 +34,7 @@ jobs: IMG_VERSION: ${{ steps.tags.outputs.tag }} run: make image/build - name: Start Kind Cluster - uses: helm/kind-action@v1.11.0 + uses: helm/kind-action@v1.12.0 with: node_image: "kindest/node:v1.27.11" - name: Load Local Registry Test Image diff --git a/.github/workflows/csi-test.yml b/.github/workflows/csi-test.yml index cca5b6da..e6d81891 100644 --- a/.github/workflows/csi-test.yml +++ b/.github/workflows/csi-test.yml @@ -59,7 +59,7 @@ jobs: run: make image/build - name: Start KinD cluster - uses: helm/kind-action@v1.11.0 + uses: helm/kind-action@v1.12.0 with: node_image: "kindest/node:v1.27.11" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index cabe5b04..ee0907f1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -66,12 +66,25 @@ jobs: fi test: - name: Test against Py ${{ matrix.python }} + name: Test against Py ${{ matrix.python }} and K8s ${{ matrix.kubernetes-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python: ["3.12", "3.11", "3.10", "3.9"] + python: ["3.12"] # see below for versions 3.9-3.11 + kubernetes-version: ["v1.27.11", "v1.28.7", "v1.29.2", "v1.30.6", "v1.31.0"] + exclude: # on main merges (not PRs), use also different K8s versions for E2E testing + - kubernetes-version: ${{ github.event_name != 'push' && 'v1.28.7' }} + - kubernetes-version: ${{ github.event_name != 'push' && 'v1.29.2' }} + - kubernetes-version: ${{ github.event_name != 'push' && 'v1.30.6' }} + - kubernetes-version: ${{ github.event_name != 'push' && 'v1.31.0' }} + include: # test Py versions only with a reference K8s version, designated currently to kubernetes-version: v1.27.11 + - python: "3.11" + kubernetes-version: "v1.27.11" + - python: "3.10" + kubernetes-version: "v1.27.11" + - python: "3.9" + kubernetes-version: "v1.27.11" env: FORCE_COLOR: "1" IMG_ORG: kubeflow @@ -122,10 +135,11 @@ jobs: IMG_VERSION: ${{ steps.tags.outputs.tag }} run: make image/build - name: Start Kind Cluster - uses: helm/kind-action@v1.11.0 + uses: helm/kind-action@v1.12.0 with: - node_image: "kindest/node:v1.27.11" + node_image: kindest/node:${{ matrix.kubernetes-version }} cluster_name: chart-testing-py-${{ matrix.python }} + kubectl_version: ${{ matrix.kubernetes-version }} - name: Load Local Registry Test Image env: IMG: "${{ env.IMG_ORG }}/${{ env.IMG_REPO }}:${{ steps.tags.outputs.tag }}" diff --git a/clients/python/poetry.lock b/clients/python/poetry.lock index 1310f3b7..344cd26d 100644 --- a/clients/python/poetry.lock +++ b/clients/python/poetry.lock @@ -13,87 +13,87 @@ files = [ [[package]] name = "aiohttp" -version = "3.11.10" +version = "3.11.11" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" files = [ - {file = "aiohttp-3.11.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cbad88a61fa743c5d283ad501b01c153820734118b65aee2bd7dbb735475ce0d"}, - {file = "aiohttp-3.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80886dac673ceaef499de2f393fc80bb4481a129e6cb29e624a12e3296cc088f"}, - {file = "aiohttp-3.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61b9bae80ed1f338c42f57c16918853dc51775fb5cb61da70d590de14d8b5fb4"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e2e576caec5c6a6b93f41626c9c02fc87cd91538b81a3670b2e04452a63def6"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02c13415b5732fb6ee7ff64583a5e6ed1c57aa68f17d2bda79c04888dfdc2769"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfce37f31f20800a6a6620ce2cdd6737b82e42e06e6e9bd1b36f546feb3c44f"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bbbfff4c679c64e6e23cb213f57cc2c9165c9a65d63717108a644eb5a7398df"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49c7dbbc1a559ae14fc48387a115b7d4bbc84b4a2c3b9299c31696953c2a5219"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:68386d78743e6570f054fe7949d6cb37ef2b672b4d3405ce91fafa996f7d9b4d"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9ef405356ba989fb57f84cac66f7b0260772836191ccefbb987f414bcd2979d9"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5d6958671b296febe7f5f859bea581a21c1d05430d1bbdcf2b393599b1cdce77"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:99b7920e7165be5a9e9a3a7f1b680f06f68ff0d0328ff4079e5163990d046767"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0dc49f42422163efb7e6f1df2636fe3db72713f6cd94688e339dbe33fe06d61d"}, - {file = "aiohttp-3.11.10-cp310-cp310-win32.whl", hash = "sha256:40d1c7a7f750b5648642586ba7206999650208dbe5afbcc5284bcec6579c9b91"}, - {file = "aiohttp-3.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:68ff6f48b51bd78ea92b31079817aff539f6c8fc80b6b8d6ca347d7c02384e33"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:77c4aa15a89847b9891abf97f3d4048f3c2d667e00f8a623c89ad2dccee6771b"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:909af95a72cedbefe5596f0bdf3055740f96c1a4baa0dd11fd74ca4de0b4e3f1"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:386fbe79863eb564e9f3615b959e28b222259da0c48fd1be5929ac838bc65683"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3de34936eb1a647aa919655ff8d38b618e9f6b7f250cc19a57a4bf7fd2062b6d"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c9527819b29cd2b9f52033e7fb9ff08073df49b4799c89cb5754624ecd98299"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a96e3e03300b41f261bbfd40dfdbf1c301e87eab7cd61c054b1f2e7c89b9e8"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f5635f7b74bcd4f6f72fcd85bea2154b323a9f05226a80bc7398d0c90763b0"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03b6002e20938fc6ee0918c81d9e776bebccc84690e2b03ed132331cca065ee5"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6362cc6c23c08d18ddbf0e8c4d5159b5df74fea1a5278ff4f2c79aed3f4e9f46"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3691ed7726fef54e928fe26344d930c0c8575bc968c3e239c2e1a04bd8cf7838"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31d5093d3acd02b31c649d3a69bb072d539d4c7659b87caa4f6d2bcf57c2fa2b"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8b3cf2dc0f0690a33f2d2b2cb15db87a65f1c609f53c37e226f84edb08d10f52"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbbaea811a2bba171197b08eea288b9402faa2bab2ba0858eecdd0a4105753a3"}, - {file = "aiohttp-3.11.10-cp311-cp311-win32.whl", hash = "sha256:4b2c7ac59c5698a7a8207ba72d9e9c15b0fc484a560be0788b31312c2c5504e4"}, - {file = "aiohttp-3.11.10-cp311-cp311-win_amd64.whl", hash = "sha256:974d3a2cce5fcfa32f06b13ccc8f20c6ad9c51802bb7f829eae8a1845c4019ec"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b78f053a7ecfc35f0451d961dacdc671f4bcbc2f58241a7c820e9d82559844cf"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab7485222db0959a87fbe8125e233b5a6f01f4400785b36e8a7878170d8c3138"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf14627232dfa8730453752e9cdc210966490992234d77ff90bc8dc0dce361d5"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:076bc454a7e6fd646bc82ea7f98296be0b1219b5e3ef8a488afbdd8e81fbac50"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:482cafb7dc886bebeb6c9ba7925e03591a62ab34298ee70d3dd47ba966370d2c"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf3d1a519a324af764a46da4115bdbd566b3c73fb793ffb97f9111dbc684fc4d"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24213ba85a419103e641e55c27dc7ff03536c4873470c2478cce3311ba1eee7b"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b99acd4730ad1b196bfb03ee0803e4adac371ae8efa7e1cbc820200fc5ded109"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:14cdb5a9570be5a04eec2ace174a48ae85833c2aadc86de68f55541f66ce42ab"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7e97d622cb083e86f18317282084bc9fbf261801b0192c34fe4b1febd9f7ae69"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:012f176945af138abc10c4a48743327a92b4ca9adc7a0e078077cdb5dbab7be0"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44224d815853962f48fe124748227773acd9686eba6dc102578defd6fc99e8d9"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c87bf31b7fdab94ae3adbe4a48e711bfc5f89d21cf4c197e75561def39e223bc"}, - {file = "aiohttp-3.11.10-cp312-cp312-win32.whl", hash = "sha256:06a8e2ee1cbac16fe61e51e0b0c269400e781b13bcfc33f5425912391a542985"}, - {file = "aiohttp-3.11.10-cp312-cp312-win_amd64.whl", hash = "sha256:be2b516f56ea883a3e14dda17059716593526e10fb6303189aaf5503937db408"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8cc5203b817b748adccb07f36390feb730b1bc5f56683445bfe924fc270b8816"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ef359ebc6949e3a34c65ce20230fae70920714367c63afd80ea0c2702902ccf"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9bca390cb247dbfaec3c664326e034ef23882c3f3bfa5fbf0b56cad0320aaca5"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811f23b3351ca532af598405db1093f018edf81368e689d1b508c57dcc6b6a32"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddf5f7d877615f6a1e75971bfa5ac88609af3b74796ff3e06879e8422729fd01"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ab29b8a0beb6f8eaf1e5049252cfe74adbaafd39ba91e10f18caeb0e99ffb34"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c49a76c1038c2dd116fa443eba26bbb8e6c37e924e2513574856de3b6516be99"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f3dc0e330575f5b134918976a645e79adf333c0a1439dcf6899a80776c9ab39"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:efb15a17a12497685304b2d976cb4939e55137df7b09fa53f1b6a023f01fcb4e"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:db1d0b28fcb7f1d35600150c3e4b490775251dea70f894bf15c678fdd84eda6a"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:15fccaf62a4889527539ecb86834084ecf6e9ea70588efde86e8bc775e0e7542"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:593c114a2221444f30749cc5e5f4012488f56bd14de2af44fe23e1e9894a9c60"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7852bbcb4d0d2f0c4d583f40c3bc750ee033265d80598d0f9cb6f372baa6b836"}, - {file = "aiohttp-3.11.10-cp313-cp313-win32.whl", hash = "sha256:65e55ca7debae8faaffee0ebb4b47a51b4075f01e9b641c31e554fd376595c6c"}, - {file = "aiohttp-3.11.10-cp313-cp313-win_amd64.whl", hash = "sha256:beb39a6d60a709ae3fb3516a1581777e7e8b76933bb88c8f4420d875bb0267c6"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0580f2e12de2138f34debcd5d88894786453a76e98febaf3e8fe5db62d01c9bf"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a55d2ad345684e7c3dd2c20d2f9572e9e1d5446d57200ff630e6ede7612e307f"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:04814571cb72d65a6899db6099e377ed00710bf2e3eafd2985166f2918beaf59"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e44a9a3c053b90c6f09b1bb4edd880959f5328cf63052503f892c41ea786d99f"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502a1464ccbc800b4b1995b302efaf426e8763fadf185e933c2931df7db9a199"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:613e5169f8ae77b1933e42e418a95931fb4867b2991fc311430b15901ed67079"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cca22a61b7fe45da8fc73c3443150c3608750bbe27641fc7558ec5117b27fdf"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86a5dfcc39309470bd7b68c591d84056d195428d5d2e0b5ccadfbaf25b026ebc"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:77ae58586930ee6b2b6f696c82cf8e78c8016ec4795c53e36718365f6959dc82"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:78153314f26d5abef3239b4a9af20c229c6f3ecb97d4c1c01b22c4f87669820c"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:98283b94cc0e11c73acaf1c9698dea80c830ca476492c0fe2622bd931f34b487"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:53bf2097e05c2accc166c142a2090e4c6fd86581bde3fd9b2d3f9e93dda66ac1"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5532f0441fc09c119e1dca18fbc0687e64fbeb45aa4d6a87211ceaee50a74c4"}, - {file = "aiohttp-3.11.10-cp39-cp39-win32.whl", hash = "sha256:47ad15a65fb41c570cd0ad9a9ff8012489e68176e7207ec7b82a0940dddfd8be"}, - {file = "aiohttp-3.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:c6b9e6d7e41656d78e37ce754813fa44b455c3d0d0dced2a047def7dc5570b74"}, - {file = "aiohttp-3.11.10.tar.gz", hash = "sha256:b1fc6b45010a8d0ff9e88f9f2418c6fd408c99c211257334aff41597ebece42e"}, + {file = "aiohttp-3.11.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a60804bff28662cbcf340a4d61598891f12eea3a66af48ecfdc975ceec21e3c8"}, + {file = "aiohttp-3.11.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b4fa1cb5f270fb3eab079536b764ad740bb749ce69a94d4ec30ceee1b5940d5"}, + {file = "aiohttp-3.11.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:731468f555656767cda219ab42e033355fe48c85fbe3ba83a349631541715ba2"}, + {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb23d8bb86282b342481cad4370ea0853a39e4a32a0042bb52ca6bdde132df43"}, + {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f047569d655f81cb70ea5be942ee5d4421b6219c3f05d131f64088c73bb0917f"}, + {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd7659baae9ccf94ae5fe8bfaa2c7bc2e94d24611528395ce88d009107e00c6d"}, + {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af01e42ad87ae24932138f154105e88da13ce7d202a6de93fafdafb2883a00ef"}, + {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5854be2f3e5a729800bac57a8d76af464e160f19676ab6aea74bde18ad19d438"}, + {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6526e5fb4e14f4bbf30411216780c9967c20c5a55f2f51d3abd6de68320cc2f3"}, + {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:85992ee30a31835fc482468637b3e5bd085fa8fe9392ba0bdcbdc1ef5e9e3c55"}, + {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88a12ad8ccf325a8a5ed80e6d7c3bdc247d66175afedbe104ee2aaca72960d8e"}, + {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0a6d3fbf2232e3a08c41eca81ae4f1dff3d8f1a30bae415ebe0af2d2458b8a33"}, + {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84a585799c58b795573c7fa9b84c455adf3e1d72f19a2bf498b54a95ae0d194c"}, + {file = "aiohttp-3.11.11-cp310-cp310-win32.whl", hash = "sha256:bfde76a8f430cf5c5584553adf9926534352251d379dcb266ad2b93c54a29745"}, + {file = "aiohttp-3.11.11-cp310-cp310-win_amd64.whl", hash = "sha256:0fd82b8e9c383af11d2b26f27a478640b6b83d669440c0a71481f7c865a51da9"}, + {file = "aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76"}, + {file = "aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538"}, + {file = "aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204"}, + {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9"}, + {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03"}, + {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287"}, + {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e"}, + {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665"}, + {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b"}, + {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34"}, + {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d"}, + {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2"}, + {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773"}, + {file = "aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62"}, + {file = "aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac"}, + {file = "aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886"}, + {file = "aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2"}, + {file = "aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c"}, + {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a"}, + {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231"}, + {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e"}, + {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8"}, + {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8"}, + {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c"}, + {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab"}, + {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da"}, + {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853"}, + {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e"}, + {file = "aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600"}, + {file = "aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d"}, + {file = "aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9"}, + {file = "aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194"}, + {file = "aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f"}, + {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104"}, + {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff"}, + {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3"}, + {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1"}, + {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4"}, + {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d"}, + {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87"}, + {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2"}, + {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12"}, + {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5"}, + {file = "aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d"}, + {file = "aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99"}, + {file = "aiohttp-3.11.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3e23419d832d969f659c208557de4a123e30a10d26e1e14b73431d3c13444c2e"}, + {file = "aiohttp-3.11.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21fef42317cf02e05d3b09c028712e1d73a9606f02467fd803f7c1f39cc59add"}, + {file = "aiohttp-3.11.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1f21bb8d0235fc10c09ce1d11ffbd40fc50d3f08a89e4cf3a0c503dc2562247a"}, + {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1642eceeaa5ab6c9b6dfeaaa626ae314d808188ab23ae196a34c9d97efb68350"}, + {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2170816e34e10f2fd120f603e951630f8a112e1be3b60963a1f159f5699059a6"}, + {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8be8508d110d93061197fd2d6a74f7401f73b6d12f8822bbcd6d74f2b55d71b1"}, + {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eed954b161e6b9b65f6be446ed448ed3921763cc432053ceb606f89d793927e"}, + {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6c9af134da4bc9b3bd3e6a70072509f295d10ee60c697826225b60b9959acdd"}, + {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:44167fc6a763d534a6908bdb2592269b4bf30a03239bcb1654781adf5e49caf1"}, + {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:479b8c6ebd12aedfe64563b85920525d05d394b85f166b7873c8bde6da612f9c"}, + {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:10b4ff0ad793d98605958089fabfa350e8e62bd5d40aa65cdc69d6785859f94e"}, + {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b540bd67cfb54e6f0865ceccd9979687210d7ed1a1cc8c01f8e67e2f1e883d28"}, + {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dac54e8ce2ed83b1f6b1a54005c87dfed139cf3f777fdc8afc76e7841101226"}, + {file = "aiohttp-3.11.11-cp39-cp39-win32.whl", hash = "sha256:568c1236b2fde93b7720f95a890741854c1200fba4a3471ff48b2934d2d93fd3"}, + {file = "aiohttp-3.11.11-cp39-cp39-win_amd64.whl", hash = "sha256:943a8b052e54dfd6439fd7989f67fc6a7f2138d0a2cf0a7de5f18aa4fe7eb3b1"}, + {file = "aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e"}, ] [package.dependencies] @@ -708,13 +708,13 @@ files = [ [[package]] name = "huggingface-hub" -version = "0.26.5" +version = "0.27.0" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = true python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.26.5-py3-none-any.whl", hash = "sha256:fb7386090bbe892072e64b85f7c4479fd2d65eea5f2543327c970d5169e83924"}, - {file = "huggingface_hub-0.26.5.tar.gz", hash = "sha256:1008bd18f60bfb65e8dbc0a97249beeeaa8c99d3c2fa649354df9fa5a13ed83b"}, + {file = "huggingface_hub-0.27.0-py3-none-any.whl", hash = "sha256:8f2e834517f1f1ddf1ecc716f91b120d7333011b7485f665a9a412eacb1a2a81"}, + {file = "huggingface_hub-0.27.0.tar.gz", hash = "sha256:902cce1a1be5739f5589e560198a65a8edcfd3b830b1666f36e4b961f0454fac"}, ] [package.dependencies] @@ -794,13 +794,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, ] [package.dependencies] @@ -1053,49 +1053,49 @@ files = [ [[package]] name = "mypy" -version = "1.13.0" +version = "1.14.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, - {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, - {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, - {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, - {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, - {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, - {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, - {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, - {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, - {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, - {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, - {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, - {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, - {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, - {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, - {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, - {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, - {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, - {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, - {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, - {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, - {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, + {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, + {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, + {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, + {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, + {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, + {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, + {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, + {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, + {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, + {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, + {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, + {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, + {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, + {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, + {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, + {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, + {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, + {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, + {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, + {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, + {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, + {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, ] [package.dependencies] -mypy-extensions = ">=1.0.0" +mypy_extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.6.0" +typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1315,18 +1315,18 @@ files = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" +pydantic-core = "2.27.2" typing-extensions = ">=4.12.2" [package.extras] @@ -1335,111 +1335,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [package.dependencies] @@ -1615,29 +1615,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.8.3" +version = "0.8.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"}, - {file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"}, - {file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"}, - {file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"}, - {file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"}, - {file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"}, - {file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"}, + {file = "ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60"}, + {file = "ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac"}, + {file = "ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf"}, + {file = "ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720"}, + {file = "ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae"}, + {file = "ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7"}, + {file = "ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111"}, + {file = "ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8"}, + {file = "ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835"}, + {file = "ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d"}, + {file = "ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08"}, + {file = "ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8"}, ] [[package]] @@ -2284,4 +2284,4 @@ hf = ["huggingface-hub"] [metadata] lock-version = "2.0" python-versions = ">= 3.9, < 4.0" -content-hash = "dc63a589c45f8e27b1e54f471957e97ff241800786af0e62d1c004b7b6538c1c" +content-hash = "d827bd45dcd093cb2bb79340259a18c514cb7c94a038285128a3c07cdd3fe11c" diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 0ea44a74..da5c2a2f 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -26,7 +26,7 @@ nest-asyncio = "^1.6.0" # necessary for modern type annotations using pydantic on 3.9 eval-type-backport = "^0.2.0" -huggingface-hub = { version = ">=0.20.1,<0.27.0", optional = true } +huggingface-hub = { version = ">=0.20.1,<0.28.0", optional = true } [tool.poetry.extras] hf = ["huggingface-hub"] diff --git a/clients/ui/.env.development b/clients/ui/.env.development new file mode 100644 index 00000000..7d7cc8e8 --- /dev/null +++ b/clients/ui/.env.development @@ -0,0 +1,3 @@ +APP_ENV=development +MOCK_AUTH=true +DEPLOYMENT_MODE=standalone \ No newline at end of file diff --git a/clients/ui/.env.production b/clients/ui/.env.production new file mode 100644 index 00000000..a904f4a9 --- /dev/null +++ b/clients/ui/.env.production @@ -0,0 +1 @@ +APP_ENV=production diff --git a/clients/ui/Makefile b/clients/ui/Makefile index 648d578c..cf3dbc4d 100644 --- a/clients/ui/Makefile +++ b/clients/ui/Makefile @@ -32,7 +32,7 @@ dev-install-dependencies: .PHONY: dev-bff dev-bff: - cd bff && make run PORT=4000 MOCK_K8S_CLIENT=true MOCK_MR_CLIENT=true + cd bff && make run PORT=4000 MOCK_K8S_CLIENT=true MOCK_MR_CLIENT=true DEV_MODE=true STANDALONE_MODE=true .PHONY: dev-frontend dev-frontend: diff --git a/clients/ui/bff/Makefile b/clients/ui/bff/Makefile index 103d914d..98707718 100644 --- a/clients/ui/bff/Makefile +++ b/clients/ui/bff/Makefile @@ -5,6 +5,7 @@ MOCK_K8S_CLIENT ?= false MOCK_MR_CLIENT ?= false DEV_MODE ?= false DEV_MODE_PORT ?= 8080 +STANDALONE_MODE ?= true # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.29.0 @@ -47,7 +48,7 @@ build: fmt vet test ## Builds the project to produce a binary executable. .PHONY: run run: fmt vet envtest ## Runs the project. ENVTEST_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \ - go run ./cmd/main.go --port=$(PORT) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) + go run ./cmd/main.go --port=$(PORT) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE) .PHONY: docker-build docker-build: ## Builds a container for the project. diff --git a/clients/ui/bff/README.md b/clients/ui/bff/README.md index fa2ad679..0befdc45 100644 --- a/clients/ui/bff/README.md +++ b/clients/ui/bff/README.md @@ -58,6 +58,7 @@ make docker-build |----------------------------------------------------------------------------------------------|----------------------------------------------|-------------------------------------------------------------| | GET /v1/healthcheck | HealthcheckHandler | Show application information. | | GET /v1/user | UserHandler | Show "kubeflow-user-id" from header information. | +| GET /v1/namespaces | NamespacesHandler | Get all user namespaces. (only enabled in devmode) | | GET /v1/model_registry | ModelRegistryHandler | Get all model registries, | | GET /v1/model_registry/{model_registry_id}/registered_models | GetAllRegisteredModelsHandler | Gets a list of all RegisteredModel entities. | | POST /v1/model_registry/{model_registry_id}/registered_models | CreateRegisteredModelHandler | Create a RegisteredModel entity. | @@ -71,28 +72,51 @@ make docker-build | GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts | GetAllModelArtifactsByModelVersionHandler | Get all ModelArtifact entities by ModelVersion ID | | POST /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts | CreateModelArtifactByModelVersion | Create a ModelArtifact entity for a specific ModelVersion | +Note: Most API paths require the namespace parameter to be passed as a query parameter. +The only exceptions are the health check (/v1/healthcheck) and user (/v1/user) paths, which do not require the namespace parameter. + ### Sample local calls -You will need to inject your requests with a kubeflow-userid header for authorization purposes. When running the service with the mocked Kubernetes client (MOCK_K8S_CLIENT=true), the user user@example.com is preconfigured with the necessary RBAC permissions to perform these actions. +You will need to inject your requests with a `kubeflow-userid` header and namespace for authorization purposes. + +When running the service with the mocked Kubernetes client (MOCK_K8S_CLIENT=true), the user `user@example.com` is preconfigured with the necessary RBAC permissions to perform these actions. ``` # GET /v1/healthcheck -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/healthcheck +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/healthcheck" ``` ``` # GET /v1/user -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/user +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/user" +``` +``` +# GET /v1/namespaces (only works when DEV_MODE=true) +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/namespaces" ``` ``` # GET /v1/model_registry -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/model_registry +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry?namespace=kubeflow" +``` +``` +# GET /v1/model_registry using groups permissions +curl -i \ + -H "kubeflow-userid: non-user@example.com" \ + -H "kubeflow-groups: dora-namespace-group ,group2,group3" \ + "http://localhost:4000/api/v1/model_registry?namespace=dora-namespace" ``` ``` # GET /v1/model_registry/{model_registry_id}/registered_models -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/model_registry/model-registry/registered_models +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow" +``` +``` +# GET /v1/model_registry/{model_registry_id}/registered_models using group permissions +curl -i \ + -H "kubeflow-userid: non-user@example.com" \ + -H "kubeflow-groups: dora-namespace-group ,dora-service-group,group3" \ + "http://localhost:4000/api/v1/model_registry/model-registry-dora/registered_models?namespace=dora-namespace" ``` ``` #POST /v1/model_registry/{model_registry_id}/registered_models -curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models" \ +curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "customProperties": { @@ -110,11 +134,11 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap ``` ``` # GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id} -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/model_registry/model-registry/registered_models/1 +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow" ``` ``` # PATCH /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id} -curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1" \ +curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "description": "New description" @@ -122,11 +146,11 @@ curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/a ``` ``` # GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id} -curl -i -H "kubeflow-userid: user@example.com" http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1 +curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow" ``` ``` # POST /api/v1/model_registry/{model_registry_id}/model_versions -curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions" \ +curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "customProperties": { @@ -145,7 +169,7 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap ``` ``` # PATCH /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id} -curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1" \ +curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "description": "New description 2" @@ -153,11 +177,11 @@ curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/a ``` ``` # GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions -curl -i -H "kubeflow-userid: user@example.com" localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions +curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow" ``` ``` # POST /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions -curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions" \ +curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "customProperties": { @@ -171,16 +195,16 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap "name": "ModelVersion One", "state": "LIVE", "author": "alex", - "registeredModelId: "1" + "registeredModelId": "1" }}' ``` ``` -# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts -curl -i -H "kubeflow-userid: user@example.com" http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts +# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts +curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow" ``` ``` # POST /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts -curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts" \ +curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow" \ -H "Content-Type: application/json" \ -d '{ "data": { "customProperties": { @@ -241,13 +265,35 @@ The mock Kubernetes environment is activated when the environment variable `MOCK - **Namespaces**: - `kubeflow` - `dora-namespace` + - `bella-namespace` - **Users**: - `user@example.com` (has `cluster-admin` privileges) - `doraNonAdmin@example.com` (restricted to the `dora-namespace`) + - `bellaNonAdmin@example.com` (restricted to the `bella-namespace`) +- **Groups**: + - `dora-service-group` (has access to `model-registry-dora` inside `dora-namespace`) + - `dora-namespace-group` (has access to the `dora-namespace`) + - **Services (Model Registries)**: - `model-registry`: resides in the `kubeflow` namespace with the label `component: model-registry`. + - `model-registry-one`: resides in the `kubeflow` namespace with the label `component: model-registry`. + - `non-model-registry`: resides in the `kubeflow` namespace *without* the label `component: model-registry`. - `model-registry-dora`: resides in the `dora-namespace` namespace with the label `component: model-registry`. - - `model-registry-bella`: resides in the `kubeflow` namespace with the label `component: model-registry`. - - `non-model-registry`: resides in the `kubeflow` namespace *without* the label `component: model-registry`. \ No newline at end of file + +#### 3. How BFF authorization works for kubeflow-userid and kubeflow-groups? + +Authorization is performed using Kubernetes SubjectAccessReview (SAR), which validates user access to resources. + +- `kubeflow-userid`: Required header that specifies the user’s email. Access is checked directly for the user via SAR. +- `kubeflow-groups`: Optional header with a comma-separated list of groups. If the user does not have access, SAR checks group permissions using OR logic. If any group has access, the request is authorized. + + +Access to Model Registry List: +- To list all model registries (/v1/model_registry), we perform a SAR check for get and list verbs on services within the specified namespace. +- If the user or any group has permission to get and list services in the namespace, the request is authorized. + +Access to Specific Model Registry Endpoints: +- For other endpoints (e.g., /v1/model_registry/{model_registry_id}/...), we perform a SAR check for get and list verbs on the specific service (identified by model_registry_id) within the namespace. +- If the user or any group has permission to get or list the specific service, the request is authorized. diff --git a/clients/ui/bff/cmd/main.go b/clients/ui/bff/cmd/main.go index eb719229..5ed0979d 100644 --- a/clients/ui/bff/cmd/main.go +++ b/clients/ui/bff/cmd/main.go @@ -24,6 +24,7 @@ func main() { flag.BoolVar(&cfg.MockMRClient, "mock-mr-client", false, "Use mock Model Registry client") flag.BoolVar(&cfg.DevMode, "dev-mode", false, "Use development mode for access to local K8s cluster") flag.IntVar(&cfg.DevModePort, "dev-mode-port", getEnvAsInt("DEV_MODE_PORT", 8080), "Use port when in development mode") + flag.BoolVar(&cfg.StandaloneMode, "standalone-mode", false, "Use standalone mode for enabling endpoints in standalone mode") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) diff --git a/clients/ui/bff/internal/api/app.go b/clients/ui/bff/internal/api/app.go index c7047f29..c144c232 100644 --- a/clients/ui/bff/internal/api/app.go +++ b/clients/ui/bff/internal/api/app.go @@ -3,18 +3,20 @@ package api import ( "context" "fmt" + "log/slog" + "net/http" + "github.com/kubeflow/model-registry/ui/bff/internal/config" "github.com/kubeflow/model-registry/ui/bff/internal/integrations" "github.com/kubeflow/model-registry/ui/bff/internal/repositories" - "log/slog" - "net/http" "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/ui/bff/internal/mocks" ) const ( - Version = "1.0.0" + Version = "1.0.0" + PathPrefix = "/api/v1" ModelRegistryId = "model_registry_id" RegisteredModelId = "registered_model_id" @@ -23,6 +25,7 @@ const ( HealthCheckPath = PathPrefix + "/healthcheck" UserPath = PathPrefix + "/user" ModelRegistryListPath = PathPrefix + "/model_registry" + NamespaceListPath = PathPrefix + "/namespaces" ModelRegistryPath = ModelRegistryListPath + "/:" + ModelRegistryId RegisteredModelListPath = ModelRegistryPath + "/registered_models" RegisteredModelPath = RegisteredModelListPath + "/:" + RegisteredModelId @@ -88,25 +91,29 @@ func (app *App) Routes() http.Handler { router.NotFound = http.HandlerFunc(app.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) - // HTTP client routes + // HTTP client routes (requests that we forward to Model Registry API) + // on those, we perform SAR on Specific Service on a given namespace router.GET(HealthCheckPath, app.HealthcheckHandler) - router.GET(RegisteredModelListPath, app.AttachRESTClient(app.GetAllRegisteredModelsHandler)) - router.GET(RegisteredModelPath, app.AttachRESTClient(app.GetRegisteredModelHandler)) - router.POST(RegisteredModelListPath, app.AttachRESTClient(app.CreateRegisteredModelHandler)) - router.PATCH(RegisteredModelPath, app.AttachRESTClient(app.UpdateRegisteredModelHandler)) - router.GET(RegisteredModelVersionsPath, app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler)) - router.POST(RegisteredModelVersionsPath, app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler)) - - router.GET(ModelVersionPath, app.AttachRESTClient(app.GetModelVersionHandler)) - router.POST(ModelVersionListPath, app.AttachRESTClient(app.CreateModelVersionHandler)) - router.PATCH(ModelVersionPath, app.AttachRESTClient(app.UpdateModelVersionHandler)) - router.GET(ModelVersionArtifactListPath, app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler)) - router.POST(ModelVersionArtifactListPath, app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler)) - - // Kubernetes client routes + router.GET(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllRegisteredModelsHandler)))) + router.GET(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetRegisteredModelHandler)))) + router.POST(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateRegisteredModelHandler)))) + router.PATCH(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateRegisteredModelHandler)))) + router.GET(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler)))) + router.POST(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler)))) + router.GET(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient((app.GetModelVersionHandler))))) + router.POST(ModelVersionListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionHandler)))) + router.PATCH(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler)))) + router.GET(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler)))) + router.POST(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler)))) + router.PATCH(ModelRegistryPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler)))) + + // Kubernetes routes router.GET(UserPath, app.UserHandler) - router.GET(ModelRegistryListPath, app.ModelRegistryHandler) - router.PATCH(ModelRegistryPath, app.AttachRESTClient(app.UpdateModelVersionHandler)) + // Perform SAR to Get List Services by Namspace + router.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler))) + if app.config.StandaloneMode { + router.GET(NamespaceListPath, app.GetNamespacesHandler) + } - return app.RecoverPanic(app.enableCORS(app.RequireAccessControl(router))) + return app.RecoverPanic(app.enableCORS(app.InjectUserHeaders(router))) } diff --git a/clients/ui/bff/internal/api/healthcheck__handler_test.go b/clients/ui/bff/internal/api/healthcheck__handler_test.go index 20ac52df..0212a58c 100644 --- a/clients/ui/bff/internal/api/healthcheck__handler_test.go +++ b/clients/ui/bff/internal/api/healthcheck__handler_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "github.com/kubeflow/model-registry/ui/bff/internal/config" "github.com/kubeflow/model-registry/ui/bff/internal/mocks" @@ -25,11 +26,12 @@ func TestHealthCheckHandler(t *testing.T) { rr := httptest.NewRecorder() req, err := http.NewRequest(http.MethodGet, HealthCheckPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, mocks.KubeflowUserIDHeaderValue) + req = req.WithContext(ctx) assert.NoError(t, err) - req.Header.Set(kubeflowUserId, mocks.KubeflowUserIDHeaderValue) - app.HealthcheckHandler(rr, req, nil) + rs := rr.Result() defer rs.Body.Close() diff --git a/clients/ui/bff/internal/api/healthcheck_handler.go b/clients/ui/bff/internal/api/healthcheck_handler.go index df6d4702..6ee2049a 100644 --- a/clients/ui/bff/internal/api/healthcheck_handler.go +++ b/clients/ui/bff/internal/api/healthcheck_handler.go @@ -1,15 +1,20 @@ package api import ( + "errors" "github.com/julienschmidt/httprouter" "net/http" ) func (app *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - userID := r.Header.Get(kubeflowUserId) + userId, ok := r.Context().Value(KubeflowUserIdKey).(string) + if !ok || userId == "" { + app.serverErrorResponse(w, r, errors.New("failed to retrieve kubeflow-userid from context")) + return + } - healthCheck, err := app.repositories.HealthCheck.HealthCheck(Version, userID) + healthCheck, err := app.repositories.HealthCheck.HealthCheck(Version, userId) if err != nil { app.serverErrorResponse(w, r, err) return diff --git a/clients/ui/bff/internal/api/middleware.go b/clients/ui/bff/internal/api/middleware.go index 02275b5f..00a41b66 100644 --- a/clients/ui/bff/internal/api/middleware.go +++ b/clients/ui/bff/internal/api/middleware.go @@ -2,8 +2,10 @@ package api import ( "context" + "errors" "fmt" "net/http" + "strings" "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/ui/bff/internal/config" @@ -12,8 +14,18 @@ import ( type contextKey string -const httpClientKey contextKey = "httpClientKey" -const kubeflowUserId = "kubeflow-userid" +const ( + ModelRegistryHttpClientKey contextKey = "ModelRegistryHttpClientKey" + NamespaceHeaderParameterKey contextKey = "namespace" + + //Kubeflow authorization operates using custom authentication headers: + // Note: The functionality for `kubeflow-groups` is not fully operational at Kubeflow platform at this time + // but it's supported on Model Registry BFF + KubeflowUserIdKey contextKey = "kubeflowUserId" // kubeflow-userid :contains the user's email address + KubeflowUserIDHeader = "kubeflow-userid" + KubeflowUserGroupsKey contextKey = "kubeflowUserGroups" // kubeflow-groups : Holds a comma-separated list of user groups + KubeflowUserGroupsIdHeader = "kubeflow-groups" +) func (app *App) RecoverPanic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -28,6 +40,37 @@ func (app *App) RecoverPanic(next http.Handler) http.Handler { }) } +func (app *App) InjectUserHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + userIdHeader := r.Header.Get(KubeflowUserIDHeader) + userGroupsHeader := r.Header.Get(KubeflowUserGroupsIdHeader) + //`kubeflow-userid`: Contains the user's email address. + if userIdHeader == "" { + app.badRequestResponse(w, r, errors.New("missing required header: kubeflow-userid")) + return + } + + // Note: The functionality for `kubeflow-groups` is not fully operational at Kubeflow platform at this time + // but it's supported on Model Registry BFF + //`kubeflow-groups`: Holds a comma-separated list of user groups. + var userGroups []string + if userGroupsHeader != "" { + userGroups = strings.Split(userGroupsHeader, ",") + // Trim spaces from each group name + for i, group := range userGroups { + userGroups[i] = strings.TrimSpace(group) + } + } + + ctx := r.Context() + ctx = context.WithValue(ctx, KubeflowUserIdKey, userIdHeader) + ctx = context.WithValue(ctx, KubeflowUserGroupsKey, userGroups) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + func (app *App) enableCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // TODO(ederign) restrict CORS to a much smaller set of trusted origins. @@ -38,29 +81,35 @@ func (app *App) enableCORS(next http.Handler) http.Handler { }) } -func (app *App) AttachRESTClient(handler func(http.ResponseWriter, *http.Request, httprouter.Params)) httprouter.Handle { +func (app *App) AttachRESTClient(next func(http.ResponseWriter, *http.Request, httprouter.Params)) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { modelRegistryID := ps.ByName(ModelRegistryId) - modelRegistryBaseURL, err := resolveModelRegistryURL(modelRegistryID, app.kubernetesClient, app.config) + namespace, ok := r.Context().Value(NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing namespace in the context")) + } + + modelRegistryBaseURL, err := resolveModelRegistryURL(namespace, modelRegistryID, app.kubernetesClient, app.config) if err != nil { - app.serverErrorResponse(w, r, fmt.Errorf("failed to resolve model registry base URL): %v", err)) + app.notFoundResponse(w, r) return } - client, err := integrations.NewHTTPClient(modelRegistryBaseURL) + client, err := integrations.NewHTTPClient(modelRegistryID, modelRegistryBaseURL) if err != nil { app.serverErrorResponse(w, r, fmt.Errorf("failed to create Kubernetes client: %v", err)) return } - ctx := context.WithValue(r.Context(), httpClientKey, client) - handler(w, r.WithContext(ctx), ps) + ctx := context.WithValue(r.Context(), ModelRegistryHttpClientKey, client) + next(w, r.WithContext(ctx), ps) } } -func resolveModelRegistryURL(id string, client integrations.KubernetesClientInterface, config config.EnvConfig) (string, error) { - serviceDetails, err := client.GetServiceDetailsByName(id) +func resolveModelRegistryURL(namespace string, serviceName string, client integrations.KubernetesClientInterface, config config.EnvConfig) (string, error) { + + serviceDetails, err := client.GetServiceDetailsByName(namespace, serviceName) if err != nil { return "", err } @@ -74,28 +123,84 @@ func resolveModelRegistryURL(id string, client integrations.KubernetesClientInte return url, nil } -func (app *App) RequireAccessControl(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func (app *App) AttachNamespace(next func(http.ResponseWriter, *http.Request, httprouter.Params)) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := r.URL.Query().Get(string(NamespaceHeaderParameterKey)) + if namespace == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing required query parameter: %s", NamespaceHeaderParameterKey)) + return + } - // Skip SAR for health check - if r.URL.Path == HealthCheckPath { - next.ServeHTTP(w, r) + ctx := context.WithValue(r.Context(), NamespaceHeaderParameterKey, namespace) + r = r.WithContext(ctx) + + next(w, r, ps) + } +} + +func (app *App) PerformSARonGetListServicesByNamespace(next func(http.ResponseWriter, *http.Request, httprouter.Params)) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + user, ok := r.Context().Value(KubeflowUserIdKey).(string) + if !ok || user == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing user in context")) return } + namespace, ok := r.Context().Value(NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing namespace in context")) + return + } + + var userGroups []string + if groups, ok := r.Context().Value(KubeflowUserGroupsKey).([]string); ok { + userGroups = groups + } else { + userGroups = []string{} + } - // Skip SAR for user info - if r.URL.Path == UserPath { - next.ServeHTTP(w, r) + allowed, err := app.kubernetesClient.PerformSARonGetListServicesByNamespace(user, userGroups, namespace) + if err != nil { + app.forbiddenResponse(w, r, fmt.Sprintf("failed to perform SAR: %v", err)) return } + if !allowed { + app.forbiddenResponse(w, r, "access denied") + return + } + + next(w, r, ps) + } +} + +func (app *App) PerformSARonSpecificService(next func(http.ResponseWriter, *http.Request, httprouter.Params)) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - user := r.Header.Get(kubeflowUserId) - if user == "" { - app.forbiddenResponse(w, r, "missing kubeflow-userid header") + user, ok := r.Context().Value(KubeflowUserIdKey).(string) + if !ok || user == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing user in context")) return } - allowed, err := app.kubernetesClient.PerformSAR(user) + namespace, ok := r.Context().Value(NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing namespace in context")) + return + } + + modelRegistryID := ps.ByName(ModelRegistryId) + if !ok || modelRegistryID == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing namespace in context")) + return + } + + var userGroups []string + if groups, ok := r.Context().Value(KubeflowUserGroupsKey).([]string); ok { + userGroups = groups + } else { + userGroups = []string{} + } + + allowed, err := app.kubernetesClient.PerformSARonSpecificService(user, userGroups, namespace, modelRegistryID) if err != nil { app.forbiddenResponse(w, r, "failed to perform SAR: %v") return @@ -105,6 +210,6 @@ func (app *App) RequireAccessControl(next http.Handler) http.Handler { return } - next.ServeHTTP(w, r) - }) + next(w, r, ps) + } } diff --git a/clients/ui/bff/internal/api/model_registry_handler.go b/clients/ui/bff/internal/api/model_registry_handler.go index 8412d8f8..1600d004 100644 --- a/clients/ui/bff/internal/api/model_registry_handler.go +++ b/clients/ui/bff/internal/api/model_registry_handler.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/ui/bff/internal/models" "net/http" @@ -10,7 +11,12 @@ type ModelRegistryListEnvelope Envelope[[]models.ModelRegistryModel, None] func (app *App) ModelRegistryHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - registries, err := app.repositories.ModelRegistry.FetchAllModelRegistries(app.kubernetesClient) + namespace, ok := r.Context().Value(NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.badRequestResponse(w, r, fmt.Errorf("missing namespace in the context")) + } + + registries, err := app.repositories.ModelRegistry.GetAllModelRegistries(app.kubernetesClient, namespace) if err != nil { app.serverErrorResponse(w, r, err) return diff --git a/clients/ui/bff/internal/api/model_registry_handler_test.go b/clients/ui/bff/internal/api/model_registry_handler_test.go index 13c460fe..872121ce 100644 --- a/clients/ui/bff/internal/api/model_registry_handler_test.go +++ b/clients/ui/bff/internal/api/model_registry_handler_test.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "fmt" "github.com/kubeflow/model-registry/ui/bff/internal/models" "github.com/kubeflow/model-registry/ui/bff/internal/repositories" . "github.com/onsi/ginkgo/v2" @@ -23,7 +25,12 @@ var _ = Describe("TestModelRegistryHandler", func() { } By("creating the http test infrastructure") - req, err := http.NewRequest(http.MethodGet, ModelRegistryListPath, nil) + requestPath := fmt.Sprintf(" %s?namespace=kubeflow", ModelRegistryListPath) + req, err := http.NewRequest(http.MethodGet, requestPath, nil) + + ctx := context.WithValue(req.Context(), NamespaceHeaderParameterKey, "kubeflow") + req = req.WithContext(ctx) + Expect(err).NotTo(HaveOccurred()) rr := httptest.NewRecorder() @@ -43,8 +50,7 @@ var _ = Describe("TestModelRegistryHandler", func() { By("should match the expected model registries") var expected = []models.ModelRegistryModel{ {Name: "model-registry", Description: "Model Registry Description", DisplayName: "Model Registry"}, - {Name: "model-registry-bella", Description: "Model Registry Bella description", DisplayName: "Model Registry Bella"}, - {Name: "model-registry-dora", Description: "Model Registry Dora description", DisplayName: "Model Registry Dora"}, + {Name: "model-registry-one", Description: "Model Registry One description", DisplayName: "Model Registry One"}, } Expect(actual.Data).To(ConsistOf(expected)) }) diff --git a/clients/ui/bff/internal/api/model_versions_handler.go b/clients/ui/bff/internal/api/model_versions_handler.go index 425ca369..a945d049 100644 --- a/clients/ui/bff/internal/api/model_versions_handler.go +++ b/clients/ui/bff/internal/api/model_versions_handler.go @@ -19,7 +19,7 @@ type ModelArtifactListEnvelope Envelope[*openapi.ModelArtifactList, None] type ModelArtifactEnvelope Envelope[*openapi.ModelArtifact, None] func (app *App) GetModelVersionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -47,7 +47,7 @@ func (app *App) GetModelVersionHandler(w http.ResponseWriter, r *http.Request, p } func (app *App) CreateModelVersionHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -101,7 +101,7 @@ func (app *App) CreateModelVersionHandler(w http.ResponseWriter, r *http.Request } func (app *App) UpdateModelVersionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -151,7 +151,7 @@ func (app *App) UpdateModelVersionHandler(w http.ResponseWriter, r *http.Request } func (app *App) GetAllModelArtifactsByModelVersionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -174,7 +174,7 @@ func (app *App) GetAllModelArtifactsByModelVersionHandler(w http.ResponseWriter, } func (app *App) CreateModelArtifactByModelVersionHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return diff --git a/clients/ui/bff/internal/api/model_versions_handler_test.go b/clients/ui/bff/internal/api/model_versions_handler_test.go index 1a9ef040..cce4cf02 100644 --- a/clients/ui/bff/internal/api/model_versions_handler_test.go +++ b/clients/ui/bff/internal/api/model_versions_handler_test.go @@ -15,7 +15,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { By("fetching a model version") data := mocks.GetModelVersionMocks()[0] expected := ModelVersionEnvelope{Data: &data} - actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1", nil, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model version") Expect(rs.StatusCode).To(Equal(http.StatusOK)) @@ -27,13 +27,13 @@ var _ = Describe("TestGetModelVersionHandler", func() { data := mocks.GetModelVersionMocks()[0] expected := ModelVersionEnvelope{Data: &data} body := ModelVersionEnvelope{Data: openapi.NewModelVersion("Model One", "1")} - actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model version created") Expect(rs.StatusCode).To(Equal(http.StatusCreated)) Expect(actual.Data.Name).To(Equal(expected.Data.Name)) - Expect(rs.Header.Get("Location")).To(Equal("/api/v1/model_registry/model-registry/model_versions/1")) + Expect(rs.Header.Get("Location")).To(Equal("/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow")) }) It("should updated a model version", func() { @@ -46,7 +46,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { } body := ModelVersionUpdateEnvelope{Data: &reqData} - actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/model_versions/1", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model version updated") @@ -58,7 +58,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { By("getting a model artifacts by model version") data := mocks.GetModelArtifactListMock() expected := ModelArtifactListEnvelope{Data: &data} - actual, rs, err := setupApiTest[ModelArtifactListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1/artifacts", nil, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelArtifactListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should get all expected model version artifacts") @@ -79,7 +79,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { ArtifactType: "ARTIFACT_TYPE_ONE", } body := ModelArtifactEnvelope{Data: &artifact} - actual, rs, err := setupApiTest[ModelArtifactEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions/1/artifacts", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelArtifactEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should get all expected model artifacts") @@ -94,7 +94,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { wrongUserIDHeader := "bella@dora.com" // Incorrect username header value // Test: GET /model_versions/1 - _, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1", nil, k8sClient, wrongUserIDHeader) + _, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow", nil, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response") @@ -106,14 +106,14 @@ var _ = Describe("TestGetModelVersionHandler", func() { ArtifactType: "ARTIFACT_TYPE_ONE", } body := ModelArtifactEnvelope{Data: &artifact} - _, rs, err = setupApiTest[ModelArtifactEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions/1/artifacts", body, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelArtifactEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow", body, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) // Test: GET /model_versions/1/artifacts - _, rs, err = setupApiTest[ModelArtifactListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1/artifacts", nil, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelArtifactListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow", nil, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response") @@ -124,7 +124,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { Description: openapi.PtrString("New description"), } body1 := ModelVersionUpdateEnvelope{Data: &reqData} - _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/model_versions/1", body1, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow", body1, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response") @@ -132,7 +132,7 @@ var _ = Describe("TestGetModelVersionHandler", func() { // Test: POST /model_versions body2 := ModelVersionEnvelope{Data: openapi.NewModelVersion("Model One", "1")} - _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions", body2, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow", body2, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) diff --git a/clients/ui/bff/internal/api/namespaces_handler.go b/clients/ui/bff/internal/api/namespaces_handler.go new file mode 100644 index 00000000..80fb4701 --- /dev/null +++ b/clients/ui/bff/internal/api/namespaces_handler.go @@ -0,0 +1,43 @@ +package api + +import ( + "errors" + "github.com/kubeflow/model-registry/ui/bff/internal/models" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +type NamespacesEnvelope Envelope[[]models.NamespaceModel, None] + +func (app *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + + userId, ok := r.Context().Value(KubeflowUserIdKey).(string) + if !ok || userId == "" { + app.serverErrorResponse(w, r, errors.New("failed to retrieve kubeflow-userid from context")) + return + } + + var userGroups []string + if groups, ok := r.Context().Value(KubeflowUserGroupsKey).([]string); ok { + userGroups = groups + } else { + userGroups = []string{} + } + + namespaces, err := app.repositories.Namespace.GetNamespaces(app.kubernetesClient, userId, userGroups) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + namespacesEnvelope := NamespacesEnvelope{ + Data: namespaces, + } + + err = app.WriteJSON(w, http.StatusOK, namespacesEnvelope, nil) + + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/clients/ui/bff/internal/api/namespaces_handler_test.go b/clients/ui/bff/internal/api/namespaces_handler_test.go new file mode 100644 index 00000000..b4869058 --- /dev/null +++ b/clients/ui/bff/internal/api/namespaces_handler_test.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "encoding/json" + "github.com/kubeflow/model-registry/ui/bff/internal/config" + "github.com/kubeflow/model-registry/ui/bff/internal/mocks" + "github.com/kubeflow/model-registry/ui/bff/internal/models" + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "io" + "net/http" + "net/http/httptest" +) + +var _ = Describe("TestNamespacesHandler", func() { + Context("when running in dev mode", Ordered, func() { + var testApp App + + BeforeAll(func() { + By("setting up the test app in dev mode") + testApp = App{ + config: config.EnvConfig{DevMode: true}, + kubernetesClient: k8sClient, + repositories: repositories.NewRepositories(mockMRClient), + logger: logger, + } + }) + + It("should return only dora-namespace for doraNonAdmin@example.com", func() { + By("creating the HTTP request with the kubeflow-userid header") + req, err := http.NewRequest(http.MethodGet, NamespaceListPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, mocks.DoraNonAdminUser) + req = req.WithContext(ctx) + Expect(err).NotTo(HaveOccurred()) + rr := httptest.NewRecorder() + + By("calling the GetNamespacesHandler") + testApp.GetNamespacesHandler(rr, req, nil) + rs := rr.Result() + defer rs.Body.Close() + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response") + var actual NamespacesEnvelope + err = json.Unmarshal(body, &actual) + Expect(err).NotTo(HaveOccurred()) + Expect(rr.Code).To(Equal(http.StatusOK)) + + By("validating the response contains only dora-namespace") + expected := []models.NamespaceModel{{Name: "dora-namespace"}} + Expect(actual.Data).To(ConsistOf(expected)) + }) + + It("should return all namespaces for user@example.com", func() { + By("creating the HTTP request with the kubeflow-userid header") + req, err := http.NewRequest(http.MethodGet, NamespaceListPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, mocks.KubeflowUserIDHeaderValue) + req = req.WithContext(ctx) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("kubeflow-userid", "user@example.com") + rr := httptest.NewRecorder() + + By("calling the GetNamespacesHandler") + testApp.GetNamespacesHandler(rr, req, nil) + rs := rr.Result() + defer rs.Body.Close() + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response") + var actual NamespacesEnvelope + err = json.Unmarshal(body, &actual) + Expect(err).NotTo(HaveOccurred()) + Expect(rr.Code).To(Equal(http.StatusOK)) + + By("validating the response contains all namespaces") + expected := []models.NamespaceModel{ + {Name: "kubeflow"}, + {Name: "dora-namespace"}, + } + Expect(actual.Data).To(ContainElements(expected)) + }) + + It("should return no namespaces for non-existent user", func() { + By("creating the HTTP request with a non-existent kubeflow-userid") + req, err := http.NewRequest(http.MethodGet, NamespaceListPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, "nonexistent@example.com") + req = req.WithContext(ctx) + Expect(err).NotTo(HaveOccurred()) + rr := httptest.NewRecorder() + + By("calling the GetNamespacesHandler") + testApp.GetNamespacesHandler(rr, req, nil) + rs := rr.Result() + defer rs.Body.Close() + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response") + var actual NamespacesEnvelope + err = json.Unmarshal(body, &actual) + Expect(err).NotTo(HaveOccurred()) + Expect(rr.Code).To(Equal(http.StatusOK)) + + By("validating the response contains no namespaces") + Expect(actual.Data).To(BeEmpty()) + }) + }) + +}) diff --git a/clients/ui/bff/internal/api/registered_models_handler.go b/clients/ui/bff/internal/api/registered_models_handler.go index f1dedde5..98daef1c 100644 --- a/clients/ui/bff/internal/api/registered_models_handler.go +++ b/clients/ui/bff/internal/api/registered_models_handler.go @@ -16,7 +16,7 @@ type RegisteredModelListEnvelope Envelope[*openapi.RegisteredModelList, None] type RegisteredModelUpdateEnvelope Envelope[*openapi.RegisteredModelUpdate, None] func (app *App) GetAllRegisteredModelsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -39,7 +39,7 @@ func (app *App) GetAllRegisteredModelsHandler(w http.ResponseWriter, r *http.Req } func (app *App) CreateRegisteredModelHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -93,7 +93,7 @@ func (app *App) CreateRegisteredModelHandler(w http.ResponseWriter, r *http.Requ } func (app *App) GetRegisteredModelHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -121,7 +121,7 @@ func (app *App) GetRegisteredModelHandler(w http.ResponseWriter, r *http.Request } func (app *App) UpdateRegisteredModelHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -171,7 +171,7 @@ func (app *App) UpdateRegisteredModelHandler(w http.ResponseWriter, r *http.Requ } func (app *App) GetAllModelVersionsForRegisteredModelHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return @@ -195,7 +195,7 @@ func (app *App) GetAllModelVersionsForRegisteredModelHandler(w http.ResponseWrit } func (app *App) CreateModelVersionForRegisteredModelHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - client, ok := r.Context().Value(httpClientKey).(integrations.HTTPClientInterface) + client, ok := r.Context().Value(ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) if !ok { app.serverErrorResponse(w, r, errors.New("REST client not found")) return diff --git a/clients/ui/bff/internal/api/registered_models_handler_test.go b/clients/ui/bff/internal/api/registered_models_handler_test.go index 93aa5cfc..e114a8f5 100644 --- a/clients/ui/bff/internal/api/registered_models_handler_test.go +++ b/clients/ui/bff/internal/api/registered_models_handler_test.go @@ -15,7 +15,7 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { By("fetching all model registries") data := mocks.GetRegisteredModelMocks()[0] expected := RegisteredModelEnvelope{Data: &data} - actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1", nil, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model registry") //TODO assert the full structure, I couldn't get unmarshalling to work for the full customProperties values @@ -28,7 +28,7 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { By("fetching all registered models") data := mocks.GetRegisteredModelListMock() expected := RegisteredModelListEnvelope{Data: &data} - actual, rs, err := setupApiTest[RegisteredModelListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models", nil, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[RegisteredModelListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model registry") Expect(rs.StatusCode).To(Equal(http.StatusOK)) @@ -43,13 +43,13 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { data := mocks.GetRegisteredModelMocks()[0] expected := RegisteredModelEnvelope{Data: &data} body := RegisteredModelEnvelope{Data: openapi.NewRegisteredModel("Model One")} - actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should do a successful post") Expect(rs.StatusCode).To(Equal(http.StatusCreated)) Expect(actual.Data.Name).To(Equal(expected.Data.Name)) - Expect(rs.Header.Get("location")).To(Equal("/api/v1/model_registry/model-registry/registered_models/1")) + Expect(rs.Header.Get("location")).To(Equal("/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow")) }) It("updating registered models", func() { @@ -60,7 +60,7 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { Description: openapi.PtrString("This is a new description"), } body := RegisteredModelUpdateEnvelope{Data: &reqData} - actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/registered_models/1", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should do a successful patch") @@ -73,7 +73,7 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { data := mocks.GetModelVersionListMock() expected := ModelVersionListEnvelope{Data: &data} - actual, rs, err := setupApiTest[ModelVersionListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1/versions", nil, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelVersionListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should get all items") @@ -90,7 +90,7 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { expected := ModelVersionEnvelope{Data: &data} body := ModelVersionEnvelope{Data: openapi.NewModelVersion("Version Fifty", "")} - actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models/1/versions", body, k8sClient, mocks.KubeflowUserIDHeaderValue) + actual, rs, err := setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow", body, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should successfully create it") @@ -105,20 +105,20 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { wrongUserIDHeader := "bella@dora.com" // Incorrect username header value // Test: GET /registered_models/1 - _, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1", nil, k8sClient, wrongUserIDHeader) + _, rs, err := setupApiTest[RegisteredModelEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow", nil, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for GET registered model by ID") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) // Test: GET /registered_models - _, rs, err = setupApiTest[RegisteredModelListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models", nil, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[RegisteredModelListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow", nil, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for GET all registered models") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) // Test: POST /registered_models body := RegisteredModelEnvelope{Data: openapi.NewRegisteredModel("Model One")} - _, rs, err = setupApiTest[RegisteredModelEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models", body, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[RegisteredModelEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow", body, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for POST create registered model") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) @@ -128,20 +128,20 @@ var _ = Describe("TestGetRegisteredModelHandler", func() { Description: openapi.PtrString("This is a new description"), } body2 := RegisteredModelUpdateEnvelope{Data: &reqData} - _, rs, err = setupApiTest[RegisteredModelEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/registered_models/1", body2, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[RegisteredModelEnvelope](http.MethodPatch, "/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow", body2, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for PATCH update registered model") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) // Test: GET /registered_models/1/versions - _, rs, err = setupApiTest[ModelVersionListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1/versions", nil, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelVersionListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow", nil, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for GET model versions of registered model") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) // Test: POST /registered_models/1/versions body3 := ModelVersionEnvelope{Data: openapi.NewModelVersion("Version Fifty", "")} - _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models/1/versions", body3, k8sClient, wrongUserIDHeader) + _, rs, err = setupApiTest[ModelVersionEnvelope](http.MethodPost, "/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow", body3, k8sClient, wrongUserIDHeader, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should return a 403 Forbidden response for POST create model version for registered model") Expect(rs.StatusCode).To(Equal(http.StatusForbidden)) diff --git a/clients/ui/bff/internal/api/test_utils.go b/clients/ui/bff/internal/api/test_utils.go index 3a2ec65a..945e7d0e 100644 --- a/clients/ui/bff/internal/api/test_utils.go +++ b/clients/ui/bff/internal/api/test_utils.go @@ -12,7 +12,7 @@ import ( "net/http/httptest" ) -func setupApiTest[T any](method string, url string, body interface{}, k8sClient k8s.KubernetesClientInterface, kubeflowUserIDHeader string) (T, *http.Response, error) { +func setupApiTest[T any](method string, url string, body interface{}, k8sClient k8s.KubernetesClientInterface, kubeflowUserIDHeaderValue string, namespace string) (T, *http.Response, error) { mockMRClient, err := mocks.NewModelRegistryClient(nil) if err != nil { return *new(T), nil, err @@ -44,9 +44,16 @@ func setupApiTest[T any](method string, url string, body interface{}, k8sClient } // Set the kubeflow-userid header - req.Header.Set(kubeflowUserId, kubeflowUserIDHeader) + req.Header.Set(KubeflowUserIDHeader, kubeflowUserIDHeaderValue) - ctx := context.WithValue(req.Context(), httpClientKey, mockClient) + ctx := req.Context() + ctx = context.WithValue(ctx, ModelRegistryHttpClientKey, mockClient) + ctx = context.WithValue(ctx, KubeflowUserIdKey, kubeflowUserIDHeaderValue) + ctx = context.WithValue(ctx, NamespaceHeaderParameterKey, namespace) + mrHttpClient := k8s.HTTPClient{ + ModelRegistryID: "model-registry", + } + ctx = context.WithValue(ctx, ModelRegistryHttpClientKey, mrHttpClient) req = req.WithContext(ctx) rr := httptest.NewRecorder() diff --git a/clients/ui/bff/internal/api/user_handler.go b/clients/ui/bff/internal/api/user_handler.go index a5bcd264..9ec135cc 100644 --- a/clients/ui/bff/internal/api/user_handler.go +++ b/clients/ui/bff/internal/api/user_handler.go @@ -11,13 +11,13 @@ type UserEnvelope Envelope[*models.User, None] func (app *App) UserHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - userHeader := r.Header.Get(kubeflowUserId) - if userHeader == "" { - app.serverErrorResponse(w, r, errors.New("kubeflow-userid not present on header")) + userId, ok := r.Context().Value(KubeflowUserIdKey).(string) + if !ok || userId == "" { + app.serverErrorResponse(w, r, errors.New("failed to retrieve kubeflow-userid from context")) return } - user, err := app.repositories.User.GetUser(app.kubernetesClient, userHeader) + user, err := app.repositories.User.GetUser(app.kubernetesClient, userId) if err != nil { app.serverErrorResponse(w, r, err) return diff --git a/clients/ui/bff/internal/api/user_handler_test.go b/clients/ui/bff/internal/api/user_handler_test.go index 0accd253..13cbf95a 100644 --- a/clients/ui/bff/internal/api/user_handler_test.go +++ b/clients/ui/bff/internal/api/user_handler_test.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "github.com/kubeflow/model-registry/ui/bff/internal/mocks" "io" "net/http" "net/http/httptest" @@ -32,10 +34,10 @@ var _ = Describe("TestUserHandler", func() { It("should show that KubeflowUserIDHeaderValue (user@example.com) is a cluster-admin", func() { By("creating the http request") req, err := http.NewRequest(http.MethodGet, UserPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, mocks.KubeflowUserIDHeaderValue) + req = req.WithContext(ctx) Expect(err).NotTo(HaveOccurred()) - req.Header.Set(kubeflowUserId, KubeflowUserIDHeaderValue) - By("creating the http test infrastructure") rr := httptest.NewRecorder() @@ -60,10 +62,10 @@ var _ = Describe("TestUserHandler", func() { It("should show that DoraNonAdminUser (doraNonAdmin@example.com) is not a cluster-admin", func() { By("creating the http request") req, err := http.NewRequest(http.MethodGet, UserPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, DoraNonAdminUser) + req = req.WithContext(ctx) Expect(err).NotTo(HaveOccurred()) - req.Header.Set(kubeflowUserId, DoraNonAdminUser) - By("creating the http test infrastructure") rr := httptest.NewRecorder() @@ -90,10 +92,10 @@ var _ = Describe("TestUserHandler", func() { By("creating the http request") req, err := http.NewRequest(http.MethodGet, UserPath, nil) + ctx := context.WithValue(req.Context(), KubeflowUserIdKey, randomUser) + req = req.WithContext(ctx) Expect(err).NotTo(HaveOccurred()) - req.Header.Set(kubeflowUserId, randomUser) - By("creating the http test infrastructure") rr := httptest.NewRecorder() diff --git a/clients/ui/bff/internal/config/environment.go b/clients/ui/bff/internal/config/environment.go index f63dcce8..7b905e12 100644 --- a/clients/ui/bff/internal/config/environment.go +++ b/clients/ui/bff/internal/config/environment.go @@ -1,9 +1,10 @@ package config type EnvConfig struct { - Port int - MockK8Client bool - MockMRClient bool - DevMode bool - DevModePort int + Port int + MockK8Client bool + MockMRClient bool + DevMode bool + StandaloneMode bool + DevModePort int } diff --git a/clients/ui/bff/internal/integrations/http.go b/clients/ui/bff/internal/integrations/http.go index c20a859b..712b8556 100644 --- a/clients/ui/bff/internal/integrations/http.go +++ b/clients/ui/bff/internal/integrations/http.go @@ -10,14 +10,16 @@ import ( ) type HTTPClientInterface interface { + GetModelRegistryID() (modelRegistryService string) GET(url string) ([]byte, error) POST(url string, body io.Reader) ([]byte, error) PATCH(url string, body io.Reader) ([]byte, error) } type HTTPClient struct { - client *http.Client - baseURL string + client *http.Client + baseURL string + ModelRegistryID string } type ErrorResponse struct { @@ -34,16 +36,21 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %s - %s", e.StatusCode, e.Code, e.Message) } -func NewHTTPClient(baseURL string) (HTTPClientInterface, error) { +func NewHTTPClient(modelRegistryID string, baseURL string) (HTTPClientInterface, error) { return &HTTPClient{ client: &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }}, - baseURL: baseURL, + baseURL: baseURL, + ModelRegistryID: modelRegistryID, }, nil } +func (c *HTTPClient) GetModelRegistryID() string { + return c.ModelRegistryID +} + func (c *HTTPClient) GET(url string) ([]byte, error) { fullURL := c.baseURL + url req, err := http.NewRequest("GET", fullURL, nil) diff --git a/clients/ui/bff/internal/integrations/k8s.go b/clients/ui/bff/internal/integrations/k8s.go index 8b6c7170..9b89bb02 100644 --- a/clients/ui/bff/internal/integrations/k8s.go +++ b/clients/ui/bff/internal/integrations/k8s.go @@ -22,14 +22,16 @@ import ( const ComponentLabelValue = "model-registry" type KubernetesClientInterface interface { - GetServiceNames() ([]string, error) - GetServiceDetailsByName(serviceName string) (ServiceDetails, error) - GetServiceDetails() ([]ServiceDetails, error) + GetServiceNames(namespace string) ([]string, error) + GetServiceDetailsByName(namespace string, serviceName string) (ServiceDetails, error) + GetServiceDetails(namespace string) ([]ServiceDetails, error) BearerToken() (string, error) Shutdown(ctx context.Context, logger *slog.Logger) error IsInCluster() bool - PerformSAR(user string) (bool, error) + PerformSARonGetListServicesByNamespace(user string, groups []string, namespace string) (bool, error) + PerformSARonSpecificService(user string, groups []string, namespace string, serviceName string) (bool, error) IsClusterAdmin(user string) (bool, error) + GetNamespaces(user string, groups []string) ([]corev1.Namespace, error) } type ServiceDetails struct { @@ -150,8 +152,8 @@ func (kc *KubernetesClient) BearerToken() (string, error) { return kc.Token, nil } -func (kc *KubernetesClient) GetServiceNames() ([]string, error) { - services, err := kc.GetServiceDetails() +func (kc *KubernetesClient) GetServiceNames(namespace string) ([]string, error) { + services, err := kc.GetServiceDetails(namespace) if err != nil { return nil, err } @@ -164,7 +166,12 @@ func (kc *KubernetesClient) GetServiceNames() ([]string, error) { return names, nil } -func (kc *KubernetesClient) GetServiceDetails() ([]ServiceDetails, error) { +func (kc *KubernetesClient) GetServiceDetails(namespace string) ([]ServiceDetails, error) { + + if namespace == "" { + return nil, fmt.Errorf("namespace cannot be empty") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -176,6 +183,7 @@ func (kc *KubernetesClient) GetServiceDetails() ([]ServiceDetails, error) { err := kc.ControllerRuntimeClient.List(ctx, serviceList, &client.ListOptions{ LabelSelector: labelSelector, + Namespace: namespace, }) if err != nil { return nil, fmt.Errorf("failed to list services: %w", err) @@ -234,8 +242,8 @@ func (kc *KubernetesClient) GetServiceDetails() ([]ServiceDetails, error) { return services, nil } -func (kc *KubernetesClient) GetServiceDetailsByName(serviceName string) (ServiceDetails, error) { - services, err := kc.GetServiceDetails() +func (kc *KubernetesClient) GetServiceDetailsByName(namespace string, serviceName string) (ServiceDetails, error) { + services, err := kc.GetServiceDetails(namespace) if err != nil { return ServiceDetails{}, fmt.Errorf("failed to get service details: %w", err) } @@ -249,19 +257,22 @@ func (kc *KubernetesClient) GetServiceDetailsByName(serviceName string) (Service return ServiceDetails{}, fmt.Errorf("service %s not found", serviceName) } -func (kc *KubernetesClient) PerformSAR(user string) (bool, error) { +func (kc *KubernetesClient) PerformSARonGetListServicesByNamespace(user string, groups []string, namespace string) (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + verbs := []string{"get", "list"} resource := "services" for _, verb := range verbs { sar := &authv1.SubjectAccessReview{ Spec: authv1.SubjectAccessReviewSpec{ - User: user, + User: user, + Groups: groups, ResourceAttributes: &authv1.ResourceAttributes{ - Verb: verb, - Resource: resource, + Verb: verb, + Resource: resource, + Namespace: namespace, }, }, } @@ -306,3 +317,84 @@ func (kc *KubernetesClient) IsClusterAdmin(user string) (bool, error) { return false, nil } + +func (kc *KubernetesClient) GetNamespaces(user string, groups []string) ([]corev1.Namespace, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + //list all namespaces + namespaceList := &corev1.NamespaceList{} + err := kc.ControllerRuntimeClient.List(ctx, namespaceList) + if err != nil { + return nil, fmt.Errorf("failed to list namespaces: %w", err) + } + + //check user access with SAR for each namespace + var namespaces []corev1.Namespace + for _, ns := range namespaceList.Items { + sar := &authv1.SubjectAccessReview{ + Spec: authv1.SubjectAccessReviewSpec{ + User: user, + Groups: groups, + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: ns.Name, + Verb: "get", + Resource: "namespaces", + }, + }, + } + + response, err := kc.KubernetesNativeClient.AuthorizationV1().SubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{}) + if err != nil { + kc.Logger.Error("failed to perform SubjectAccessReview", "namespace", ns.Name, "error", err) + continue + } + + if response.Status.Allowed { + namespaces = append(namespaces, ns) + } + } + + return namespaces, nil + +} + +func (kc *KubernetesClient) PerformSARonSpecificService(user string, groups []string, namespace string, serviceName string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resource := "services" + verb := "get" + + sar := &authv1.SubjectAccessReview{ + Spec: authv1.SubjectAccessReviewSpec{ + User: user, + Groups: groups, + ResourceAttributes: &authv1.ResourceAttributes{ + Verb: verb, + Resource: resource, + Namespace: namespace, + Name: serviceName, + }, + }, + } + + // Perform the SAR using the native KubernetesNativeClient client + response, err := kc.KubernetesNativeClient.AuthorizationV1().SubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{}) + if err != nil { + return false, fmt.Errorf( + "failed to create SubjectAccessReview for verb %q on resource %q (service: %q) in namespace %q: %w", + verb, resource, serviceName, namespace, err, + ) + } + + if !response.Status.Allowed { + kc.Logger.Warn( + "access denied", "user", user, "verb", verb, "resource", resource, + "namespace", namespace, "service", serviceName, + ) + return false, nil + } + + return true, nil +} diff --git a/clients/ui/bff/internal/mocks/http_mock.go b/clients/ui/bff/internal/mocks/http_mock.go index c530592e..fd93aa21 100644 --- a/clients/ui/bff/internal/mocks/http_mock.go +++ b/clients/ui/bff/internal/mocks/http_mock.go @@ -9,6 +9,10 @@ type MockHTTPClient struct { mock.Mock } +func (c *MockHTTPClient) GetModelRegistryID() string { + return "model-registry" +} + func (m *MockHTTPClient) GET(url string) ([]byte, error) { args := m.Called(url) return args.Get(0).([]byte), args.Error(1) diff --git a/clients/ui/bff/internal/mocks/k8s_mock.go b/clients/ui/bff/internal/mocks/k8s_mock.go index 9fcc8a56..ce2b3e61 100644 --- a/clients/ui/bff/internal/mocks/k8s_mock.go +++ b/clients/ui/bff/internal/mocks/k8s_mock.go @@ -20,6 +20,9 @@ import ( const ( KubeflowUserIDHeaderValue = "user@example.com" DoraNonAdminUser = "doraNonAdmin@example.com" + BellaNonAdminUser = "bellaNonAdmin@example.com" + DoraServiceGroup = "dora-service-group" + DoraNamespaceGroup = "dora-namespace-group" ) type KubernetesClientMock struct { @@ -128,19 +131,28 @@ func setupMock(mockK8sClient client.Client, ctx context.Context) error { return err } + err = createNamespace(mockK8sClient, ctx, "bella-namespace") + if err != nil { + return err + } + err = createService(mockK8sClient, ctx, "model-registry", "kubeflow", "Model Registry", "Model Registry Description", "10.0.0.10", "model-registry") if err != nil { return err } - err = createService(mockK8sClient, ctx, "model-registry-dora", "dora-namespace", "Model Registry Dora", "Model Registry Dora description", "10.0.0.11", "model-registry") + err = createService(mockK8sClient, ctx, "model-registry-one", "kubeflow", "Model Registry One", "Model Registry One description", "10.0.0.11", "model-registry") if err != nil { return err } - err = createService(mockK8sClient, ctx, "model-registry-bella", "kubeflow", "Model Registry Bella", "Model Registry Bella description", "10.0.0.12", "model-registry") + err = createService(mockK8sClient, ctx, "model-registry-dora", "dora-namespace", "Model Registry Dora", "Model Registry Dora description", "10.0.0.12", "model-registry") if err != nil { return err } - err = createService(mockK8sClient, ctx, "non-model-registry", "kubeflow", "Not a Model Registry", "Not a Model Registry Bella description", "10.0.0.13", "") + err = createService(mockK8sClient, ctx, "model-registry-bella", "bella-namespace", "Model Registry Bella", "Model Registry Bella description", "10.0.0.13", "model-registry") + if err != nil { + return err + } + err = createService(mockK8sClient, ctx, "non-model-registry", "kubeflow", "Not a Model Registry", "Not a Model Registry Bella description", "10.0.0.14", "") if err != nil { return err } @@ -155,11 +167,26 @@ func setupMock(mockK8sClient client.Client, ctx context.Context) error { return fmt.Errorf("failed to create namespace-restricted RBAC: %w", err) } + err = createNamespaceRestrictedRBAC(mockK8sClient, ctx, BellaNonAdminUser, "bella-namespace") + if err != nil { + return fmt.Errorf("failed to create namespace-restricted RBAC: %w", err) + } + + err = createGroupAccessRBAC(mockK8sClient, ctx, DoraServiceGroup, "dora-namespace", "model-registry-dora") + if err != nil { + return fmt.Errorf("failed to create group-based RBAC: %w", err) + } + + err = createGroupNamespaceAccessRBAC(mockK8sClient, ctx, DoraNamespaceGroup, "dora-namespace") + if err != nil { + return fmt.Errorf("failed to set up group access to namespace: %w", err) + } + return nil } -func (m *KubernetesClientMock) GetServiceDetails() ([]k8s.ServiceDetails, error) { - originalServices, err := m.KubernetesClient.GetServiceDetails() +func (m *KubernetesClientMock) GetServiceDetails(namespace string) ([]k8s.ServiceDetails, error) { + originalServices, err := m.KubernetesClient.GetServiceDetails(namespace) if err != nil { return nil, fmt.Errorf("failed to get service details: %w", err) } @@ -172,8 +199,8 @@ func (m *KubernetesClientMock) GetServiceDetails() ([]k8s.ServiceDetails, error) return originalServices, nil } -func (m *KubernetesClientMock) GetServiceDetailsByName(serviceName string) (k8s.ServiceDetails, error) { - originalService, err := m.KubernetesClient.GetServiceDetailsByName(serviceName) +func (m *KubernetesClientMock) GetServiceDetailsByName(namespace string, serviceName string) (k8s.ServiceDetails, error) { + originalService, err := m.KubernetesClient.GetServiceDetailsByName(namespace, serviceName) if err != nil { return k8s.ServiceDetails{}, fmt.Errorf("failed to get service details: %w", err) } @@ -303,7 +330,7 @@ func createNamespaceRestrictedRBAC(k8sClient client.Client, ctx context.Context, Rules: []rbacv1.PolicyRule{ { APIGroups: []string{""}, - Resources: []string{"services"}, + Resources: []string{"services", "namespaces"}, Verbs: []string{"get", "list"}, }, }, @@ -340,6 +367,98 @@ func createNamespaceRestrictedRBAC(k8sClient client.Client, ctx context.Context, return nil } +func createGroupAccessRBAC(k8sClient client.Client, ctx context.Context, groupName, namespace, serviceName string) error { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-model-registry-access", + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"services"}, + Verbs: []string{"get", "list"}, + ResourceNames: []string{ + serviceName, + }, + }, + }, + } + + if err := k8sClient.Create(ctx, role); err != nil { + return fmt.Errorf("failed to create Role for group: %w", err) + } + + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-access-binding", + Namespace: namespace, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "Group", + Name: groupName, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: "group-model-registry-access", + APIGroup: "rbac.authorization.k8s.io", + }, + } + + if err := k8sClient.Create(ctx, roleBinding); err != nil { + return fmt.Errorf("failed to create RoleBinding for group: %w", err) + } + + return nil +} + +func createGroupNamespaceAccessRBAC(k8sClient client.Client, ctx context.Context, groupName, namespace string) error { + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-namespace-access-role", + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces", "services"}, + Verbs: []string{"get", "list"}, + }, + }, + } + + if err := k8sClient.Create(ctx, role); err != nil { + return fmt.Errorf("failed to create Role for group namespace access: %w", err) + } + + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-namespace-access-binding", + Namespace: namespace, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "Group", + Name: groupName, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: "group-namespace-access-role", + APIGroup: "rbac.authorization.k8s.io", + }, + } + + if err := k8sClient.Create(ctx, roleBinding); err != nil { + return fmt.Errorf("failed to create RoleBinding for group namespace access: %w", err) + } + + return nil +} + func strPtr(s string) *string { return &s } diff --git a/clients/ui/bff/internal/mocks/k8s_mock_test.go b/clients/ui/bff/internal/mocks/k8s_mock_test.go index d77c658b..e236326a 100644 --- a/clients/ui/bff/internal/mocks/k8s_mock_test.go +++ b/clients/ui/bff/internal/mocks/k8s_mock_test.go @@ -11,7 +11,7 @@ var _ = Describe("Kubernetes ControllerRuntimeClient Test", func() { It("should retrieve the get all service successfully", func() { By("getting service details") - services, err := k8sClient.GetServiceDetails() + services, err := k8sClient.GetServiceDetails("kubeflow") Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") By("checking that all services have the modified ClusterIP and HTTPPort") @@ -37,7 +37,7 @@ var _ = Describe("Kubernetes ControllerRuntimeClient Test", func() { It("should retrieve the service details by name", func() { By("getting service by name") - service, err := k8sClient.GetServiceDetailsByName("model-registry-dora") + service, err := k8sClient.GetServiceDetailsByName("dora-namespace", "model-registry-dora") Expect(err).NotTo(HaveOccurred(), "Failed to create k8s request") By("checking that service details are correct") @@ -49,11 +49,11 @@ var _ = Describe("Kubernetes ControllerRuntimeClient Test", func() { It("should retrieve the services names", func() { By("getting service by name") - services, err := k8sClient.GetServiceNames() + services, err := k8sClient.GetServiceNames("kubeflow") Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") By("checking that service details are correct") - Expect(services).To(ConsistOf("model-registry", "model-registry-bella", "model-registry-dora")) + Expect(services).To(ConsistOf("model-registry", "model-registry-one")) }) }) @@ -65,14 +65,25 @@ var _ = Describe("KubernetesNativeClient SAR Test", func() { It("should allow allowed user to access services", func() { By("performing SAR for Kubeflow User ID") - allowed, err := k8sClient.PerformSAR(KubeflowUserIDHeaderValue) + allowed, err := k8sClient.PerformSARonGetListServicesByNamespace(KubeflowUserIDHeaderValue, []string{}, "kubeflow") Expect(err).NotTo(HaveOccurred(), "Failed to perform SAR for Kubeflow User ID\"") Expect(allowed).To(BeTrue(), "Expected Kubeflow User ID to have access") }) + It("check dora access to namespaces", func() { + By("performing SAR for dora user") + allowed, err := k8sClient.PerformSARonGetListServicesByNamespace(DoraNonAdminUser, []string{}, "kubeflow") + Expect(err).NotTo(HaveOccurred(), "Failed to perform SAR for unauthorized-dora@example.com") + Expect(allowed).To(BeFalse(), "Expected doraNonAdmin@example.com to be denied access") + + allowed, err = k8sClient.PerformSARonGetListServicesByNamespace(DoraNonAdminUser, []string{}, "dora-namespace") + Expect(err).NotTo(HaveOccurred(), "Failed to perform SAR for unauthorized-dora@example.com") + Expect(allowed).To(BeTrue(), "Expected doraNonAdmin@example.com ID to have access") + }) + It("should deny access for another user", func() { By("performing SAR for another user") - allowed, err := k8sClient.PerformSAR("unauthorized-dora@example.com") + allowed, err := k8sClient.PerformSARonGetListServicesByNamespace("unauthorized-dora@example.com", []string{}, "kubeflow") Expect(err).NotTo(HaveOccurred(), "Failed to perform SAR for unauthorized-dora@example.com") Expect(allowed).To(BeFalse(), "Expected unauthorized-dora@example.com to be denied access") }) @@ -80,6 +91,63 @@ var _ = Describe("KubernetesNativeClient SAR Test", func() { }) }) +var _ = Describe("KubernetesClient PerformSARonSpecificService Group Tests", func() { + Context("checking access using group memberships", func() { + const ( + namespace = "dora-namespace" + serviceName = "model-registry-dora" + existingUser = "bentoOnlyGroupAccess@example.com" + ) + + It("should deny access for a group that does not exist", func() { + groups := []string{"non-existent-group"} + + allowed, err := k8sClient.PerformSARonSpecificService(existingUser, groups, namespace, serviceName) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse(), "Access should be denied for a non-existent group") + }) + + It("should allow service access for the DoraServiceGroup", func() { + groups := []string{DoraServiceGroup} + + allowed, err := k8sClient.PerformSARonSpecificService(existingUser, groups, namespace, serviceName) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue(), "Access should be allowed for the DoraServiceGroup group") + }) + + It("should allow access when one group exists and the other does not", func() { + groups := []string{DoraServiceGroup, "non-existent-group"} + + allowed, err := k8sClient.PerformSARonSpecificService(existingUser, groups, namespace, serviceName) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue(), "Access should be allowed if any group in the list has access") + }) + + It("should allow access only when I've service access and namespace access", func() { + groups := []string{DoraServiceGroup} + + allowed, err := k8sClient.PerformSARonSpecificService(existingUser, groups, namespace, serviceName) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue(), "Access should be allowed for the DoraServiceGroup group") + + allowed, err = k8sClient.PerformSARonGetListServicesByNamespace(existingUser, groups, namespace) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse(), "Access should not be allowed for only DoraServiceGroup group") + + allGroups := []string{DoraServiceGroup, DoraNamespaceGroup} + + allowed, err = k8sClient.PerformSARonGetListServicesByNamespace(existingUser, allGroups, namespace) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue(), "Access should be allowed for both groups") + + allowed, err = k8sClient.PerformSARonSpecificService(existingUser, allGroups, namespace, serviceName) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue(), "Access should be allowed for for both groups") + + }) + }) +}) + var _ = Describe("KubernetesClient isClusterAdmin Test", func() { Context("checking cluster admin status", func() { It("should confirm that user@example.com(KubeflowUserIDHeaderValue) is a cluster-admin", func() { diff --git a/clients/ui/bff/internal/models/namespace.go b/clients/ui/bff/internal/models/namespace.go new file mode 100644 index 00000000..2f37b184 --- /dev/null +++ b/clients/ui/bff/internal/models/namespace.go @@ -0,0 +1,11 @@ +package models + +type NamespaceModel struct { + Name string `json:"name"` +} + +func NewNamespaceModelFromNamespace(name string) NamespaceModel { + return NamespaceModel{ + Name: name, + } +} diff --git a/clients/ui/bff/internal/repositories/model_registry.go b/clients/ui/bff/internal/repositories/model_registry.go index a60b2279..db417595 100644 --- a/clients/ui/bff/internal/repositories/model_registry.go +++ b/clients/ui/bff/internal/repositories/model_registry.go @@ -13,9 +13,9 @@ func NewModelRegistryRepository() *ModelRegistryRepository { return &ModelRegistryRepository{} } -func (m *ModelRegistryRepository) FetchAllModelRegistries(client k8s.KubernetesClientInterface) ([]models.ModelRegistryModel, error) { +func (m *ModelRegistryRepository) GetAllModelRegistries(client k8s.KubernetesClientInterface, namespace string) ([]models.ModelRegistryModel, error) { - resources, err := client.GetServiceDetails() + resources, err := client.GetServiceDetails(namespace) if err != nil { return nil, fmt.Errorf("error fetching model registries: %w", err) } diff --git a/clients/ui/bff/internal/repositories/model_registry_test.go b/clients/ui/bff/internal/repositories/model_registry_test.go index e430011c..a5a0d903 100644 --- a/clients/ui/bff/internal/repositories/model_registry_test.go +++ b/clients/ui/bff/internal/repositories/model_registry_test.go @@ -9,20 +9,44 @@ import ( var _ = Describe("TestFetchAllModelRegistry", func() { Context("with existing model registries", Ordered, func() { - It("should retrieve the get all service successfully", func() { + It("should retrieve the get all kubeflow service successfully", func() { By("fetching all model registries in the repository") modelRegistryRepository := NewModelRegistryRepository() - registries, err := modelRegistryRepository.FetchAllModelRegistries(k8sClient) + registries, err := modelRegistryRepository.GetAllModelRegistries(k8sClient, "kubeflow") Expect(err).NotTo(HaveOccurred()) By("should match the expected model registries") expectedRegistries := []models.ModelRegistryModel{ {Name: "model-registry", Description: "Model Registry Description", DisplayName: "Model Registry"}, - {Name: "model-registry-bella", Description: "Model Registry Bella description", DisplayName: "Model Registry Bella"}, + {Name: "model-registry-one", Description: "Model Registry One description", DisplayName: "Model Registry One"}, + } + Expect(registries).To(ConsistOf(expectedRegistries)) + }) + + It("should retrieve the get all dora-namespace service successfully", func() { + + By("fetching all model registries in the repository") + modelRegistryRepository := NewModelRegistryRepository() + registries, err := modelRegistryRepository.GetAllModelRegistries(k8sClient, "dora-namespace") + Expect(err).NotTo(HaveOccurred()) + + By("should match the expected model registries") + expectedRegistries := []models.ModelRegistryModel{ {Name: "model-registry-dora", Description: "Model Registry Dora description", DisplayName: "Model Registry Dora"}, } Expect(registries).To(ConsistOf(expectedRegistries)) }) + + It("should not retrieve namespaces", func() { + + By("fetching all model registries in the repository") + modelRegistryRepository := NewModelRegistryRepository() + registries, err := modelRegistryRepository.GetAllModelRegistries(k8sClient, "no-namespace") + Expect(err).NotTo(HaveOccurred()) + + By("should be empty") + Expect(registries).To(BeEmpty()) + }) }) }) diff --git a/clients/ui/bff/internal/repositories/namespace.go b/clients/ui/bff/internal/repositories/namespace.go new file mode 100644 index 00000000..6cfda951 --- /dev/null +++ b/clients/ui/bff/internal/repositories/namespace.go @@ -0,0 +1,28 @@ +package repositories + +import ( + "fmt" + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" + "github.com/kubeflow/model-registry/ui/bff/internal/models" +) + +type NamespaceRepository struct{} + +func NewNamespaceRepository() *NamespaceRepository { + return &NamespaceRepository{} +} + +func (r *NamespaceRepository) GetNamespaces(client k8s.KubernetesClientInterface, user string, groups []string) ([]models.NamespaceModel, error) { + + namespaces, err := client.GetNamespaces(user, groups) + if err != nil { + return nil, fmt.Errorf("error fetching namespaces: %w", err) + } + + var namespaceModels = []models.NamespaceModel{} + for _, ns := range namespaces { + namespaceModels = append(namespaceModels, models.NewNamespaceModelFromNamespace(ns.Name)) + } + + return namespaceModels, nil +} diff --git a/clients/ui/bff/internal/repositories/repositories.go b/clients/ui/bff/internal/repositories/repositories.go index 5efa9b84..434c2d6b 100644 --- a/clients/ui/bff/internal/repositories/repositories.go +++ b/clients/ui/bff/internal/repositories/repositories.go @@ -6,6 +6,7 @@ type Repositories struct { ModelRegistry *ModelRegistryRepository ModelRegistryClient ModelRegistryClientInterface User *UserRepository + Namespace *NamespaceRepository } func NewRepositories(modelRegistryClient ModelRegistryClientInterface) *Repositories { @@ -14,5 +15,6 @@ func NewRepositories(modelRegistryClient ModelRegistryClientInterface) *Reposito ModelRegistry: NewModelRegistryRepository(), ModelRegistryClient: modelRegistryClient, User: NewUserRepository(), + Namespace: NewNamespaceRepository(), } } diff --git a/clients/ui/frontend/.env.development b/clients/ui/frontend/.env.development new file mode 100644 index 00000000..7d7cc8e8 --- /dev/null +++ b/clients/ui/frontend/.env.development @@ -0,0 +1,3 @@ +APP_ENV=development +MOCK_AUTH=true +DEPLOYMENT_MODE=standalone \ No newline at end of file diff --git a/clients/ui/frontend/Dockerfile b/clients/ui/frontend/Dockerfile index c25a2b1c..448f724b 100644 --- a/clients/ui/frontend/Dockerfile +++ b/clients/ui/frontend/Dockerfile @@ -6,7 +6,7 @@ COPY . /usr/src/app RUN npm cache clean --force RUN npm ci --omit=optional -RUN npm run build +RUN npm run build:prod FROM nginxinc/nginx-unprivileged diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index f85da266..5a6a3ddf 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -9,13 +9,13 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@emotion/react": "^11.13.5", + "@emotion/react": "^11.14.0", "@emotion/styled": "^11.13.5", "@patternfly/patternfly": "^6.0.0", - "@patternfly/react-core": "6.0.0", + "@patternfly/react-core": "6.1.0", "@patternfly/react-icons": "6.0.0", "@patternfly/react-styles": "6.0.0", - "@patternfly/react-table": "6.0.0", + "@patternfly/react-table": "6.1.0", "@patternfly/react-templates": "6.0.0", "classnames": "^2.2.6", "dompurify": "^3.2.3", @@ -24,7 +24,7 @@ "react": "^18", "react-dom": "^18", "react-router": "^7.0.2", - "sass": "^1.78.0", + "sass": "^1.83.0", "showdown": "^2.1.0" }, "devDependencies": { @@ -34,13 +34,13 @@ "@cypress/code-coverage": "^3.13.8", "@mui/icons-material": "^6.1.10", "@mui/material": "^6.1.7", - "@mui/types": "^7.2.17", + "@mui/types": "^7.2.20", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@swc/core": "^1.9.1", "@testing-library/cypress": "^10.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.2", - "@testing-library/react": "^16.0.0", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "14.5.2", "@types/classnames": "^2.3.1", "@types/dompurify": "^3.2.0", @@ -78,11 +78,11 @@ "react-refresh": "^0.14.2", "react-router-dom": "^7.0.2", "regenerator-runtime": "^0.14.1", - "sass": "^1.56.2", + "sass": "^1.83.0", "sass-loader": "^13.2.0", "serve": "^14.2.4", "speed-measure-webpack-plugin": "^1.5.0", - "style-loader": "^2.0.0", + "style-loader": "^4.0.0", "svg-url-loader": "^6.0.0", "swc-loader": "^0.2.6", "terser-webpack-plugin": "^5.3.10", @@ -2155,9 +2155,9 @@ "license": "MIT" }, "node_modules/@emotion/cache": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.5.tgz", - "integrity": "sha512-Z3xbtJ+UcK76eWkagZ1onvn/wAVb1GOMuR15s30Fm2wrMgC7jzpnO2JZXr4eujTTqoQFUrZIw/rT0c6Zzjca1g==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -2188,15 +2188,15 @@ "license": "MIT" }, "node_modules/@emotion/react": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.5.tgz", - "integrity": "sha512-6zeCUxUH+EPF1s+YF/2hPVODeV/7V07YU5x+2tfuRL8MdW6rv5vb2+CBEGTGwBdux0OIERcOS+RzxeK80k2DsQ==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.13.5", + "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" @@ -2258,10 +2258,9 @@ "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", - "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", - "license": "MIT", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "peerDependencies": { "react": ">=16.8.0" } @@ -3218,11 +3217,10 @@ } }, "node_modules/@mui/types": { - "version": "7.2.19", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz", - "integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==", + "version": "7.2.20", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", + "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", "dev": true, - "license": "MIT", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -3679,23 +3677,36 @@ "license": "MIT" }, "node_modules/@patternfly/react-core": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0.tgz", - "integrity": "sha512-UKFj9+YzBY+FfEDsLONgOM4N0e8SPV/27/UzNRiJ0gpgqbw2POuXwLpjGSRTTIUuCaLaGGM5PeTSj7mMB73ykw==", - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.1.0.tgz", + "integrity": "sha512-zj0lJPZxQanXKD8ae2kYnweT0kpp1CzpHYAkaBjTrw2k6ZMfr/UPlp0/ugCjWEokBqh79RUADLkKJJPce/yoSQ==", "dependencies": { - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-styles": "^6.0.0", - "@patternfly/react-tokens": "^6.0.0", - "focus-trap": "7.6.0", - "react-dropzone": "^14.2.3", - "tslib": "^2.7.0" + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-styles": "^6.1.0", + "@patternfly/react-tokens": "^6.1.0", + "focus-trap": "7.6.2", + "react-dropzone": "^14.3.5", + "tslib": "^2.8.1" }, "peerDependencies": { "react": "^17 || ^18", "react-dom": "^17 || ^18" } }, + "node_modules/@patternfly/react-core/node_modules/@patternfly/react-icons": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.1.0.tgz", + "integrity": "sha512-V1w/j19YmOgvh72IRRf1p07k+u4M5+9P+o/IxunlF0fWzLDX4Hf+utBI11A8cRfUzpQN7eLw/vZIS3BLM8Ge3Q==", + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, + "node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.1.0.tgz", + "integrity": "sha512-JQ3zIl5SFiSB0YWVYibcUwgZdsp6Wn8hkfZ7KhtCjHFccSDdJexPOXVV1O9f2h4PfxTlY3YntZ81ZsguBx/Q7A==" + }, "node_modules/@patternfly/react-icons": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0.tgz", @@ -3713,23 +3724,36 @@ "license": "MIT" }, "node_modules/@patternfly/react-table": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.0.0.tgz", - "integrity": "sha512-LvWMzjcQZHdFUpK8fjj5EAFrNxqB8/MFd7gUUZu7AgYt6rmS2im4xk6yb7h0K7cAhY085oPeRF9lkYSCgzlRDg==", - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.1.0.tgz", + "integrity": "sha512-eC8mKkvFR0btfv6yEOvE+J4gBXU8ZGe9i2RSezBM+MJaXEQt/CKRjV+SAB5EeE3PyBYKG8yYDdsOoNmaPxxvSA==", "dependencies": { - "@patternfly/react-core": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-styles": "^6.0.0", - "@patternfly/react-tokens": "^6.0.0", + "@patternfly/react-core": "^6.1.0", + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-styles": "^6.1.0", + "@patternfly/react-tokens": "^6.1.0", "lodash": "^4.17.21", - "tslib": "^2.7.0" + "tslib": "^2.8.1" }, "peerDependencies": { "react": "^17 || ^18", "react-dom": "^17 || ^18" } }, + "node_modules/@patternfly/react-table/node_modules/@patternfly/react-icons": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.1.0.tgz", + "integrity": "sha512-V1w/j19YmOgvh72IRRf1p07k+u4M5+9P+o/IxunlF0fWzLDX4Hf+utBI11A8cRfUzpQN7eLw/vZIS3BLM8Ge3Q==", + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, + "node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.1.0.tgz", + "integrity": "sha512-JQ3zIl5SFiSB0YWVYibcUwgZdsp6Wn8hkfZ7KhtCjHFccSDdJexPOXVV1O9f2h4PfxTlY3YntZ81ZsguBx/Q7A==" + }, "node_modules/@patternfly/react-templates": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@patternfly/react-templates/-/react-templates-6.0.0.tgz", @@ -3748,10 +3772,9 @@ } }, "node_modules/@patternfly/react-tokens": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.0.0.tgz", - "integrity": "sha512-xd0ynDkiIW2rp8jz4TNvR4Dyaw9kSMkZdsuYcLlFXCVmvX//Mnl4rhBnid/2j2TaqK0NbkyTTPnPY/BU7SfLVQ==", - "license": "MIT" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.1.0.tgz", + "integrity": "sha512-t1UcHbOa4txczTR5UlnG4XcAAdnDSfSlCaOddw/HTqRF59pn2ks2JUu9sfnFRZ8SiAAxKRiYdX5bT7Mf4R24+w==" }, "node_modules/@pkgr/core": { "version": "0.1.1", @@ -4217,11 +4240,10 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", - "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -4230,10 +4252,10 @@ }, "peerDependencies": { "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -10277,10 +10299,9 @@ "optional": true }, "node_modules/focus-trap": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.0.tgz", - "integrity": "sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==", - "license": "MIT", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", + "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", "dependencies": { "tabbable": "^6.2.0" } @@ -11528,9 +11549,9 @@ } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "dev": true, "license": "MIT" }, @@ -14865,9 +14886,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -17830,14 +17851,14 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.80.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.6.tgz", - "integrity": "sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.0.tgz", + "integrity": "sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -19014,43 +19035,19 @@ } }, "node_modules/style-loader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", - "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", "dev": true, - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, "engines": { - "node": ">= 10.13.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/style-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "webpack": "^5.27.0" } }, "node_modules/stylehacks": { @@ -19312,8 +19309,7 @@ "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/tapable": { "version": "2.2.1", diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index e131fd12..b2239c02 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -39,11 +39,11 @@ "@cypress/code-coverage": "^3.13.8", "@mui/material": "^6.1.7", "@mui/icons-material": "^6.1.10", - "@mui/types": "^7.2.17", + "@mui/types": "^7.2.20", "@testing-library/cypress": "^10.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.2", - "@testing-library/react": "^16.0.0", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "14.5.2", "@types/classnames": "^2.3.1", "@types/dompurify": "^3.2.0", @@ -81,11 +81,11 @@ "react-refresh": "^0.14.2", "react-router-dom": "^7.0.2", "regenerator-runtime": "^0.14.1", - "sass": "^1.56.2", + "sass": "^1.83.0", "sass-loader": "^13.2.0", "serve": "^14.2.4", "speed-measure-webpack-plugin": "^1.5.0", - "style-loader": "^2.0.0", + "style-loader": "^4.0.0", "svg-url-loader": "^6.0.0", "swc-loader": "^0.2.6", "terser-webpack-plugin": "^5.3.10", @@ -102,20 +102,20 @@ "webpack-merge": "^6.0.1" }, "dependencies": { - "@emotion/react": "^11.13.5", + "@emotion/react": "^11.14.0", "@emotion/styled": "^11.13.5", "@patternfly/patternfly": "^6.0.0", - "@patternfly/react-core": "6.0.0", + "@patternfly/react-core": "6.1.0", "@patternfly/react-icons": "6.0.0", "@patternfly/react-styles": "6.0.0", - "@patternfly/react-table": "6.0.0", + "@patternfly/react-table": "6.1.0", "@patternfly/react-templates": "6.0.0", "lodash-es": "^4.17.15", "npm-run-all": "^4.1.5", "react": "^18", "react-dom": "^18", "react-router": "^7.0.2", - "sass": "^1.78.0", + "sass": "^1.83.0", "dompurify": "^3.2.3", "showdown": "^2.1.0", "classnames": "^2.2.6" diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 66fc6967..ed0785a6 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -13,8 +13,9 @@ import { } from '@patternfly/react-core'; import ToastNotifications from '~/shared/components/ToastNotifications'; import { useSettings } from '~/shared/hooks/useSettings'; -import { isMUITheme, Theme, AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const'; +import { isMUITheme, Theme, AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; import { logout } from '~/shared/utilities/appUtils'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; import NavSidebar from './NavSidebar'; import AppRoutes from './AppRoutes'; import { AppContext } from './AppContext'; @@ -29,6 +30,8 @@ const App: React.FC = () => { loadError: configError, } = useSettings(); + const { namespacesLoaded, namespacesLoadError } = React.useContext(NamespaceSelectorContext); + const username = userSettings?.userId; React.useEffect(() => { @@ -41,7 +44,7 @@ const App: React.FC = () => { }, []); React.useEffect(() => { - if (DEV_MODE && username) { + if (MOCK_AUTH && username) { localStorage.setItem(AUTH_HEADER, username); } else { localStorage.removeItem(AUTH_HEADER); @@ -59,8 +62,10 @@ const App: React.FC = () => { [configSettings, userSettings], ); + const error = configError || namespacesLoadError; + // We lack the critical data to startup the app - if (configError) { + if (error) { // There was an error fetching critical data return ( @@ -68,7 +73,11 @@ const App: React.FC = () => { -

{configError.message || 'Unknown error occurred during startup.'}

+

+ {configError?.message || + namespacesLoadError?.message || + 'Unknown error occurred during startup.'} +

Logging out and logging back in may solve the issue.

@@ -87,7 +96,8 @@ const App: React.FC = () => { } // Waiting on the API to finish - const loading = !configLoaded || !userSettings || !configSettings || !contextValue; + const loading = + !configLoaded || !userSettings || !configSettings || !contextValue || !namespacesLoaded; return loading ? ( diff --git a/clients/ui/frontend/src/app/AppRoutes.tsx b/clients/ui/frontend/src/app/AppRoutes.tsx index 9c858d56..1f0f6461 100644 --- a/clients/ui/frontend/src/app/AppRoutes.tsx +++ b/clients/ui/frontend/src/app/AppRoutes.tsx @@ -53,6 +53,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {/* TODO: [Conditional render] Follow up add testing and conditional rendering when in standalone mode*/} {clusterAdmin && ( } /> )} diff --git a/clients/ui/frontend/src/app/NavBar.tsx b/clients/ui/frontend/src/app/NavBar.tsx index 62876c85..b9e52df6 100644 --- a/clients/ui/frontend/src/app/NavBar.tsx +++ b/clients/ui/frontend/src/app/NavBar.tsx @@ -13,23 +13,25 @@ import { ToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; -import { SimpleSelect, SimpleSelectOption } from '@patternfly/react-templates'; +import { SimpleSelect } from '@patternfly/react-templates'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; interface NavBarProps { username?: string; onLogout: () => void; } -const Options: SimpleSelectOption[] = [{ content: 'All Namespaces', value: 'All' }]; - const NavBar: React.FC = ({ username, onLogout }) => { - const [selected, setSelected] = React.useState('All'); + const { namespaces, preferredNamespace, updatePreferredNamespace } = + React.useContext(NamespaceSelectorContext); + const [userMenuOpen, setUserMenuOpen] = React.useState(false); - const initialOptions = React.useMemo( - () => Options.map((o) => ({ ...o, selected: o.value === selected })), - [selected], - ); + const options = namespaces.map((namespace) => ({ + content: namespace.name, + value: namespace.name, + selected: namespace.name === preferredNamespace?.name, + })); const handleLogout = () => { setUserMenuOpen(false); @@ -51,9 +53,10 @@ const NavBar: React.FC = ({ username, onLogout }) => { setSelected(String(selection))} + initialOptions={options} + onSelect={(_ev, selection) => { + updatePreferredNamespace({ name: String(selection) }); + }} /> diff --git a/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx b/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx index 6c107e27..28ce8290 100644 --- a/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx +++ b/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; import { BFF_API_VERSION } from '~/app/const'; -import useModelRegistryAPIState, { ModelRegistryAPIState } from './useModelRegistryAPIState'; +import useQueryParamNamespaces from '~/shared/hooks/useQueryParamNamespaces'; +import useModelRegistryAPIState, { + ModelRegistryAPIState, +} from '~/app/hooks/useModelRegistryAPIState'; export type ModelRegistryContextType = { apiState: ModelRegistryAPIState; @@ -26,7 +29,9 @@ export const ModelRegistryContextProvider: React.FC = ({ children }) => { - const [modelRegistries, isLoaded, error] = useModelRegistries(); + const queryParams = useQueryParamNamespaces(); + + const [modelRegistries, isLoaded, error] = useModelRegistries(queryParams); const [preferredModelRegistry, setPreferredModelRegistry] = React.useState(undefined); diff --git a/clients/ui/frontend/src/app/hooks/useModelRegistries.ts b/clients/ui/frontend/src/app/hooks/useModelRegistries.ts index 20fdec71..7db8f7c6 100644 --- a/clients/ui/frontend/src/app/hooks/useModelRegistries.ts +++ b/clients/ui/frontend/src/app/hooks/useModelRegistries.ts @@ -5,9 +5,15 @@ import useFetchState, { } from '~/shared/utilities/useFetchState'; import { ModelRegistry } from '~/app/types'; import { getListModelRegistries } from '~/shared/api/k8s'; +import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; -const useModelRegistries = (): FetchState => { - const listModelRegistries = React.useMemo(() => getListModelRegistries(''), []); +const useModelRegistries = (queryParams: Record): FetchState => { + const paramsMemo = useDeepCompareMemoize(queryParams); + + const listModelRegistries = React.useMemo( + () => getListModelRegistries('', paramsMemo), + [paramsMemo], + ); const callback = React.useCallback>( (opts) => listModelRegistries(opts), [listModelRegistries], diff --git a/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts b/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts index 5a211568..9bbe0c78 100644 --- a/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts +++ b/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; type UseModelRegistryAPI = ModelRegistryAPIState & { diff --git a/clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx b/clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx similarity index 53% rename from clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx rename to clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx index d51592d9..e7364211 100644 --- a/clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx +++ b/clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx @@ -25,27 +25,31 @@ export type ModelRegistryAPIState = APIState; const useModelRegistryAPIState = ( hostPath: string | null, + queryParameters?: Record, ): [apiState: ModelRegistryAPIState, refreshAPIState: () => void] => { const createAPI = React.useCallback( (path: string) => ({ - createRegisteredModel: createRegisteredModel(path), - createModelVersion: createModelVersion(path), - createModelVersionForRegisteredModel: createModelVersionForRegisteredModel(path), - createModelArtifact: createModelArtifact(path), - createModelArtifactForModelVersion: createModelArtifactForModelVersion(path), - getRegisteredModel: getRegisteredModel(path), - getModelVersion: getModelVersion(path), - getModelArtifact: getModelArtifact(path), - listModelArtifacts: getListModelArtifacts(path), - listModelVersions: getListModelVersions(path), - listRegisteredModels: getListRegisteredModels(path), - getModelVersionsByRegisteredModel: getModelVersionsByRegisteredModel(path), - getModelArtifactsByModelVersion: getModelArtifactsByModelVersion(path), - patchRegisteredModel: patchRegisteredModel(path), - patchModelVersion: patchModelVersion(path), - patchModelArtifact: patchModelArtifact(path), + createRegisteredModel: createRegisteredModel(path, queryParameters), + createModelVersion: createModelVersion(path, queryParameters), + createModelVersionForRegisteredModel: createModelVersionForRegisteredModel( + path, + queryParameters, + ), + createModelArtifact: createModelArtifact(path, queryParameters), + createModelArtifactForModelVersion: createModelArtifactForModelVersion(path, queryParameters), + getRegisteredModel: getRegisteredModel(path, queryParameters), + getModelVersion: getModelVersion(path, queryParameters), + getModelArtifact: getModelArtifact(path, queryParameters), + listModelArtifacts: getListModelArtifacts(path, queryParameters), + listModelVersions: getListModelVersions(path, queryParameters), + listRegisteredModels: getListRegisteredModels(path, queryParameters), + getModelVersionsByRegisteredModel: getModelVersionsByRegisteredModel(path, queryParameters), + getModelArtifactsByModelVersion: getModelArtifactsByModelVersion(path, queryParameters), + patchRegisteredModel: patchRegisteredModel(path, queryParameters), + patchModelVersion: patchModelVersion(path, queryParameters), + patchModelArtifact: patchModelArtifact(path, queryParameters), }), - [], + [queryParameters], ); return useAPIState(hostPath, createAPI); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts index fadde80c..8d3510d6 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts @@ -1,6 +1,6 @@ import React from 'react'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import useUser from '~/app/hooks/useUser'; type RegistrationCommonState = { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts index 801cc76c..22fef4d0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts @@ -5,7 +5,7 @@ import { ModelVersion, RegisteredModel, } from '~/app/types'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import { objectStorageFieldsToUri } from '~/app/pages/modelRegistry/screens/utils'; import { ModelLocationType, diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx index 120ea680..6e320bb2 100644 --- a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx @@ -5,10 +5,13 @@ import ApplicationsPage from '~/shared/components/ApplicationsPage'; import useModelRegistries from '~/app/hooks/useModelRegistries'; import TitleWithIcon from '~/shared/components/design/TitleWithIcon'; import { ProjectObjectType } from '~/shared/components/design/utils'; +import useQueryParamNamespaces from '~/shared/hooks/useQueryParamNamespaces'; import ModelRegistriesTable from './ModelRegistriesTable'; const ModelRegistrySettings: React.FC = () => { - const [modelRegistries, loaded, loadError] = useModelRegistries(); + const queryParams = useQueryParamNamespaces(); + + const [modelRegistries, loaded, loadError] = useModelRegistries(queryParams); return ( <> - + + + diff --git a/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts b/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts index 07e1fbc8..4052e0c1 100644 --- a/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts +++ b/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts @@ -342,6 +342,7 @@ describe('patchRegisteredModel', () => { const mockData = { description: 'new test' }; const response = await patchRegisteredModel( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -349,6 +350,7 @@ describe('patchRegisteredModel', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); @@ -361,6 +363,7 @@ describe('patchModelVersion', () => { const mockData = { description: 'new test' }; const response = await patchModelVersion( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -368,6 +371,7 @@ describe('patchModelVersion', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); @@ -380,6 +384,7 @@ describe('patchModelArtifact', () => { const mockData = { description: 'new test' }; const response = await patchModelArtifact( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -387,6 +392,7 @@ describe('patchModelArtifact', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); diff --git a/clients/ui/frontend/src/shared/api/apiUtils.ts b/clients/ui/frontend/src/shared/api/apiUtils.ts index 01d03161..19d831ff 100644 --- a/clients/ui/frontend/src/shared/api/apiUtils.ts +++ b/clients/ui/frontend/src/shared/api/apiUtils.ts @@ -1,7 +1,7 @@ import { APIOptions } from '~/shared/api/types'; import { EitherOrNone } from '~/shared/typeHelpers'; import { ModelRegistryBody } from '~/app/types'; -import { DEV_MODE, AUTH_HEADER } from '~/shared/utilities/const'; +import { AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; export const mergeRequestInit = ( opts: APIOptions = {}, @@ -65,11 +65,14 @@ const callRestJSON = ( requestData = JSON.stringify(data); } + // Workaround if we wanna force in a call to add the AUTH_HEADER + const authHeader = Object.keys(otherOptions.headers || {}).some((key) => key === AUTH_HEADER); + return fetch(`${host}${path}${searchParams ? `?${searchParams}` : ''}`, { ...otherOptions, headers: { ...otherOptions.headers, - ...(DEV_MODE && { [AUTH_HEADER]: localStorage.getItem(AUTH_HEADER) }), + ...(MOCK_AUTH && !authHeader && { [AUTH_HEADER]: localStorage.getItem(AUTH_HEADER) }), ...(contentType && { 'Content-Type': contentType }), }, method, @@ -152,10 +155,12 @@ export const restPATCH = ( host: string, path: string, data: Record, + queryParams: Record = {}, options?: APIOptions, ): Promise => callRestJSON(host, path, mergeRequestInit(options, { method: 'PATCH' }), { data, + queryParams, parseJSON: options?.parseJSON, }); @@ -184,3 +189,8 @@ export const isModelRegistryResponse = (response: unknown): response is Model export const assembleModelRegistryBody = (data: T): ModelRegistryBody => ({ data, }); + +export const getNamespaceQueryParam = (): string | null => { + const params = new URLSearchParams(window.location.search); + return params.get('ns'); +}; diff --git a/clients/ui/frontend/src/shared/api/k8s.ts b/clients/ui/frontend/src/shared/api/k8s.ts index 6c483eae..1687740f 100644 --- a/clients/ui/frontend/src/shared/api/k8s.ts +++ b/clients/ui/frontend/src/shared/api/k8s.ts @@ -3,26 +3,38 @@ import { handleRestFailures } from '~/shared/api/errorUtils'; import { isModelRegistryResponse, restGET } from '~/shared/api/apiUtils'; import { ModelRegistry } from '~/app/types'; import { BFF_API_VERSION } from '~/app/const'; -import { UserSettings } from '~/shared/types'; +import { Namespace, UserSettings } from '~/shared/types'; export const getListModelRegistries = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, {}, opts)).then( + handleRestFailures( + restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); + +export const getUser = + (hostPath: string) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/user`, {}, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); }, ); -export const getUser = +export const getNamespaces = (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/user`, {}, opts)).then( + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/namespaces`, {}, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); diff --git a/clients/ui/frontend/src/shared/api/service.ts b/clients/ui/frontend/src/shared/api/service.ts index b62c7721..1e377235 100644 --- a/clients/ui/frontend/src/shared/api/service.ts +++ b/clients/ui/frontend/src/shared/api/service.ts @@ -20,10 +20,16 @@ import { APIOptions } from '~/shared/api/types'; import { handleRestFailures } from '~/shared/api/errorUtils'; export const createRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateRegisteredModelData): Promise => handleRestFailures( - restCREATE(hostPath, `/registered_models`, assembleModelRegistryBody(data), {}, opts), + restCREATE( + hostPath, + `/registered_models`, + assembleModelRegistryBody(data), + queryParams, + opts, + ), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -32,10 +38,10 @@ export const createRegisteredModel = }); export const createModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateModelVersionData): Promise => handleRestFailures( - restCREATE(hostPath, `/model_versions`, assembleModelRegistryBody(data), {}, opts), + restCREATE(hostPath, `/model_versions`, assembleModelRegistryBody(data), queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -44,7 +50,7 @@ export const createModelVersion = }); export const createModelVersionForRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, registeredModelId: string, @@ -55,7 +61,7 @@ export const createModelVersionForRegisteredModel = hostPath, `/registered_models/${registeredModelId}/versions`, assembleModelRegistryBody(data), - {}, + queryParams, opts, ), ).then((response) => { @@ -66,10 +72,10 @@ export const createModelVersionForRegisteredModel = }); export const createModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateModelArtifactData): Promise => handleRestFailures( - restCREATE(hostPath, `/model_artifacts`, assembleModelRegistryBody(data), {}, opts), + restCREATE(hostPath, `/model_artifacts`, assembleModelRegistryBody(data), queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -78,7 +84,7 @@ export const createModelArtifact = }); export const createModelArtifactForModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, modelVersionId: string, @@ -89,7 +95,7 @@ export const createModelArtifactForModelVersion = hostPath, `/model_versions/${modelVersionId}/artifacts`, assembleModelRegistryBody(data), - {}, + queryParams, opts, ), ).then((response) => { @@ -100,55 +106,57 @@ export const createModelArtifactForModelVersion = }); export const getRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, registeredModelId: string): Promise => - handleRestFailures(restGET(hostPath, `/registered_models/${registeredModelId}`, {}, opts)).then( - (response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }, - ); + handleRestFailures( + restGET(hostPath, `/registered_models/${registeredModelId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelversionId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_versions/${modelversionId}`, {}, opts)).then( - (response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }, - ); + handleRestFailures( + restGET(hostPath, `/model_versions/${modelversionId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelArtifactId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts/${modelArtifactId}`, {}, opts)).then( + handleRestFailures( + restGET(hostPath, `/model_artifacts/${modelArtifactId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); + +export const getListModelArtifacts = + (hostPath: string, queryParams: Record = {}) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/model_artifacts`, queryParams, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); }, ); -export const getListModelArtifacts = - (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts`, {}, opts)).then((response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); - export const getListModelVersions = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_versions`, {}, opts)).then((response) => { + handleRestFailures(restGET(hostPath, `/model_versions`, queryParams, opts)).then((response) => { if (isModelRegistryResponse(response)) { return response.data; } @@ -156,20 +164,22 @@ export const getListModelVersions = }); export const getListRegisteredModels = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/registered_models`, {}, opts)).then((response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); + handleRestFailures(restGET(hostPath, `/registered_models`, queryParams, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const getModelVersionsByRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, registeredmodelId: string): Promise => handleRestFailures( - restGET(hostPath, `/registered_models/${registeredmodelId}/versions`, {}, opts), + restGET(hostPath, `/registered_models/${registeredmodelId}/versions`, queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -178,10 +188,10 @@ export const getModelVersionsByRegisteredModel = }); export const getModelArtifactsByModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelVersionId: string): Promise => handleRestFailures( - restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, {}, opts), + restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -190,7 +200,7 @@ export const getModelArtifactsByModelVersion = }); export const patchRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, data: Partial, @@ -201,6 +211,7 @@ export const patchRegisteredModel = hostPath, `/registered_models/${registeredModelId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { @@ -211,13 +222,14 @@ export const patchRegisteredModel = }); export const patchModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: Partial, modelversionId: string): Promise => handleRestFailures( restPATCH( hostPath, `/model_versions/${modelversionId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { @@ -228,7 +240,7 @@ export const patchModelVersion = }); export const patchModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, data: Partial, @@ -239,6 +251,7 @@ export const patchModelArtifact = hostPath, `/model_artifacts/${modelartifactId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { diff --git a/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx b/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx new file mode 100644 index 00000000..372e9372 --- /dev/null +++ b/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import useNamespaces from '~/shared/hooks/useNamespaces'; +import { Namespace } from '~/shared/types'; + +export type NamespaceSelectorContextType = { + namespacesLoaded: boolean; + namespacesLoadError?: Error; + namespaces: Namespace[]; + preferredNamespace: Namespace | undefined; + updatePreferredNamespace: (namespace: Namespace | undefined) => void; +}; + +type NamespaceSelectorContextProviderProps = { + children: React.ReactNode; +}; + +export const NamespaceSelectorContext = React.createContext({ + namespacesLoaded: false, + namespacesLoadError: undefined, + namespaces: [], + preferredNamespace: undefined, + updatePreferredNamespace: () => undefined, +}); + +export const NamespaceSelectorContextProvider: React.FC = ({ + children, + ...props +}) => ( + + {children} + +); + +const EnabledNamespaceSelectorContextProvider: React.FC = ({ + children, +}) => { + const [namespaces, isLoaded, error] = useNamespaces(); + const [preferredNamespace, setPreferredNamespace] = + React.useState(undefined); + + const firstNamespace = namespaces.length > 0 ? namespaces[0] : null; + + const contextValue = React.useMemo( + () => ({ + namespacesLoaded: isLoaded, + namespacesLoadError: error, + namespaces, + preferredNamespace: preferredNamespace ?? firstNamespace ?? undefined, + updatePreferredNamespace: setPreferredNamespace, + }), + [isLoaded, error, namespaces, preferredNamespace, firstNamespace], + ); + + return ( + + {children} + + ); +}; diff --git a/clients/ui/frontend/src/shared/hooks/useNamespaces.ts b/clients/ui/frontend/src/shared/hooks/useNamespaces.ts new file mode 100644 index 00000000..72a0e0f7 --- /dev/null +++ b/clients/ui/frontend/src/shared/hooks/useNamespaces.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { Namespace } from '~/shared/types'; +import { AUTH_HEADER, isStandalone, MOCK_AUTH, USERNAME } from '~/shared/utilities/const'; +import { getNamespaces } from '~/shared/api/k8s'; + +const useNamespaces = (): FetchState => { + const listNamespaces = React.useMemo(() => getNamespaces(''), []); + const callback = React.useCallback>( + (opts) => { + if (!isStandalone()) { + return Promise.resolve([]); + } + const headers = MOCK_AUTH ? { [AUTH_HEADER]: USERNAME } : undefined; + return listNamespaces({ + ...opts, + headers, + }); + }, + [listNamespaces], + ); + return useFetchState(callback, [], { initialPromisePurity: true }); +}; + +export default useNamespaces; diff --git a/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts b/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts new file mode 100644 index 00000000..e24ce3de --- /dev/null +++ b/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts @@ -0,0 +1,14 @@ +import React from 'react'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; +import { isStandalone } from '~/shared/utilities/const'; +import { getNamespaceQueryParam } from '~/shared/api/apiUtils'; +import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; + +const useQueryParamNamespaces = (): Record => { + const { preferredNamespace: namespaceSelector } = React.useContext(NamespaceSelectorContext); + const namespace = isStandalone() ? namespaceSelector?.name : getNamespaceQueryParam(); + + return useDeepCompareMemoize({ namespace }); +}; + +export default useQueryParamNamespaces; diff --git a/clients/ui/frontend/src/shared/hooks/useSettings.tsx b/clients/ui/frontend/src/shared/hooks/useSettings.tsx index d81488c6..c7014efb 100644 --- a/clients/ui/frontend/src/shared/hooks/useSettings.tsx +++ b/clients/ui/frontend/src/shared/hooks/useSettings.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { USERNAME, POLL_INTERVAL, AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const'; +import { USERNAME, POLL_INTERVAL, AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; import { ConfigSettings, UserSettings } from '~/shared/types'; import useTimeBasedRefresh from '~/shared/hooks/useTimeBasedRefresh'; -import { getUser } from '~/shared/api/k8s'; +import { getNamespaces, getUser } from '~/shared/api/k8s'; export const useSettings = (): { configSettings: ConfigSettings | null; @@ -15,15 +15,16 @@ export const useSettings = (): { const [loadError, setLoadError] = React.useState(); const [config, setConfig] = React.useState(null); const [user, setUser] = React.useState(null); - const userSettings = React.useMemo(() => getUser(''), []); + const userRest = React.useMemo(() => getUser(''), []); + const namespaceRest = React.useMemo(() => getNamespaces(''), []); const setRefreshMarker = useTimeBasedRefresh(); React.useEffect(() => { let watchHandle: ReturnType; let cancelled = false; const watchConfig = () => { - const headers = DEV_MODE ? { [AUTH_HEADER]: USERNAME } : undefined; - Promise.all([fetchConfig(), userSettings({ headers })]) + const headers = MOCK_AUTH ? { [AUTH_HEADER]: USERNAME } : undefined; + Promise.all([fetchConfig(), userRest({ headers })]) .then(([fetchedConfig, fetchedUser]) => { if (cancelled) { return; @@ -56,12 +57,17 @@ export const useSettings = (): { cancelled = true; clearTimeout(watchHandle); }; - }, [setRefreshMarker, userSettings]); + }, [setRefreshMarker, userRest, namespaceRest]); const retConfig = useDeepCompareMemoize(config); const retUser = useDeepCompareMemoize(user); - return { configSettings: retConfig, userSettings: retUser, loaded, loadError }; + return { + configSettings: retConfig, + userSettings: retUser, + loaded, + loadError, + }; }; // Mock a settings config call diff --git a/clients/ui/frontend/src/shared/types.ts b/clients/ui/frontend/src/shared/types.ts index 31658843..56164eae 100644 --- a/clients/ui/frontend/src/shared/types.ts +++ b/clients/ui/frontend/src/shared/types.ts @@ -22,4 +22,8 @@ export type KeyValuePair = { value: string; }; +export type Namespace = { + name: string; +}; + export type UpdateObjectAtPropAndValue = (propKey: keyof T, propValue: ValueOf) => void; diff --git a/clients/ui/frontend/src/shared/utilities/const.ts b/clients/ui/frontend/src/shared/utilities/const.ts index 5fcb7e79..eef4905f 100644 --- a/clients/ui/frontend/src/shared/utilities/const.ts +++ b/clients/ui/frontend/src/shared/utilities/const.ts @@ -4,14 +4,23 @@ export enum Theme { // Future themes can be added here } +export enum DeploymentMode { + Standalone = 'standalone', + Integrated = 'integrated', +} + export const isMUITheme = (): boolean => STYLE_THEME === Theme.MUI; +export const isStandalone = (): boolean => DEPLOYMENT_MODE === DeploymentMode.Standalone; +export const isIntegrated = (): boolean => DEPLOYMENT_MODE === DeploymentMode.Integrated; const STYLE_THEME = process.env.STYLE_THEME || Theme.MUI; const DEV_MODE = process.env.APP_ENV === 'development'; +const MOCK_AUTH = process.env.MOCK_AUTH === 'true'; +const DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || DeploymentMode.Integrated; const POLL_INTERVAL = process.env.POLL_INTERVAL ? parseInt(process.env.POLL_INTERVAL) : 30000; const AUTH_HEADER = process.env.AUTH_HEADER || 'kubeflow-userid'; const USERNAME = process.env.USERNAME || 'user@example.com'; const IMAGE_DIR = process.env.IMAGE_DIR || 'images'; const LOGO_LIGHT = process.env.LOGO || 'logo-light-theme.svg'; -export { POLL_INTERVAL, DEV_MODE, AUTH_HEADER, USERNAME, IMAGE_DIR, LOGO_LIGHT }; +export { POLL_INTERVAL, DEV_MODE, AUTH_HEADER, USERNAME, IMAGE_DIR, LOGO_LIGHT, MOCK_AUTH }; diff --git a/clients/ui/frontend/src/shared/utilities/markdown.ts b/clients/ui/frontend/src/shared/utilities/markdown.ts index 9b0b2ec3..0bbfa6d9 100644 --- a/clients/ui/frontend/src/shared/utilities/markdown.ts +++ b/clients/ui/frontend/src/shared/utilities/markdown.ts @@ -13,8 +13,7 @@ export const markdownConverter = { // add hook to transform anchor tags DOMPurify.addHook('beforeSanitizeElements', (node) => { - // nodeType 1 = element type - if (node.nodeType === 1 && node.nodeName.toLowerCase() === 'a') { + if (node instanceof HTMLAnchorElement) { node.setAttribute('rel', 'noopener noreferrer'); } }); diff --git a/clients/ui/manifests/kubeflow/kustomization.yaml b/clients/ui/manifests/kubeflow/kustomization.yaml new file mode 100644 index 00000000..418e7642 --- /dev/null +++ b/clients/ui/manifests/kubeflow/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +patchesJson6902: + - path: model-registry-ui-deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-ui-deployment + - path: deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment \ No newline at end of file diff --git a/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml b/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml new file mode 100644 index 00000000..b7216756 --- /dev/null +++ b/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml @@ -0,0 +1,4 @@ +- op: add + path: /spec/template/spec/containers/0/args + value: + - "--standalone-mode=false" diff --git a/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml b/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml new file mode 100644 index 00000000..1959e4bb --- /dev/null +++ b/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml @@ -0,0 +1,9 @@ +- op: add + path: /spec/template/spec/containers/0/env + value: + - name: API_URL + value: "http://model-registry-bff-service:4000" + - name: MOCK_AUTH + value: "false" + - name: DEPLOYMENT_MODE + value: "integrated" \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/kubeflow-dashboard-rbac.yaml b/clients/ui/manifests/standalone/kubeflow-dashboard-rbac.yaml similarity index 100% rename from clients/ui/manifests/user-rbac/kubeflow-dashboard-rbac.yaml rename to clients/ui/manifests/standalone/kubeflow-dashboard-rbac.yaml diff --git a/clients/ui/manifests/standalone/kustomization.yaml b/clients/ui/manifests/standalone/kustomization.yaml new file mode 100644 index 00000000..fda80e7d --- /dev/null +++ b/clients/ui/manifests/standalone/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - kubeflow-dashboard-rbac.yaml + +patchesJson6902: + - path: model-registry-ui-deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment + - path: deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment \ No newline at end of file diff --git a/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml b/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml new file mode 100644 index 00000000..38b5569a --- /dev/null +++ b/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml @@ -0,0 +1,4 @@ +- op: add + path: /spec/template/spec/containers/0/args + value: + - "--standalone-mode=true" diff --git a/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml b/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml new file mode 100644 index 00000000..5211d0b0 --- /dev/null +++ b/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml @@ -0,0 +1,9 @@ +- op: add + path: /spec/template/spec/containers/0/env + value: + - name: API_URL + value: "http://model-registry-bff-service:4000" + - name: MOCK_AUTH + value: "true" + - name: DEPLOYMENT_MODE + value: "standalone" \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/admin-rbac.yaml b/clients/ui/manifests/user-rbac/admin-rbac.yaml deleted file mode 100644 index 592a58bf..00000000 --- a/clients/ui/manifests/user-rbac/admin-rbac.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: admin-user - namespace: kube-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: admin-user -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -subjects: - - kind: ServiceAccount - name: admin-user - namespace: kube-system \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/kustomization.yaml b/clients/ui/manifests/user-rbac/kustomization.yaml deleted file mode 100644 index 3e513a32..00000000 --- a/clients/ui/manifests/user-rbac/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - admin-rbac.yaml - - kubeflow-dashboard-rbac.yaml \ No newline at end of file