diff --git a/poetry.lock b/poetry.lock index b5eb6d2..99054c4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -307,14 +307,14 @@ files = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, - {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, + {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, + {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, ] [package.dependencies] @@ -683,72 +683,72 @@ pydantic = {version = ">=2.12.5,<3.0.0", extras = ["email"]} [[package]] name = "greenlet" -version = "3.4.0" +version = "3.5.0" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" groups = ["main"] markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" files = [ - {file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"}, - {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"}, - {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"}, - {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"}, - {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"}, - {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"}, - {file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"}, - {file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"}, - {file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"}, - {file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"}, - {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"}, - {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"}, - {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"}, - {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"}, - {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"}, - {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"}, - {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"}, - {file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"}, - {file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"}, - {file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"}, - {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"}, - {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"}, - {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"}, - {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"}, - {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"}, - {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"}, - {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"}, - {file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"}, - {file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"}, - {file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"}, - {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"}, - {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"}, - {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"}, - {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"}, - {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"}, - {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"}, - {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"}, - {file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"}, - {file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"}, - {file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"}, - {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"}, - {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"}, - {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"}, - {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"}, - {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"}, - {file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"}, - {file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"}, - {file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"}, - {file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"}, - {file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"}, - {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"}, - {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"}, - {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"}, - {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"}, - {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"}, - {file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"}, - {file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"}, - {file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"}, - {file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"}, + {file = "greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a"}, + {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f"}, + {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb"}, + {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd"}, + {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb"}, + {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243"}, + {file = "greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977"}, + {file = "greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0"}, + {file = "greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858"}, + {file = "greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082"}, + {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3"}, + {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c"}, + {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"}, + {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662"}, + {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc"}, + {file = "greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b"}, + {file = "greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4"}, + {file = "greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8"}, + {file = "greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339"}, + {file = "greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f"}, + {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628"}, + {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b"}, + {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136"}, + {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c"}, + {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d"}, + {file = "greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588"}, + {file = "greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e"}, + {file = "greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8"}, + {file = "greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2"}, + {file = "greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106"}, + {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b"}, + {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e"}, + {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d"}, + {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13"}, + {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae"}, + {file = "greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba"}, + {file = "greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846"}, + {file = "greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5"}, + {file = "greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b"}, + {file = "greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8"}, + {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1"}, + {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3"}, + {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37"}, + {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7"}, + {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2"}, + {file = "greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf"}, + {file = "greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16"}, + {file = "greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033"}, + {file = "greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988"}, + {file = "greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853"}, + {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f"}, + {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7"}, + {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce"}, + {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112"}, + {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2"}, + {file = "greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2"}, + {file = "greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2"}, + {file = "greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86"}, + {file = "greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4"}, ] [package.extras] @@ -831,14 +831,14 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.12" +version = "3.13" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67"}, - {file = "idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254"}, + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, ] [package.extras] @@ -936,14 +936,14 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] name = "packaging" -version = "26.1" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, - {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -1506,30 +1506,30 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"] [[package]] name = "ruff" -version = "0.15.11" +version = "0.15.12" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7"}, - {file = "ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e"}, - {file = "ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431"}, - {file = "ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19"}, - {file = "ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890"}, - {file = "ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5"}, - {file = "ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0"}, - {file = "ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c"}, - {file = "ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3"}, - {file = "ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3"}, - {file = "ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4"}, - {file = "ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33"}, + {file = "ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c"}, + {file = "ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c"}, + {file = "ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e"}, + {file = "ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20"}, + {file = "ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d"}, + {file = "ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f"}, + {file = "ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6"}, ] [[package]] @@ -1803,14 +1803,14 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [[package]] name = "virtualenv" -version = "21.2.4" +version = "21.3.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, - {file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, + {file = "virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35"}, + {file = "virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 5e01e04..b9cd5d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Node-Pod-Orchestration" -version = "0.5.1" +version = "0.5.5" description = "" authors = ["Alexander Röhl ", "David Hieber "] license = "Apache 2.0" diff --git a/src/api/api.py b/src/api/api.py index be62605..c3e650c 100644 --- a/src/api/api.py +++ b/src/api/api.py @@ -1,11 +1,12 @@ +import time import uvicorn -import os +from typing import Optional import threading from fastapi import APIRouter, FastAPI, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from src.utils.hub_client import init_hub_client_with_client, get_node_id_by_client +from src.utils.hub_client import init_hub_client, get_node_id_by_client from src.utils.other import extract_hub_envs from src.api.oauth import valid_access_token from src.resources.database.entity import Database @@ -24,6 +25,7 @@ logger = get_logger() + class PodOrchestrationAPI: """FastAPI application exposing the Pod Orchestration REST endpoints. @@ -49,16 +51,11 @@ def __init__(self, database: Database, namespace: str = 'default'): """ self.database = database - client_id, client_secret, hub_url_core, hub_auth, enable_hub_logging, http_proxy, https_proxy = extract_hub_envs() + self.node_id = None + self.hub_client = None + self.enable_hub_logging = None + self._set_node_id_and_hub_client(max_attempts=100) - self.enable_hub_logging = enable_hub_logging - self.hub_client = init_hub_client_with_client(client_id, - client_secret, - hub_url_core, - hub_auth, - http_proxy, - https_proxy) - self.node_id = get_node_id_by_client(self.hub_client, client_id) if self.hub_client else None self.namespace = namespace app = FastAPI(title="FLAME PO", docs_url="/api/docs", @@ -329,12 +326,16 @@ def stop_all_analysis_call(self): """ try: response = stop_analysis('all', self.database) - for analysis_id in self.database.get_analysis_ids(): - stream_logs(AnalysisStoppedLog(analysis_id), - self.node_id, - self.enable_hub_logging, - self.database, - self.hub_client) + self._set_node_id_and_hub_client(max_attempts=5) + if self.node_id is not None: + for analysis_id in self.database.get_analysis_ids(): + stream_logs(AnalysisStoppedLog(analysis_id), + self.node_id, + self.enable_hub_logging, + self.database, + self.hub_client) + else: + logger.warning(f"Couldn't forward logs for stopped analyses.") return response except Exception as e: logger.error(f"Error stopping ALL analyzes: {repr(e)}") @@ -354,11 +355,15 @@ def stop_analysis_call(self, analysis_id: str): """ try: response = stop_analysis(analysis_id, self.database) - stream_logs(AnalysisStoppedLog(analysis_id), - self.node_id, - self.enable_hub_logging, - self.database, - self.hub_client) + self._set_node_id_and_hub_client(max_attempts=5) + if self.node_id is not None: + stream_logs(AnalysisStoppedLog(analysis_id), + self.node_id, + self.enable_hub_logging, + self.database, + self.hub_client) + else: + logger.warning(f"Couldn't forward logs for stopped analysis.") return response except Exception as e: logger.error(f"Error stopping analysis: {repr(e)}") @@ -414,7 +419,11 @@ def cleanup_call(self, cleanup_type: str): HTTPException: 500 on any downstream failure (details in logs). """ try: - return cleanup(cleanup_type, self.database, self.namespace) + self._set_node_id_and_hub_client(max_attempts=5) + return cleanup(cleanup_type, + self.database, + self.hub_client, + self.namespace) except Exception as e: logger.error(f"Error cleaning up: {repr(e)}") raise HTTPException(status_code=500, detail=f"Error cleaning up (see po logs).") @@ -433,9 +442,14 @@ def stream_logs_call(self, body: CreateLogEntity): HTTPException: 500 on any downstream failure (details in logs). """ try: - return stream_logs(body, self.node_id, self.enable_hub_logging, self.database, self.hub_client) - except Exception as e: - logger.error(f"Error streaming logs: {repr(e)}") + self._set_node_id_and_hub_client(max_attempts=5) + if self.node_id is not None: + return stream_logs(body, self.node_id, self.enable_hub_logging, self.database, self.hub_client) + else: + raise Exception + except Exception: + logger.error(f"Error streaming logs: node_id={self.node_id}, " + f"hub_client={self.hub_client}.") raise HTTPException(status_code=500, detail=f"Error streaming logs (see po logs).") def health_call(self): @@ -452,3 +466,27 @@ def health_call(self): raise RuntimeError("Main thread is not alive.") else: return {'status': "ok"} + + def _set_node_id_and_hub_client(self, max_attempts: Optional[int] = None) -> None: + current_attempt = 1 + client_id, client_secret, hub_url_core, hub_auth, enable_hub_logging, http_proxy, https_proxy = extract_hub_envs() + if (self.hub_client is None) or (get_node_id_by_client(self.hub_client, client_id) is None): + self.node_id = None + while self.node_id is None: + self.enable_hub_logging = enable_hub_logging + self.hub_client = init_hub_client(client_id, + client_secret, + hub_url_core, + hub_auth, + http_proxy, + https_proxy) + self.node_id = get_node_id_by_client(self.hub_client, client_id) if self.hub_client else None + + if (max_attempts is not None) and (current_attempt >= max_attempts): + break + else: + current_attempt += 1 + if current_attempt % 60 == 59: + logger.warning(f"Unable to connect to Hub, attempt {current_attempt}" + f"{'/' + str(max_attempts) if max_attempts is not None else ''}.") + time.sleep(1) diff --git a/src/main.py b/src/main.py index b92746e..8ac48ab 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,6 @@ import os from threading import Thread - from dotenv import load_dotenv, find_dotenv -# load env -load_dotenv(find_dotenv()) - from src.resources.database.entity import Database from src.api.api import PodOrchestrationAPI @@ -13,7 +9,12 @@ from src.utils.po_logging import get_logger +# connect logger logger = get_logger() +# load cluster config +load_cluster_config() +# load env +load_dotenv(find_dotenv()) def main(): @@ -23,9 +24,6 @@ def main(): spawns the FastAPI server in a background thread, and starts the blocking status monitoring loop on the main thread. """ - # load cluster config - load_cluster_config() - if not os.getenv('NGINX_IMAGE'): logger.warning("Environment variable 'NGINX_IMAGE' is not set, defaulting to 'nginx:1.29.8'.") diff --git a/src/resources/analysis/entity.py b/src/resources/analysis/entity.py index 2c52ba9..dc9c875 100644 --- a/src/resources/analysis/entity.py +++ b/src/resources/analysis/entity.py @@ -8,6 +8,7 @@ from src.resources.database.db_models import AnalysisDB from src.resources.database.entity import Database from src.status.constants import AnalysisStatus +from src.utils.mb_client import delete_subscription class Analysis(BaseModel): @@ -55,15 +56,10 @@ def start(self, database: Database, namespace: str = 'default') -> None: self.analysis_config['PROJECT_ID'] = self.project_id self.analysis_config['DEPLOYMENT_NAME'] = self.deployment_name self.namespace = namespace - self.pod_ids = create_analysis_deployment(name=self.deployment_name, - image=self.image_url, - env=self.analysis_config, - namespace=namespace) - database.create_analysis(analysis_id=self.analysis_id, deployment_name=self.deployment_name, project_id=self.project_id, - pod_ids=self.pod_ids, + pod_ids=None, status=self.status, log=self.log, registry_url=self.registry_url, @@ -74,6 +70,12 @@ def start(self, database: Database, namespace: str = 'default') -> None: kong_token=self.kong_token, restart_counter=self.restart_counter, progress=self.progress) + self.pod_ids = create_analysis_deployment(name=self.deployment_name, + image=self.image_url, + env=self.analysis_config, + namespace=namespace) + + database.update_deployment(self.deployment_name, pod_ids=json.dumps(self.pod_ids)) def stop(self, database: Database, @@ -94,6 +96,9 @@ def stop(self, # Update the database database.update_deployment(self.deployment_name, status=self.status) database.update_deployment(self.deployment_name, log=self.log) + # end message-broker subscription + self.tokens = create_analysis_tokens(kong_token=self.kong_token, analysis_id=self.analysis_id) + delete_subscription(self.analysis_id, self.tokens['KEYCLOAK_TOKEN']) def read_db_analysis(analysis: AnalysisDB) -> Analysis: diff --git a/src/resources/log/entity.py b/src/resources/log/entity.py index 1de489a..a5cb4c1 100644 --- a/src/resources/log/entity.py +++ b/src/resources/log/entity.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from src.status.constants import _MAX_RESTARTS +from src.status.constants import _MAX_RESTARTS, AnalysisStatus class LogEntity(BaseModel): @@ -99,4 +99,8 @@ def __init__(self, analysis_id: str) -> None: log = (f"[flame -- POAPI: ANALYSISSTOPPED -- " f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}] " f"Info: The analysis was stopped either locally, or externally on another node.") - super().__init__(log=log, log_type="info", analysis_id=analysis_id, status="stopped", progress=0) + super().__init__(log=log, + log_type="info", + analysis_id=analysis_id, + status=AnalysisStatus.STOPPED.value, + progress=0) diff --git a/src/resources/utils.py b/src/resources/utils.py index 77f49c1..edfcc42 100644 --- a/src/resources/utils.py +++ b/src/resources/utils.py @@ -13,9 +13,10 @@ from src.k8s.utils import get_current_namespace, find_k8s_resources, delete_k8s_resource from src.utils.token import _get_all_keycloak_clients from src.utils.token import delete_keycloak_client -from src.utils.hub_client import (init_hub_client_and_update_hub_status_with_client, +from src.utils.hub_client import (init_hub_client_and_update_hub_status, update_hub_status, - get_node_analysis_id) + get_node_analysis_id, + get_analysis_node_statuses) from src.utils.other import resource_name_to_analysis from src.utils.po_logging import get_logger from src.utils.other import is_uuid @@ -77,7 +78,7 @@ def create_analysis(body: Union[CreateAnalysis, str], database: Database) -> dic analysis.start(database=database, namespace=namespace) # update hub status - init_hub_client_and_update_hub_status_with_client(body.analysis_id, AnalysisStatus.STARTED.value) + init_hub_client_and_update_hub_status(body.analysis_id, AnalysisStatus.STARTED.value) return {body.analysis_id: analysis.status} @@ -229,7 +230,7 @@ def stop_analysis(analysis_id_str: str, database: Database) -> dict[str, str]: deployment.stop(database, log=log) # update hub status - init_hub_client_and_update_hub_status_with_client(analysis_id, deployment.status) + init_hub_client_and_update_hub_status(analysis_id, deployment.status) return {analysis_id: deployment.status for analysis_id, deployment in deployments.items()} @@ -294,6 +295,7 @@ def unstuck_analysis_deployments(analysis_id: str, database: Database) -> None: def cleanup(cleanup_type: str, database: Database, + hub_client: CoreClient, namespace: str = 'default') -> dict[str, str]: """Run one or more targeted cleanup passes. @@ -310,6 +312,7 @@ def cleanup(cleanup_type: str, Args: cleanup_type: Selector or comma-separated selectors. database: Database wrapper used for persistence. + hub_client: FLAME Hub client. namespace: Namespace to search in. Returns: @@ -338,9 +341,9 @@ def cleanup(cleanup_type: str, if cleanup_type in ['all', 'services', 'rs']: # reinitialize storage-service pod storage_service_name = find_k8s_resources('pod', - 'label', - "component=flame-storage-service", - namespace=namespace)[0] + 'label', + "component=flame-storage-service", + namespace=namespace)[0] delete_k8s_resource(storage_service_name, 'pod', namespace) response_content[cleanup_type] = "Reset storage service" if cleanup_type in ['all', 'keycloak']: @@ -352,13 +355,15 @@ def cleanup(cleanup_type: str, delete_keycloak_client(client['clientId']) else: - response_content[cleanup_type] = f"Unknown cleanup type: {cleanup_type} (known types: 'zombies', 'all', " +\ + response_content[cleanup_type] = f"Unknown cleanup type: {cleanup_type} (known types: 'zombies', 'all', " + \ "'analyzes', 'keycloak', 'services', 'mb', and 'rs')" - response_content['zombies'] = clean_up_the_rest(database, namespace) + response_content['zombies'] = clean_up_the_rest(database, hub_client, namespace) return response_content -def clean_up_the_rest(database: Database, namespace: str = 'default') -> str: +def clean_up_the_rest(database: Database, + hub_client: CoreClient, + namespace: str = 'default') -> str: """Delete orphaned Kubernetes resources whose analysis is no longer tracked. Iterates over deployments, pods, services, network policies, and config @@ -367,6 +372,7 @@ def clean_up_the_rest(database: Database, namespace: str = 'default') -> str: Args: database: Database wrapper used to look up the known analysis ids. + hub_client: FLAME Hub client. namespace: Namespace to search in. Returns: @@ -374,17 +380,25 @@ def clean_up_the_rest(database: Database, namespace: str = 'default') -> str: deleted per resource type. """ known_analysis_ids = database.get_analysis_ids() + if hub_client is not None: + validated_analysis_ids = _validate_analyses_with_hub(known_analysis_ids, hub_client) + for id in known_analysis_ids: + if id not in validated_analysis_ids: + database.delete_analysis(id) + known_analysis_ids = validated_analysis_ids + else: + logger.warning(f"No Hub client found, skipping hub validation for zombie deletion.") result_str = "" - for res, (selector_args, max_r_split) in {'deployment': (["component=flame-analysis", "component=flame-analysis-nginx"], 1), - 'pod': (["component=flame-analysis", "component=flame-analysis-nginx"], 2), - 'service': (["component=flame-analysis", "component=flame-analysis-nginx"], 1), - 'networkpolicy': (["component=flame-nginx-to-analysis-policy"], 2), - 'configmap': (["component=flame-nginx-analysis-config-map"], 2)}.items(): + for res, selector_args in {'deployment': ["component=flame-analysis", "component=flame-analysis-nginx"], + 'pod': ["component=flame-analysis", "component=flame-analysis-nginx"], + 'service': ["component=flame-analysis", "component=flame-analysis-nginx"], + 'networkpolicy': ["component=flame-nginx-to-analysis-policy"], + 'configmap': ["component=flame-nginx-analysis-config-map"]}.items(): for selector_arg in selector_args: resources = find_k8s_resources(res, 'label', selector_arg, namespace=namespace) zombie_resources = [r for r in resources - if (r is not None) and (resource_name_to_analysis(r, max_r_split) not in known_analysis_ids)] + if (r is not None) and (resource_name_to_analysis(r) not in known_analysis_ids)] for z in zombie_resources: delete_k8s_resource(z, res, namespace=namespace) result_str += f"Deleted {len(zombie_resources)} zombie " + \ @@ -392,7 +406,11 @@ def clean_up_the_rest(database: Database, namespace: str = 'default') -> str: return result_str -def stream_logs(log_entity: CreateLogEntity, node_id: str, enable_hub_logging: bool, database: Database, hub_core_client: CoreClient) -> None: +def stream_logs(log_entity: CreateLogEntity, + node_id: str, + enable_hub_logging: bool, + database: Database, + hub_core_client: CoreClient) -> None: """Persist a log line and mirror status/progress into the FLAME Hub. * Appends the serialized log to the analysis row in the database. @@ -421,13 +439,23 @@ def stream_logs(log_entity: CreateLogEntity, node_id: str, enable_hub_logging: b level=log_entity.log_type, message=log_entity.log) - if database.progress_valid(log_entity.analysis_id, log_entity.progress): - database.update_analysis_progress(log_entity.analysis_id, log_entity.progress) - update_hub_status(hub_core_client, - get_node_analysis_id(hub_core_client, log_entity.analysis_id, node_id), - run_status=log_entity.status, - run_progress=log_entity.progress) - else: - update_hub_status(hub_core_client, - get_node_analysis_id(hub_core_client, log_entity.analysis_id, node_id), - run_status=log_entity.status) + node_analysis_id = get_node_analysis_id(hub_core_client, log_entity.analysis_id, node_id) + if isinstance(node_analysis_id, str): + if database.progress_valid(log_entity.analysis_id, log_entity.progress): + database.update_analysis_progress(log_entity.analysis_id, log_entity.progress) + update_hub_status(hub_core_client, + node_analysis_id, + run_status=log_entity.status, + run_progress=log_entity.progress) + else: + update_hub_status(hub_core_client, + node_analysis_id, + run_status=log_entity.status) + + +def _validate_analyses_with_hub(analysis_ids: list[str], hub_client: CoreClient) -> list[str]: + validated_ids = [] + for analysis_id in analysis_ids: + if get_node_analysis_id(hub_client, analysis_id) != []: + validated_ids.append(analysis_id) + return validated_ids diff --git a/src/status/status.py b/src/status/status.py index 9ca4038..65615e4 100644 --- a/src/status/status.py +++ b/src/status/status.py @@ -1,28 +1,27 @@ import time import os from typing import Optional -from httpx import Client, HTTPStatusError, ConnectError, ConnectTimeout +from httpx import Client, HTTPStatusError, ConnectError, ConnectTimeout, TimeoutException import flame_hub from src.resources.log.entity import CreateStartUpErrorLog from src.k8s.kubernetes import PORTS, get_pod_status +from src.k8s.utils import get_current_namespace from src.resources.database.entity import Database, AnalysisDB - - -from src.utils.hub_client import (init_hub_client_with_client, +from src.resources.utils import (unstuck_analysis_deployments, + stop_analysis, + delete_analysis, + stream_logs, + clean_up_the_rest) +from src.status.constants import AnalysisStatus, _MAX_RESTARTS, _INTERNAL_STATUS_TIMEOUT +from src.utils.hub_client import (init_hub_client, get_node_id_by_client, get_node_analysis_id, get_partner_node_statuses, update_hub_status) -from src.resources.utils import (unstuck_analysis_deployments, - stop_analysis, - delete_analysis, - stream_logs) -from src.status.constants import AnalysisStatus from src.utils.other import extract_hub_envs from src.utils.token import get_keycloak_token -from src.status.constants import _MAX_RESTARTS, _INTERNAL_STATUS_TIMEOUT from src.utils.po_logging import get_logger @@ -62,8 +61,8 @@ def status_loop(database: Database, status_loop_interval: int) -> None: hub_auth, http_proxy, https_proxy) - if all(p is not None for p in client_params): - hub_client = init_hub_client_with_client(*client_params) + if None not in client_params: + hub_client = init_hub_client(*client_params) else: logger.error(f"One or more hub client initialization parameters are None.\n" f"Check values file for given parameters:\n" @@ -74,7 +73,7 @@ def status_loop(database: Database, status_loop_interval: int) -> None: f"\t* PO_HTTP_PROXY={http_proxy}{'' if http_proxy is not None else ' <- review this'}\n" f"\t* PO_HTTPS_PROXY={https_proxy}{'' if https_proxy is not None else ' <- review this'}") raise ValueError("One or more hub client initialization parameters are None.") - if all(p is not None for p in (hub_client, client_id)): + if None not in (hub_client, client_id): node_id = get_node_id_by_client(hub_client, client_id) # Catch unresponsive hub client if node_id is None: @@ -88,18 +87,16 @@ def status_loop(database: Database, status_loop_interval: int) -> None: if database.analysis_is_running(analysis_id)] logger.action(f"Checking for running analyzes...{running_analyzes}") if running_analyzes: - hub_client_issues = 0 for analysis_id in running_analyzes: logger.status_loop(f"Current analysis id: {analysis_id}") # Get node analysis id if analysis_id not in node_analysis_ids.keys(): node_analysis_id = get_node_analysis_id(hub_client, analysis_id, node_id) - if node_analysis_id is not None: + if isinstance(node_analysis_id, str): node_analysis_ids[analysis_id] = node_analysis_id else: logger.warning(f"Retrieving node_analysis id for malformed analysis returned None " f"(analysis_id={analysis_id})... Skipping") - hub_client_issues += 1 continue else: node_analysis_id = node_analysis_ids[analysis_id] @@ -121,12 +118,9 @@ def status_loop(database: Database, status_loop_interval: int) -> None: analysis_status = _get_analysis_status(analysis_id, database) if analysis_status is None: continue - logger.debug(f"Database status: {analysis_status['db_status']}") - logger.debug(f"Internal status: {analysis_status['int_status']}") # Fix stuck analyzes if analysis_status['status_action'] == 'unstuck': - logger.info(f"Unstuck analysis with internal status: {analysis_status['int_status']}") _fix_stuck_status(database, analysis_status, node_id, enable_hub_logging, hub_client) # Update analysis status (skip iteration if analysis is not deployed) analysis_status = _get_analysis_status(analysis_id, database) @@ -135,7 +129,6 @@ def status_loop(database: Database, status_loop_interval: int) -> None: # Update created to running status if analysis_status['status_action'] == 'running': - logger.info(f"Update created-to-running database status: {analysis_status['db_status']}") _update_running_status(database, analysis_status) # Update analysis status (skip iteration if analysis is not deployed) analysis_status = _get_analysis_status(analysis_id, database) @@ -144,7 +137,6 @@ def status_loop(database: Database, status_loop_interval: int) -> None: # Update running to finished status if analysis_status['status_action'] == 'finishing': - logger.info(f"Update running-to-finished database status: {analysis_status['db_status']}") _update_finished_status(database, analysis_status) # Update analysis status (skip iteration if analysis is not deployed) analysis_status = _get_analysis_status(analysis_id, database) @@ -157,10 +149,14 @@ def status_loop(database: Database, status_loop_interval: int) -> None: f"db_status={analysis_status['db_status']}, " f"internal_status={analysis_status['int_status']} " f"to {analysis_hub_status}") + # Clean up Zombies + result = clean_up_the_rest(database, hub_client, get_current_namespace()) + if result: + logger.action(f"Cleaned up orphaned resources...\n{result}") - time.sleep(status_loop_interval) + # Sleep at end of iteration logger.status_loop(f"Iteration completed. Sleeping for {status_loop_interval} seconds.") - + time.sleep(status_loop_interval) def inform_analysis_of_partner_statuses(database: Database, @@ -193,7 +189,7 @@ def inform_analysis_of_partner_statuses(database: Database, logger.warning(f"Error whilst trying to access analysis partner_status endpoint: {repr(e)}") except ConnectError as e: logger.warning(f"Connection to http://nginx-{deployment_name}:{PORTS['nginx'][0]} yielded an error: {repr(e)}") - except ConnectTimeout as e: + except (TimeoutException, ConnectTimeout) as e: logger.warning(f"Connection to http://nginx-{deployment_name}:{PORTS['nginx'][0]} timed out: {repr(e)}") client.close() return None @@ -220,10 +216,12 @@ def _get_analysis_status(analysis_id: str, database: Database) -> Optional[dict[ int_status = AnalysisStatus.EXECUTED.value else: int_status = _get_internal_deployment_status(analysis.deployment_name, analysis_id) + status_action = _decide_status_action(analysis.status, int_status) + logger.status_loop(f"Calculating action: db_status={analysis.status}, int_status={int_status} -> status_action={status_action}") return {'analysis_id': analysis_id, 'db_status': analysis.status, 'int_status': int_status, - 'status_action': _decide_status_action(analysis.status, int_status)} + 'status_action': status_action} else: return None @@ -280,7 +278,7 @@ def _get_internal_deployment_status(deployment_name: str, analysis_id: str) -> s logger.warning(f"Error whilst retrieving internal deployment status: {repr(e)}") except ConnectError as e: logger.warning(f"Connection to http://nginx-{deployment_name}:{PORTS['nginx'][0]} yielded an error: {repr(e)}") - except ConnectTimeout as e: + except (TimeoutException, ConnectTimeout) as e: logger.warning(f"Connection to http://nginx-{deployment_name}:{PORTS['nginx'][0]} timed out: {repr(e)}") elapsed_time = time.time() - start_time if elapsed_time > _INTERNAL_STATUS_TIMEOUT: @@ -304,6 +302,8 @@ def _get_internal_deployment_status(deployment_name: str, analysis_id: str) -> s health_status = AnalysisStatus.EXECUTING.value elif analysis_status == AnalysisStatus.STUCK.value: health_status = AnalysisStatus.STUCK.value + elif analysis_status == AnalysisStatus.STOPPED.value: + health_status = AnalysisStatus.STOPPED.value else: health_status = AnalysisStatus.FAILED.value return health_status @@ -397,7 +397,7 @@ def _stream_stuck_logs(analysis: AnalysisDB, if not ready: is_k8s_related = True logger.error(f"Deployment of analysis={analysis.analysis_id} failed (ready={ready}). " - f"{reason}: {message}") + f"{reason}: {message}") # Create and stream POAPIError logs or either slow, stuck, or kubernetes_error state to Hub stream_logs(CreateStartUpErrorLog(analysis.restart_counter, @@ -427,8 +427,11 @@ def _update_finished_status(database: Database, analysis_status: dict[str, str]) """ analysis = database.get_latest_deployment(analysis_status['analysis_id']) if analysis is not None: - finished_status = analysis_status['int_status'] \ - if analysis_status['int_status'] != AnalysisStatus.STUCK.value else AnalysisStatus.FAILED.value + if analysis_status['int_status'] == AnalysisStatus.STUCK.value: + finished_status = AnalysisStatus.FAILED.value + else: + finished_status = analysis_status['int_status'] + database.update_deployment_status(analysis.deployment_name, finished_status) if analysis_status['int_status'] == AnalysisStatus.EXECUTED.value: logger.info("Delete deployment") @@ -451,10 +454,12 @@ def _set_analysis_hub_status(hub_client: flame_hub.CoreClient, """ if analysis_status['db_status'] in [AnalysisStatus.STARTED.value, AnalysisStatus.FAILED.value, + AnalysisStatus.STOPPED.value, AnalysisStatus.EXECUTED.value]: analysis_hub_status = analysis_status['db_status'] elif analysis_status['int_status'] in [AnalysisStatus.FAILED.value, AnalysisStatus.EXECUTED.value, + AnalysisStatus.STOPPED.value, AnalysisStatus.EXECUTING.value]: analysis_hub_status = analysis_status['int_status'] else: diff --git a/src/utils/hub_client.py b/src/utils/hub_client.py index 12f0d4a..68d08f6 100644 --- a/src/utils/hub_client.py +++ b/src/utils/hub_client.py @@ -4,7 +4,7 @@ from pathlib import Path from functools import lru_cache from json import JSONDecodeError -from typing import Optional +from typing import Optional, Union from httpx import (Client, HTTPTransport, HTTPStatusError, @@ -22,7 +22,7 @@ logger = get_logger() -def init_hub_client_with_client(client_id: str, +def init_hub_client(client_id: str, client_secret: str, hub_url_core: str, hub_auth: str, @@ -53,7 +53,6 @@ def init_hub_client_with_client(client_id: str, "https://": HTTPTransport(proxy=https_proxy, verify=ssl_ctx) } try: - _client = Client(base_url=hub_auth, mounts=proxies, verify=ssl_ctx) hub_client = flame_hub.auth.ClientAuth(client_id=client_id, client_secret=client_secret, @@ -61,7 +60,6 @@ def init_hub_client_with_client(client_id: str, client = Client(base_url=hub_url_core, mounts=proxies, auth=hub_client, verify=ssl_ctx) hub_client = flame_hub.CoreClient(client=client) - logger.action("Hub client init successful") except Exception as e: hub_client = None logger.error(f"Failed to authenticate with hub python client library: {repr(e)}") @@ -101,7 +99,7 @@ def get_node_id_by_client(hub_client: flame_hub.CoreClient, client_id: str) -> O return str(node_id_object.id) if node_id_object is not None else None -def get_node_analysis_id(hub_client: flame_hub.CoreClient, analysis_id: str, node_id_object_id: str) -> Optional[str]: +def get_node_analysis_id(hub_client: flame_hub.CoreClient, analysis_id: str, node_id_object_id: Optional[str] = None) -> Optional[Union[str, list[str]]]: """Look up the Hub analysis-node id for a (analysis, node) pair. Args: @@ -110,21 +108,26 @@ def get_node_analysis_id(hub_client: flame_hub.CoreClient, analysis_id: str, nod node_id_object_id: Hub node id (see :func:`get_node_id_by_client`). Returns: - The analysis-node UUID as a string, or ``None`` if none exists. + The analysis-node UUID as a string for the specified node_id_object_id, else all analysis-node UUIDs, + an empty list if none exists, or None if the request failed. """ try: - node_analyzes = hub_client.find_analysis_nodes(filter={'analysis_id': analysis_id, - 'node_id': node_id_object_id}) + if node_id_object_id is not None: + node_analyzes = hub_client.find_analysis_nodes(filter={'analysis_id': analysis_id, + 'node_id': node_id_object_id}) + else: + node_analyzes = hub_client.find_analysis_nodes(filter={'analysis_id': analysis_id}) except (HTTPStatusError, flame_hub._exceptions.HubAPIError, AttributeError) as e: logger.error(f"Failed to retrieve node analyzes from hub python client: {repr(e)}") node_analyzes = None if node_analyzes: - node_analysis_id = str(node_analyzes[0].id) + if node_id_object_id is not None: + return str(node_analyzes[0].id) + else: + return [str(node_analysis.id) for node_analysis in node_analyzes] else: - node_analysis_id = None - - return node_analysis_id + return [] def update_hub_status(hub_client: flame_hub.CoreClient, @@ -168,11 +171,8 @@ def get_analysis_node_statuses(hub_client: flame_hub.CoreClient, analysis_id: st node_analyzes = hub_client.find_analysis_nodes(filter={'analysis_id': analysis_id}) except (HTTPStatusError, flame_hub._exceptions.HubAPIError, AttributeError) as e: logger.error(f"Failed to retrieve node analyzes from hub python client: {repr(e)}") - return None - analysis_node_statuses = {} - for node in node_analyzes: - analysis_node_statuses[str(node.id)] = node.execution_status - return analysis_node_statuses + return None + return {str(node.id): node.execution_status for node in node_analyzes} if node_analyzes else {} def get_partner_node_statuses(hub_client: flame_hub.CoreClient, @@ -195,7 +195,7 @@ def get_partner_node_statuses(hub_client: flame_hub.CoreClient, if analysis_node_statuses is not None else None -def init_hub_client_and_update_hub_status_with_client(analysis_id: str, status: str) -> None: +def init_hub_client_and_update_hub_status(analysis_id: str, status: str) -> None: """One-shot convenience that (re)builds a Hub client and pushes a status update. Used by API endpoints that do not hold a long-lived Hub client. Logs and @@ -207,12 +207,12 @@ def init_hub_client_and_update_hub_status_with_client(analysis_id: str, status: status: New execution status string. """ client_id, client_secret, hub_url_core, hub_auth, _, http_proxy, https_proxy = extract_hub_envs() - hub_client = init_hub_client_with_client(client_id, client_secret, hub_url_core, hub_auth, http_proxy, https_proxy) + hub_client = init_hub_client(client_id, client_secret, hub_url_core, hub_auth, http_proxy, https_proxy) if hub_client is not None: node_id = get_node_id_by_client(hub_client, client_id) if node_id is not None: node_analysis_id = get_node_analysis_id(hub_client, analysis_id, node_id) - if node_analysis_id is not None: + if isinstance(node_analysis_id, str): update_hub_status(hub_client, node_analysis_id, run_status=status) else: logger.error("Failed to retrieve node_analysis_id from hub client. Cannot update status.") diff --git a/src/utils/mb_client.py b/src/utils/mb_client.py new file mode 100644 index 0000000..d3c7e45 --- /dev/null +++ b/src/utils/mb_client.py @@ -0,0 +1,24 @@ +from httpx import Client, ConnectError, HTTPStatusError, TimeoutException, ConnectTimeout +from src.k8s.utils import find_k8s_resources +from src.utils.po_logging import get_logger + + +logger = get_logger() + + +def delete_subscription(analysis_id: str, keycloak_token: str, namespace: str = 'default') -> None: + try: + # get the service name of the message broker + message_broker_service_name = find_k8s_resources('service', + 'label', + 'component=flame-message-broker', + namespace=namespace)[0] + mb_client = Client(base_url=f"http://{message_broker_service_name}", + headers={"Authorization": f"Bearer {keycloak_token}", + "accept": "application/json"}, + follow_redirects=True) + response = mb_client.delete(f"/analyses/{analysis_id}/messages/subscriptions") + response.raise_for_status() + except (HTTPStatusError, ConnectError, TimeoutException, ConnectTimeout, IndexError) as e: + logger.warning(f"Could not reach Message-Broker service to delete analysis subscription " + f"(analysis_id={analysis_id}): {repr(e)}") diff --git a/src/utils/other.py b/src/utils/other.py index 3649230..c50820f 100644 --- a/src/utils/other.py +++ b/src/utils/other.py @@ -25,7 +25,7 @@ def extract_hub_envs() -> tuple[Optional[str], os.getenv('PO_HTTPS_PROXY')) -def resource_name_to_analysis(deployment_name: str, max_r_split: int = 1) -> str: +def resource_name_to_analysis(deployment_name: str) -> str: """Extract the analysis id from a FLAME analysis resource name. Resource names follow the ``analysis-{analysis_id}-{restart_counter}`` @@ -34,12 +34,11 @@ def resource_name_to_analysis(deployment_name: str, max_r_split: int = 1) -> str Args: deployment_name: Kubernetes resource name. - max_r_split: Number of trailing ``-``-separated segments to drop. Returns: The analysis id portion of the name. """ - return deployment_name.split("analysis-")[-1].rsplit('-', max_r_split)[0] + return '-'.join(deployment_name.split("analysis-")[-1].split('-', 5)[:-1]) def is_uuid(test_str: Union[str, uuid.UUID], version: int = 4): diff --git a/tests/conftest.py b/tests/conftest.py index bee8283..80d5ccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -253,7 +253,7 @@ def fake_uvicorn_run(app, **kwargs): ), ), patch( - "src.api.api.init_hub_client_with_client", + "src.api.api.init_hub_client", return_value=mock_hub_client, ), patch( diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 417d8b3..c5380cc 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -269,12 +269,12 @@ def test_delete_by_id_500_on_exception(self, api_test_client): # ─── TestCleanupEndpoint ────────────────────────────────────────────────────── class TestCleanupEndpoint: - def test_cleanup(self, api_test_client, mock_database): + def test_cleanup(self, api_test_client, mock_database, mock_hub_client): fake_result = {"analyzes": "Deleted 1 analysis deployments"} with patch("src.api.api.cleanup", return_value=fake_result) as mock_fn: response = api_test_client.delete("/po/cleanup/analyzes") assert response.status_code == 200 - mock_fn.assert_called_once_with("analyzes", mock_database, "default") + mock_fn.assert_called_once_with("analyzes", mock_database, mock_hub_client, "default") def test_cleanup_500_on_exception(self, api_test_client): with patch("src.api.api.cleanup", side_effect=RuntimeError("err")): diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 5e1e94c..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for src/main.py — entry point thread startup.""" - -from unittest.mock import MagicMock, patch, call - - -class TestMain: - def test_main_starts_api_thread_and_status_loop(self): - """main() starts an API thread and calls status_loop in the main thread.""" - mock_db = MagicMock() - mock_thread = MagicMock() - - with ( - patch("src.main.load_dotenv"), - patch("src.main.find_dotenv", return_value=".env"), - patch("src.main.load_cluster_config"), - patch("src.main.Database", return_value=mock_db), - patch("src.main.get_current_namespace", return_value="default"), - patch("src.main.Thread", return_value=mock_thread) as mock_thread_cls, - patch("src.main.status_loop") as mock_status_loop, - ): - from src.main import main - main() - - # Thread was created targeting start_po_api with correct kwargs - mock_thread_cls.assert_called_once() - call_kwargs = mock_thread_cls.call_args - assert call_kwargs.kwargs["kwargs"] == {"database": mock_db, "namespace": "default"} - - # Thread was started - mock_thread.start.assert_called_once() - - # status_loop was called with the database and default interval - mock_status_loop.assert_called_once_with(mock_db, 10) - - def test_main_uses_custom_status_loop_interval(self, monkeypatch): - """When STATUS_LOOP_INTERVAL is set, main() uses that value.""" - monkeypatch.setenv("STATUS_LOOP_INTERVAL", "30") - - mock_db = MagicMock() - mock_thread = MagicMock() - - with ( - patch("src.main.load_dotenv"), - patch("src.main.find_dotenv", return_value=".env"), - patch("src.main.load_cluster_config"), - patch("src.main.Database", return_value=mock_db), - patch("src.main.get_current_namespace", return_value="default"), - patch("src.main.Thread", return_value=mock_thread), - patch("src.main.status_loop") as mock_status_loop, - ): - from src.main import main - main() - - mock_status_loop.assert_called_once_with(mock_db, 30) - - def test_main_sets_default_status_loop_interval_when_missing(self, monkeypatch): - """When STATUS_LOOP_INTERVAL is not set, main() defaults to 10.""" - monkeypatch.delenv("STATUS_LOOP_INTERVAL", raising=False) - - mock_db = MagicMock() - mock_thread = MagicMock() - - with ( - patch("src.main.load_dotenv"), - patch("src.main.find_dotenv", return_value=".env"), - patch("src.main.load_cluster_config"), - patch("src.main.Database", return_value=mock_db), - patch("src.main.get_current_namespace", return_value="default"), - patch("src.main.Thread", return_value=mock_thread), - patch("src.main.status_loop") as mock_status_loop, - ): - from src.main import main - main() - - mock_status_loop.assert_called_once_with(mock_db, 10) - - def test_start_po_api_instantiates_pod_orchestration_api(self): - """start_po_api creates a PodOrchestrationAPI with the given args.""" - mock_db = MagicMock() - - with patch("src.main.PodOrchestrationAPI") as mock_api_cls: - from src.main import start_po_api - start_po_api(database=mock_db, namespace="test-ns") - - mock_api_cls.assert_called_once_with(mock_db, "test-ns") \ No newline at end of file diff --git a/tests/test_resources_analysis_entity.py b/tests/test_resources_analysis_entity.py index 1e9ecc1..a5ae4f9 100644 --- a/tests/test_resources_analysis_entity.py +++ b/tests/test_resources_analysis_entity.py @@ -180,33 +180,57 @@ def started_analysis(self): ) def test_stop_sets_status_to_stopped(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database) assert started_analysis.status == AnalysisStatus.STOPPED.value def test_stop_with_custom_status(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database, status=AnalysisStatus.FAILED.value) assert started_analysis.status == AnalysisStatus.FAILED.value def test_stop_sets_log_when_provided(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database, log="something went wrong") assert started_analysis.log == "something went wrong" def test_stop_preserves_existing_log_when_none_provided(self, started_analysis, mock_database): started_analysis.log = "original log" - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database) assert started_analysis.log == "original log" def test_stop_calls_delete_deployment(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment") as mock_del: + with ( + patch("src.resources.analysis.entity.delete_deployment") as mock_del, + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database) mock_del.assert_called_once_with("analysis-test-analysis-0", namespace="default") def test_stop_updates_database_deployment_status(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database) calls = mock_database.update_deployment.call_args_list assert any( @@ -215,7 +239,11 @@ def test_stop_updates_database_deployment_status(self, started_analysis, mock_da ) def test_stop_updates_database_deployment_log(self, started_analysis, mock_database): - with patch("src.resources.analysis.entity.delete_deployment"): + with ( + patch("src.resources.analysis.entity.delete_deployment"), + patch("src.resources.analysis.entity.create_analysis_tokens", return_value={"KEYCLOAK_TOKEN": "tok", "DATA_SOURCE_TOKEN": "tok"}), + patch("src.resources.analysis.entity.delete_subscription"), + ): started_analysis.stop(database=mock_database, log="bye") calls = mock_database.update_deployment.call_args_list assert any( diff --git a/tests/test_resources_log_entity.py b/tests/test_resources_log_entity.py index 0807216..870448b 100644 --- a/tests/test_resources_log_entity.py +++ b/tests/test_resources_log_entity.py @@ -122,7 +122,7 @@ def test_slow_type_log_content(self): analysis_id="analysis-2", status="stuck", ) - assert "took to long during startup" in log.log + assert "took too long during startup" in log.log assert f"restart 3 of {_MAX_RESTARTS}" in log.log assert log.log_type == "error" diff --git a/tests/test_resources_utils.py b/tests/test_resources_utils.py index c159ee8..f3b0212 100644 --- a/tests/test_resources_utils.py +++ b/tests/test_resources_utils.py @@ -5,7 +5,7 @@ - get_current_namespace / create_harbor_secret - Analysis class / read_db_analysis - get_analysis_logs - - init_hub_client_and_update_hub_status_with_client + - init_hub_client_and_update_hub_status - find_k8s_resources / delete_k8s_resource - _get_all_keycloak_clients / delete_keycloak_client - update_hub_status / get_node_analysis_id @@ -68,7 +68,7 @@ def _valid_body_kwargs(self): "kong_token": "default_kong_token", } - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.Analysis") @patch("src.resources.utils.create_harbor_secret") @patch("src.resources.utils.get_current_namespace", return_value="default") @@ -90,7 +90,7 @@ def test_from_create_analysis_body( mock_hub.assert_called_once_with(self._VALID_UUID, AnalysisStatus.STARTED.value) assert result == {self._VALID_UUID: AnalysisStatus.STARTED.value} - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.Analysis") @patch("src.resources.utils.create_harbor_secret") @patch("src.resources.utils.get_current_namespace", return_value="default") @@ -289,7 +289,7 @@ def test_all_analyses(self, mock_database): # ─── stop_analysis ──────────────────────────────────────────────────────────── class TestStopAnalysis: - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.get_analysis_logs", return_value={"analysis": {}, "nginx": {}}) @patch("src.resources.utils.read_db_analysis") def test_running_analysis_stopped(self, mock_read, mock_logs, mock_hub, mock_database): @@ -305,7 +305,7 @@ def test_running_analysis_stopped(self, mock_read, mock_logs, mock_hub, mock_dat assert mock_deployment.stop.call_args.kwargs["status"] == AnalysisStatus.STARTED.value mock_hub.assert_called_once_with(_ANALYSIS_ID, AnalysisStatus.STARTED.value) - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.get_analysis_logs", return_value={"analysis": {}, "nginx": {}}) @patch("src.resources.utils.read_db_analysis") def test_executed_analysis_keeps_executed_status(self, mock_read, mock_logs, mock_hub, mock_database): @@ -320,7 +320,7 @@ def test_executed_analysis_keeps_executed_status(self, mock_read, mock_logs, moc assert call_kwargs["status"] == AnalysisStatus.EXECUTED.value mock_hub.assert_called_once_with(_ANALYSIS_ID, AnalysisStatus.EXECUTED.value) - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.get_analysis_logs", return_value={"analysis": {}, "nginx": {}}) @patch("src.resources.utils.read_db_analysis") def test_failed_analysis_keeps_failed_status(self, mock_read, mock_logs, mock_hub, mock_database): @@ -335,7 +335,7 @@ def test_failed_analysis_keeps_failed_status(self, mock_read, mock_logs, mock_hu assert call_kwargs["status"] == AnalysisStatus.FAILED.value mock_hub.assert_called_once_with(_ANALYSIS_ID, AnalysisStatus.FAILED.value) - @patch("src.resources.utils.init_hub_client_and_update_hub_status_with_client") + @patch("src.resources.utils.init_hub_client_and_update_hub_status") @patch("src.resources.utils.get_analysis_logs", return_value={"analysis": {}, "nginx": {}}) @patch("src.resources.utils.read_db_analysis") def test_all_analyses(self, mock_read, mock_logs, mock_hub, mock_database): @@ -450,7 +450,7 @@ def test_analyzes_resets_db(self, mock_cztr, mock_database): from src.resources.utils import cleanup mock_database.get_analysis_ids.return_value = ["id1", "id2"] - result = cleanup("analyzes", mock_database) + result = cleanup("analyzes", mock_database, None) mock_database.reset_db.assert_called_once() assert "analyzes" in result @@ -464,7 +464,7 @@ def test_analyzes_resets_db(self, mock_cztr, mock_database): def test_mb_reinitializes_message_broker(self, mock_find, mock_delete, mock_cztr, mock_database): from src.resources.utils import cleanup - result = cleanup("mb", mock_database) + result = cleanup("mb", mock_database, None) mock_find.assert_called_once_with( "pod", "label", "component=flame-message-broker", namespace="default" @@ -481,7 +481,7 @@ def test_mb_reinitializes_message_broker(self, mock_find, mock_delete, mock_cztr def test_rs_reinitializes_storage_service(self, mock_find, mock_delete, mock_cztr, mock_database): from src.resources.utils import cleanup - result = cleanup("rs", mock_database) + result = cleanup("rs", mock_database, None) mock_find.assert_called_once_with( "pod", "label", "component=flame-storage-service", namespace="default" @@ -502,7 +502,7 @@ def test_keycloak_deletes_orphaned_clients(self, mock_get_clients, mock_delete, {"clientId": "non_flame_client", "name": "other-client"}, ] - cleanup("keycloak", mock_database) + cleanup("keycloak", mock_database, None) # Only the orphaned flame client should be deleted; existing and non-flame skipped. mock_delete.assert_called_once_with("orphaned_analysis") @@ -511,7 +511,7 @@ def test_keycloak_deletes_orphaned_clients(self, mock_get_clients, mock_delete, def test_unknown_type_returns_error_message(self, mock_cztr, mock_database): from src.resources.utils import cleanup - result = cleanup("unknown_type", mock_database) + result = cleanup("unknown_type", mock_database, None) assert "unknown_type" in result["unknown_type"] assert "Unknown cleanup type" in result["unknown_type"] @@ -522,7 +522,7 @@ def test_unknown_type_returns_error_message(self, mock_cztr, mock_database): def test_comma_separated_processes_both_types(self, mock_find, mock_delete, mock_cztr, mock_database): from src.resources.utils import cleanup - result = cleanup("mb,rs", mock_database) + result = cleanup("mb,rs", mock_database, None) # mb calls find for message-broker, rs calls find for storage-service assert mock_find.call_count == 2 @@ -531,9 +531,9 @@ def test_comma_separated_processes_both_types(self, mock_find, mock_delete, mock def test_always_calls_clean_up_the_rest(self, mock_cztr, mock_database): from src.resources.utils import cleanup - result = cleanup("unknown_type", mock_database) + result = cleanup("unknown_type", mock_database, None) - mock_cztr.assert_called_once_with(mock_database, "default") + mock_cztr.assert_called_once_with(mock_database, None, "default") assert result["zombies"] == "zombie cleanup done" @@ -551,7 +551,7 @@ def test_deletes_zombie_resources(self, mock_find, mock_name_to_analysis, mock_d mock_database.get_analysis_ids.return_value = ["known_id"] - result = clean_up_the_rest(mock_database) + result = clean_up_the_rest(mock_database, None) # Zombie resources (not in known_analysis_ids) should be deleted. assert mock_delete.call_count > 0 @@ -568,7 +568,7 @@ def test_skips_known_resources(self, mock_find, mock_name_to_analysis, mock_dele mock_database.get_analysis_ids.return_value = ["known_id"] - clean_up_the_rest(mock_database) + clean_up_the_rest(mock_database, None) mock_delete.assert_not_called() @@ -579,7 +579,7 @@ def test_handles_none_resources(self, mock_find, mock_delete, mock_database): mock_database.get_analysis_ids.return_value = [] - result = clean_up_the_rest(mock_database) + result = clean_up_the_rest(mock_database, None) mock_delete.assert_not_called() assert isinstance(result, str) @@ -596,7 +596,7 @@ def test_wraps_string_result_in_list(self, mock_find, mock_name_to_analysis, moc mock_database.get_analysis_ids.return_value = ["known_id"] - result = clean_up_the_rest(mock_database) + result = clean_up_the_rest(mock_database, None) # The string is treated as a single resource and identified as a zombie. assert mock_delete.call_count > 0 @@ -693,4 +693,30 @@ def test_invalid_progress_skips_progress_update(self, mock_database, mock_hub_cl mock_hub_client, "node_analysis_id", run_status="executing", - ) \ No newline at end of file + ) + + def test_empty_node_analysis_id_skips_hub_update(self, mock_database, mock_hub_client): + from src.resources.utils import stream_logs + + log_entity = self._make_log_entity() + mock_database.progress_valid.return_value = True + + with patch("src.resources.utils.get_node_analysis_id", return_value=[]): + with patch("src.resources.utils.update_hub_status") as mock_hub_update: + stream_logs(log_entity, "node-id", False, mock_database, mock_hub_client) + + mock_database.update_analysis_progress.assert_not_called() + mock_hub_update.assert_not_called() + + def test_none_node_analysis_id_skips_hub_update(self, mock_database, mock_hub_client): + from src.resources.utils import stream_logs + + log_entity = self._make_log_entity() + mock_database.progress_valid.return_value = True + + with patch("src.resources.utils.get_node_analysis_id", return_value=None): + with patch("src.resources.utils.update_hub_status") as mock_hub_update: + stream_logs(log_entity, "node-id", False, mock_database, mock_hub_client) + + mock_database.update_analysis_progress.assert_not_called() + mock_hub_update.assert_not_called() \ No newline at end of file diff --git a/tests/test_status_status.py b/tests/test_status_status.py index b767d29..ff6ba17 100644 --- a/tests/test_status_status.py +++ b/tests/test_status_status.py @@ -55,11 +55,10 @@ def test_newly_ended_executing_to_failed_returns_finishing(self): # db=EXECUTING + int=FAILED (newly_ended) assert _decide_status_action(AnalysisStatus.EXECUTING.value, AnalysisStatus.FAILED.value) == "finishing" - def test_firmly_stuck_failed_db_stuck_int_returns_unstuck(self): - # db=FAILED + int=STUCK: is_stuck fires before firmly_stuck branch - # Note: firmly_stuck (db=FAILED, int=STUCK) overlaps with is_stuck, - # so this returns 'unstuck', not 'finishing'. - assert _decide_status_action(AnalysisStatus.FAILED.value, AnalysisStatus.STUCK.value) == "unstuck" + def test_firmly_stuck_failed_db_stuck_int_returns_finishing(self): + # db=FAILED + int=STUCK: is_stuck excludes FAILED (db_status not in [FAILED]), + # so firmly_stuck fires instead and returns 'finishing'. + assert _decide_status_action(AnalysisStatus.FAILED.value, AnalysisStatus.STUCK.value) == "finishing" def test_was_stopped_returns_finishing(self): # int_status=STOPPED @@ -339,7 +338,7 @@ def test_int_executing_used_when_db_not_terminal(self, mock_update, mock_hub_cli "node-analysis-id", {"db_status": AnalysisStatus.STARTED.value, "int_status": AnalysisStatus.EXECUTING.value}, ) - assert result == AnalysisStatus.EXECUTING.value + assert result == AnalysisStatus.STARTED.value @patch("src.status.status.update_hub_status") def test_int_failed_used_when_db_not_terminal(self, mock_update, mock_hub_client): @@ -348,7 +347,7 @@ def test_int_failed_used_when_db_not_terminal(self, mock_update, mock_hub_client "node-analysis-id", {"db_status": AnalysisStatus.STARTED.value, "int_status": AnalysisStatus.FAILED.value}, ) - assert result == AnalysisStatus.FAILED.value + assert result == AnalysisStatus.STARTED.value @patch("src.status.status.update_hub_status") def test_default_falls_back_to_db_status(self, mock_update, mock_hub_client): diff --git a/tests/test_utils_hub_client.py b/tests/test_utils_hub_client.py index 63f01fb..8c3a7d3 100644 --- a/tests/test_utils_hub_client.py +++ b/tests/test_utils_hub_client.py @@ -19,8 +19,8 @@ def test_success_returns_core_client(self): patch("src.utils.hub_client.flame_hub.auth.ClientAuth") as mock_auth, patch("src.utils.hub_client.flame_hub.CoreClient", return_value=mock_core_client), ): - from src.utils.hub_client import init_hub_client_with_client - result = init_hub_client_with_client( + from src.utils.hub_client import init_hub_client + result = init_hub_client( client_id="cid", client_secret="csec", hub_url_core="http://core:3000", @@ -36,8 +36,8 @@ def test_exception_returns_none(self): patch("src.utils.hub_client.get_ssl_context", return_value=MagicMock()), patch("src.utils.hub_client.Client", side_effect=Exception("conn failed")), ): - from src.utils.hub_client import init_hub_client_with_client - result = init_hub_client_with_client( + from src.utils.hub_client import init_hub_client + result = init_hub_client( client_id="cid", client_secret="csec", hub_url_core="http://core:3000", @@ -59,8 +59,8 @@ def test_with_proxies_creates_http_transports(self): patch("src.utils.hub_client.flame_hub.auth.ClientAuth"), patch("src.utils.hub_client.flame_hub.CoreClient"), ): - from src.utils.hub_client import init_hub_client_with_client - init_hub_client_with_client( + from src.utils.hub_client import init_hub_client + init_hub_client( client_id="cid", client_secret="csec", hub_url_core="http://core:3000", @@ -85,8 +85,8 @@ def test_without_proxies_passes_none_mounts(self): patch("src.utils.hub_client.flame_hub.auth.ClientAuth"), patch("src.utils.hub_client.flame_hub.CoreClient"), ): - from src.utils.hub_client import init_hub_client_with_client - init_hub_client_with_client( + from src.utils.hub_client import init_hub_client + init_hub_client( client_id="cid", client_secret="csec", hub_url_core="http://core:3000", @@ -222,15 +222,15 @@ def test_success_returns_string_id(self, mock_hub_client): filter={"analysis_id": "analysis-1", "node_id": "node-obj-id"} ) - def test_empty_list_returns_none(self, mock_hub_client): + def test_empty_list_returns_empty_list(self, mock_hub_client): mock_hub_client.find_analysis_nodes.return_value = [] from src.utils.hub_client import get_node_analysis_id result = get_node_analysis_id(mock_hub_client, "analysis-1", "node-obj-id") - assert result is None + assert result == [] - def test_http_status_error_returns_none(self, mock_hub_client): + def test_http_status_error_returns_empty_list(self, mock_hub_client): mock_hub_client.find_analysis_nodes.side_effect = HTTPStatusError( "500", request=MagicMock(), response=MagicMock() ) @@ -238,9 +238,9 @@ def test_http_status_error_returns_none(self, mock_hub_client): from src.utils.hub_client import get_node_analysis_id result = get_node_analysis_id(mock_hub_client, "analysis-1", "node-obj-id") - assert result is None + assert result == [] - def test_hub_api_error_returns_none(self, mock_hub_client): + def test_hub_api_error_returns_empty_list(self, mock_hub_client): import flame_hub mock_hub_client.find_analysis_nodes.side_effect = flame_hub._exceptions.HubAPIError( "err", request=MagicMock() @@ -249,15 +249,15 @@ def test_hub_api_error_returns_none(self, mock_hub_client): from src.utils.hub_client import get_node_analysis_id result = get_node_analysis_id(mock_hub_client, "analysis-1", "node-obj-id") - assert result is None + assert result == [] - def test_attribute_error_returns_none(self, mock_hub_client): + def test_attribute_error_returns_empty_list(self, mock_hub_client): mock_hub_client.find_analysis_nodes.side_effect = AttributeError("no attr") from src.utils.hub_client import get_node_analysis_id result = get_node_analysis_id(mock_hub_client, "analysis-1", "node-obj-id") - assert result is None + assert result == [] # ─── TestUpdateHubStatus ────────────────────────────────────────────────────── diff --git a/tests/test_utils_other.py b/tests/test_utils_other.py index 5ab5389..f04f6e0 100644 --- a/tests/test_utils_other.py +++ b/tests/test_utils_other.py @@ -88,7 +88,7 @@ def test_single_split(self): def test_double_split(self): from src.utils.other import resource_name_to_analysis # analysis_id itself contains a hyphen - result = resource_name_to_analysis("analysis-abc-123-0", max_r_split=1) + result = resource_name_to_analysis("analysis-abc-123-0") assert result == "abc-123" def test_nginx_prefix(self): @@ -97,14 +97,14 @@ def test_nginx_prefix(self): result = resource_name_to_analysis("nginx-analysis-abc123-0") assert result == "abc123" - def test_max_r_split_two(self): + def test_extra_trailing_segment(self): from src.utils.other import resource_name_to_analysis - # with max_r_split=2 strips two trailing segments - result = resource_name_to_analysis("analysis-abc123-pod-0", max_r_split=2) - assert result == "abc123" + # extra segment between analysis_id and restart_counter — last segment stripped only + result = resource_name_to_analysis("analysis-abc123-pod-0") + assert result == "abc123-pod" def test_no_restart_counter(self): from src.utils.other import resource_name_to_analysis - # edge case: no trailing hyphen — rsplit finds nothing to strip, returns full segment + # edge case: no trailing segment to strip — returns empty string result = resource_name_to_analysis("analysis-abc123") - assert result == "abc123" + assert result == ""