From 015eb2bda8579c86c97c1596d689fa9045ebb19e Mon Sep 17 00:00:00 2001 From: jmintb Date: Wed, 2 Aug 2023 14:26:52 +0200 Subject: [PATCH] Migrate to new Task and controller architecture Issue: #136 Before the inital release we are consolidting the controller and crd architectures. This commit also polishes the CLI. --- Cargo.lock | 1095 +++++----- Cargo.toml | 2 +- Dockerfile.controller | 2 +- Dockerfile.server | 2 +- README.md | 2 +- {docs => ame/docs}/datasets.md | 0 ame/docs/index.md | 17 + ame/docs/model_validation.md | 172 ++ ame/docs/models.html | 30 + ame/docs/models.md | 42 + {docs => ame/docs}/project_sources.md | 4 +- ame/docs/tasks.md | 681 +++++++ ame/mkdocs.yml | 32 + cli/Cargo.toml | 62 +- cli/src/lib.rs | 12 +- cli/src/main.rs | 41 +- cli/src/project.rs | 254 ++- cli/src/project_cmd.rs | 113 + cli/src/projectsrc.rs | 13 +- cli/src/secrets.rs | 12 +- cli/src/task.rs | 458 +++++ cli/tests/cli.rs | 382 ++-- cli/tests/snapshots/cli__ame_run_task-2.snap | 5 +- cli/tests/snapshots/cli__ame_run_task.snap | 25 +- .../cli__invalid_projecttasklogs.snap | 11 + .../cli__invalid_projecttaskrun.snap | 9 + .../cli__missing_projecttaskrun.snap | 9 + .../snapshots/cli__snap_shot_task_view.snap | 13 + ...__snap_shot_test_cli_error_messages-2.snap | 9 + ...__snap_shot_test_cli_error_messages-3.snap | 11 + ...li__snap_shot_test_cli_error_messages.snap | 9 + controller/Cargo.toml | 30 +- controller/src/crdgen.rs | 2 +- controller/src/data_set.rs | 98 +- controller/src/lib.rs | 2 + controller/src/main.rs | 49 +- controller/src/project.rs | 525 +++++ ...can_create_workflow_and_finalize_task.snap | 114 ++ controller/src/task.rs | 443 ++++ docs/model_validation.md | 115 -- executor/Dockerfile | 16 +- justfile | 35 +- lib/Cargo.toml | 70 +- lib/ame.proto | 190 +- lib/build.rs | 58 +- lib/src/ctrl.rs | 41 +- lib/src/custom_resources/argo.rs | 256 ++- lib/src/custom_resources/common.rs | 200 +- lib/src/custom_resources/data_set.rs | 68 +- lib/src/custom_resources/mod.rs | 125 +- lib/src/custom_resources/new_task.rs | 507 +++++ lib/src/custom_resources/project.rs | 940 +++------ lib/src/custom_resources/project_source.rs | 157 +- .../custom_resources/project_source_ctrl.rs | 22 +- lib/src/custom_resources/secrets.rs | 28 +- ...__new_task__test__snap_shot_task_yaml.snap | 33 + ...w_task__test__snap_shot_workflow_yaml.snap | 190 ++ ...ject__test__produces_valid_deployment.snap | 4 +- ...project__test__produces_valid_ingress.snap | 11 +- ...t__produces_valid_model_training_task.snap | 30 + ...t_projects_from_public_git_repository.snap | 10 +- lib/src/custom_resources/task.rs | 1465 ------------- lib/src/custom_resources/task_ctrl.rs | 118 ++ lib/src/error.rs | 48 + lib/src/lib.rs | 143 +- lib/src/project.rs | 32 + lib/tests/e2e_test.rs | 555 +++++ .../snapshots/e2e_test__ame_run_task.snap | 38 + .../e2e_test__can_list_project_srcs.snap | 8 + .../controller/base/resources/controller.yaml | 4 +- manifests/crd.yaml | 358 ++-- manifests/data_set_crd.yaml | 140 +- manifests/project_crd.yaml | 1813 +++++------------ manifests/project_src_crd.yaml | 8 +- rustfmt.toml | 1 + service/Cargo.toml | 21 +- service/src/ameservice.rs | 350 +++- service/src/lib.rs | 27 +- service/src/server.rs | 218 +- shell.nix | 4 +- test_data/test_projects/env/ame.yaml | 32 +- .../test_projects/executors/custom/ame.yaml | 10 + .../test_projects/executors/custom/nn.py | 1 + .../test_projects/executors/mlflow/MLProject | 8 + .../test_projects/executors/mlflow/Pipfile | 12 + .../executors/mlflow/Pipfile.lock | 52 + .../test_projects/executors/mlflow/ame.yaml | 25 + .../test_projects/executors/mlflow/nn.py | 3 + .../executors/mlflow/python_env.yaml | 9 + .../test_projects/executors/pip/ame.yaml | 10 + test_data/test_projects/executors/pip/nn.py | 3 + .../executors/pip/requirements.txt | 2 + .../test_projects/executors/pipenv/Pipfile | 12 + .../executors/pipenv/Pipfile.lock | 52 + .../test_projects/executors/pipenv/ame.yaml | 9 + .../test_projects/executors/pipenv/nn.py | 3 + .../test_projects/executors/poetry/ame.yaml | 17 + .../test_projects/executors/poetry/nn.py | 3 + .../executors/poetry/poetry.lock | 44 + .../executors/poetry/pyproject.toml | 15 + .../test_projects/invalid_project/ame.yaml | 17 + test_data/test_projects/invalid_project/nn.py | 3 + .../test_projects/invalid_project/poetry.lock | 44 + .../invalid_project/pyproject.toml | 15 + test_data/test_projects/missing_project/nn.py | 3 + .../test_projects/missing_project/poetry.lock | 44 + .../missing_project/pyproject.toml | 15 + test_data/test_projects/new_echo/ame.yaml | 5 +- .../test_projects/pipenvnn/forreadme2.cast | 309 --- test_data/test_projects/pipenvnn/nn.cast | 218 -- test_data/test_projects/poetry/nn.py | 96 +- .../sklearn_logistic_regression/ame.yaml | 3 +- .../mlruns/0/meta.yaml | 4 + web/src/app.rs | 23 +- web/src/main.rs | 3 +- 115 files changed, 8392 insertions(+), 5981 deletions(-) rename {docs => ame/docs}/datasets.md (100%) create mode 100644 ame/docs/index.md create mode 100644 ame/docs/model_validation.md create mode 100644 ame/docs/models.html create mode 100644 ame/docs/models.md rename {docs => ame/docs}/project_sources.md (94%) create mode 100644 ame/docs/tasks.md create mode 100644 ame/mkdocs.yml create mode 100644 cli/src/project_cmd.rs create mode 100644 cli/src/task.rs create mode 100644 cli/tests/snapshots/cli__invalid_projecttasklogs.snap create mode 100644 cli/tests/snapshots/cli__invalid_projecttaskrun.snap create mode 100644 cli/tests/snapshots/cli__missing_projecttaskrun.snap create mode 100644 cli/tests/snapshots/cli__snap_shot_task_view.snap create mode 100644 cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-2.snap create mode 100644 cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-3.snap create mode 100644 cli/tests/snapshots/cli__snap_shot_test_cli_error_messages.snap create mode 100644 controller/src/project.rs create mode 100644 controller/src/snapshots/controller__task__test__can_create_workflow_and_finalize_task.snap create mode 100644 controller/src/task.rs delete mode 100644 docs/model_validation.md create mode 100644 lib/src/custom_resources/new_task.rs create mode 100644 lib/src/custom_resources/snapshots/ame__custom_resources__new_task__test__snap_shot_task_yaml.snap create mode 100644 lib/src/custom_resources/snapshots/ame__custom_resources__new_task__test__snap_shot_workflow_yaml.snap create mode 100644 lib/src/custom_resources/snapshots/ame__custom_resources__project__test__produces_valid_model_training_task.snap delete mode 100644 lib/src/custom_resources/task.rs create mode 100644 lib/src/custom_resources/task_ctrl.rs create mode 100644 lib/src/project.rs create mode 100644 lib/tests/e2e_test.rs create mode 100644 lib/tests/snapshots/e2e_test__ame_run_task.snap create mode 100644 lib/tests/snapshots/e2e_test__can_list_project_srcs.snap create mode 100644 rustfmt.toml create mode 100755 test_data/test_projects/executors/custom/ame.yaml create mode 100644 test_data/test_projects/executors/custom/nn.py create mode 100644 test_data/test_projects/executors/mlflow/MLProject create mode 100644 test_data/test_projects/executors/mlflow/Pipfile create mode 100644 test_data/test_projects/executors/mlflow/Pipfile.lock create mode 100755 test_data/test_projects/executors/mlflow/ame.yaml create mode 100644 test_data/test_projects/executors/mlflow/nn.py create mode 100644 test_data/test_projects/executors/mlflow/python_env.yaml create mode 100755 test_data/test_projects/executors/pip/ame.yaml create mode 100644 test_data/test_projects/executors/pip/nn.py create mode 100644 test_data/test_projects/executors/pip/requirements.txt create mode 100644 test_data/test_projects/executors/pipenv/Pipfile create mode 100644 test_data/test_projects/executors/pipenv/Pipfile.lock create mode 100755 test_data/test_projects/executors/pipenv/ame.yaml create mode 100644 test_data/test_projects/executors/pipenv/nn.py create mode 100755 test_data/test_projects/executors/poetry/ame.yaml create mode 100644 test_data/test_projects/executors/poetry/nn.py create mode 100644 test_data/test_projects/executors/poetry/poetry.lock create mode 100644 test_data/test_projects/executors/poetry/pyproject.toml create mode 100755 test_data/test_projects/invalid_project/ame.yaml create mode 100644 test_data/test_projects/invalid_project/nn.py create mode 100644 test_data/test_projects/invalid_project/poetry.lock create mode 100644 test_data/test_projects/invalid_project/pyproject.toml create mode 100644 test_data/test_projects/missing_project/nn.py create mode 100644 test_data/test_projects/missing_project/poetry.lock create mode 100644 test_data/test_projects/missing_project/pyproject.toml delete mode 100644 test_data/test_projects/pipenvnn/forreadme2.cast delete mode 100644 test_data/test_projects/pipenvnn/nn.cast create mode 100644 test_data/test_projects/sklearn_logistic_regression/mlruns/0/meta.yaml diff --git a/Cargo.lock b/Cargo.lock index 8d919962..ca7aebb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + [[package]] name = "adler" version = "1.0.2" @@ -33,18 +42,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - -[[package]] -name = "aho-corasick" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" dependencies = [ "memchr", ] @@ -68,13 +68,19 @@ dependencies = [ name = "ame" version = "0.1.0" dependencies = [ + "ame", + "anyhow", + "assert_cmd", "assert_fs", "async-stream", "async-trait", "cfg-if", + "chrono", + "cron-parser", "duration-string", "either", "envconfig", + "fs_extra", "futures 0.3.28", "futures-util", "git2", @@ -82,7 +88,7 @@ dependencies = [ "http-body", "humantime", "hyper", - "hyper-rustls", + "hyper-rustls 0.23.2", "insta", "k8s-openapi", "kube", @@ -92,30 +98,40 @@ dependencies = [ "openidconnect", "predicates 2.1.5", "prost", + "prost-build", + "rand", "reqwest", + "rstest", "rustls-native-certs", "schemars", "serde", "serde_json", "serde_merge", "serde_tuple", - "serde_yaml 0.9.21", - "serial_test", + "serde_yaml 0.9.25", + "serial_test 2.0.0", "similar", - "similar-asserts", "thiserror", + "time 0.3.24", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", "tokio-stream", "tonic", "tonic-build", "tonic-web-wasm-client", "tower", - "tower-http 0.4.0", + "tower-http 0.4.3", "tracing", + "tracing-subscriber", "url", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -127,9 +143,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e579a7752471abc2a8268df8b20005e3eadd975f585398f17efcfd8d4927371" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" dependencies = [ "anstyle", "anstyle-parse", @@ -142,15 +158,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" dependencies = [ "utf8parse", ] @@ -166,9 +182,9 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcd8291a340dd8ac70e18878bc4501dd7b4ff970cfa21c207d36ece51ea88fd" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" dependencies = [ "anstyle", "windows-sys 0.48.0", @@ -176,18 +192,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.70" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" [[package]] name = "assert_cmd" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d6b683edf8d1119fe420a94f8a7e389239666aa72e65495d91c00462510151" +checksum = "88903cb14723e4d4003335bb7f8a14f27691649105346a0f0957466c096adfe6" dependencies = [ "anstyle", - "bstr 1.4.0", + "bstr 1.6.0", "doc-comment", "predicates 3.0.3", "predicates-core", @@ -232,7 +248,7 @@ checksum = "0e97ce7de6cf12de5d7226c73f5ba9811622f4db3a5b91b55c53e987e5f91cba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -254,18 +270,18 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] name = "async-trait" -version = "0.1.68" +version = "0.1.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -341,7 +357,7 @@ dependencies = [ "serde", "serde-xml-rs", "thiserror", - "time", + "time 0.3.24", "url", ] @@ -356,14 +372,14 @@ dependencies = [ [[package]] name = "axum" -version = "0.6.16" +version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113713495a32dd0ab52baf5c10044725aa3aec00b31beda84218e469029b72a3" +checksum = "a6a1de45611fdb535bfde7b7de4fd54f4fd2b17b1737c0a59b69bf9b92074b8c" dependencies = [ "async-trait", "axum-core", "axum-macros", - "bitflags", + "bitflags 1.3.2", "bytes 1.4.0", "futures-util", "http", @@ -406,14 +422,14 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bb524613be645939e280b7279f7b017f98cf7f5ef084ec374df373530e73277" +checksum = "cdca6a10ecad987bda04e95606ef85a5417dcaac1a78455242d72e031e2b6b62" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -427,6 +443,21 @@ dependencies = [ "rand", ] +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "base64" version = "0.13.1" @@ -441,9 +472,9 @@ checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" [[package]] name = "base64" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" [[package]] name = "bitflags" @@ -451,6 +482,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "block-buffer" version = "0.10.4" @@ -489,26 +526,25 @@ checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" dependencies = [ "lazy_static", "memchr", - "regex-automata", + "regex-automata 0.1.10", ] [[package]] name = "bstr" -version = "1.4.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09" +checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05" dependencies = [ "memchr", - "once_cell", - "regex-automata", + "regex-automata 0.3.4", "serde", ] [[package]] name = "bumpalo" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" [[package]] name = "byteorder" @@ -534,9 +570,9 @@ checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" [[package]] name = "camino" -version = "1.1.4" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c530edf18f37068ac2d977409ed5cd50d53d73bc653c7647b48eb78976ac9ae2" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" [[package]] name = "cc" @@ -555,22 +591,25 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.24" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ + "android-tzdata", "iana-time-zone", - "num-integer", + "js-sys", "num-traits", "serde", + "time 0.1.45", + "wasm-bindgen", "winapi", ] [[package]] name = "ciborium" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c137568cc60b904a7724001b35ce2630fd00d5d84805fbb608ab89509d788f" +checksum = "effd91f6c78e5a4ace8a5d3c0b6bfaec9e2baaef55f3efc00e45fb2e477ee926" dependencies = [ "ciborium-io", "ciborium-ll", @@ -579,15 +618,15 @@ dependencies = [ [[package]] name = "ciborium-io" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346de753af073cc87b52b2083a506b38ac176a44cfb05497b622e27be899b369" +checksum = "cdf919175532b369853f5d5e20b26b43112613fd6fe7aee757e35f7a44642656" [[package]] name = "ciborium-ll" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213030a2b5a4e0c0892b6652260cf6ccac84827b83a85a534e178e3906c4cf1b" +checksum = "defaa24ecc093c77630e6c15e17c51f5e187bf35ee514f4e2d67baaa96dae22b" dependencies = [ "ciborium-io", "half", @@ -595,9 +634,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.2.4" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956ac1f6381d8d82ab4684768f89c0ea3afe66925ceadb4eeb3fc452ffc55d62" +checksum = "5fd304a20bff958a57f04c4e96a2e7594cc4490a0e809cbd48bb6437edaa452d" dependencies = [ "clap_builder", "clap_derive", @@ -606,43 +645,44 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.2.4" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84080e799e54cff944f4b4a4b0e71630b0e0443b25b985175c7dddc1a859b749" +checksum = "01c6a3f08f1fe5662a35cfe393aec09c4df95f60ee93b7556505260f75eee9e1" dependencies = [ "anstream", "anstyle", - "bitflags", "clap_lex", "strsim", ] [[package]] name = "clap_derive" -version = "4.2.0" +version = "4.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9644cd56d6b87dbe899ef8b053e331c0637664e9e21a33dfcdc36093f5c5c4" +checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] name = "clap_lex" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" [[package]] name = "cli" version = "0.1.0" dependencies = [ "ame", + "anyhow", "assert_cmd", "assert_fs", "async-stream", + "atty", "bytes 1.4.0", "clap", "colored", @@ -655,7 +695,7 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", + "hyper-rustls 0.23.2", "insta", "k8s-openapi", "kube", @@ -667,14 +707,14 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", - "serde_yaml 0.9.21", - "serial_test", + "serde_yaml 0.9.25", + "serial_test 0.9.0", "similar-asserts", "spinners", "temp-env", "thiserror", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", "tonic", "tower", "tower-http 0.3.5", @@ -683,16 +723,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - [[package]] name = "collection_literals" version = "1.0.1" @@ -707,13 +737,13 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "colored" -version = "2.0.0" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" dependencies = [ - "atty", + "is-terminal", "lazy_static", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -755,15 +785,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.5" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d79fbe8970a77e3e34151cc13d3b3e248aa0faaecb9f6091fa07ebefe5ad60" +checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" dependencies = [ "encode_unicode", "lazy_static", "libc", "unicode-width", - "windows-sys 0.42.0", + "windows-sys 0.45.0", ] [[package]] @@ -788,18 +818,18 @@ dependencies = [ [[package]] name = "const_format" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7309d9b4d3d2c0641e018d449232f2e28f1b22933c137f157d3dbc14228b8c0e" +checksum = "c990efc7a285731f9a4378d81aff2f0e85a2c8781a05ef0f8baa8dac54d0ff48" dependencies = [ "const_format_proc_macros", ] [[package]] name = "const_format_proc_macros" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f47bf7270cf70d370f8f98c1abb6d2d4cf60a6845d30e05bfb90c6568650" +checksum = "e026b6ce194a874cb9cf32cd5772d1ef9767cc8fcb5765948d74f37a9d8b2bf6" dependencies = [ "proc-macro2", "quote", @@ -812,19 +842,23 @@ version = "0.1.0" dependencies = [ "ame", "async-trait", + "chrono", + "cron-parser", "duration-string", "either", "envconfig", "futures 0.3.28", "git2", "humantime", + "insta", "k8s-openapi", "kube", "reqwest", "schemars", "serde", "serde_json", - "serde_yaml 0.9.21", + "serde_merge", + "serde_yaml 0.9.25", "thiserror", "tokio", "tonic", @@ -859,9 +893,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.7" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" dependencies = [ "libc", ] @@ -876,57 +910,22 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cxx" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61f1b6389c3fe1c316bf8a4dccc90a38208354b330925bce1f74a6c4756eb93" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.94" +name = "cron-parser" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cee708e8962df2aeb38f594aae5d827c022b6460ac71a7a3e2c3c2aae5a07b" +checksum = "5894f6023d8f0125d49d24a10f07dacf1ae6838ff1878ac50d0ff0db17c82a83" dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.15", + "chrono", ] [[package]] -name = "cxxbridge-flags" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7944172ae7e4068c533afbb984114a56c46e9ccddda550499caa222902c7f7bb" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.94" +name = "crypto-common" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.15", + "generic-array", + "typenum", ] [[package]] @@ -1001,17 +1000,26 @@ dependencies = [ [[package]] name = "dashmap" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +checksum = "6943ae99c34386c84a470c499d3414f66502a41340aa895406e0d2e4a207b91d" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.0", "lock_api", "once_cell", "parking_lot_core", ] +[[package]] +name = "deranged" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8810e7e2cf385b1e9b50d68264908ec367ba642c96d02edfe61c39e88e2a3c01" +dependencies = [ + "serde", +] + [[package]] name = "derivative" version = "2.2.0" @@ -1031,7 +1039,7 @@ checksum = "5bc1955a640c4464859ae700fbe48e666da6fdce99ce5fe1acd08dd295889d10" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -1041,6 +1049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87" dependencies = [ "console", + "fuzzy-matcher", "shell-words", "tempfile", "zeroize", @@ -1054,9 +1063,9 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", @@ -1139,15 +1148,15 @@ checksum = "c5d74010fd35f37054de1eb72485be4f562b0815e0e7b477e578581d3998fa47" [[package]] name = "dyn-clone" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b0cf012f1230e43cd00ebb729c6bb58707ecfa8ad08b52ef3a4ccd2697fc30" +checksum = "304e6508efa593091e97a9abbc10f90aa7ca635b6d2784feff3c89d41dd12272" [[package]] name = "educe" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4af7804abe0786a9b69375115821fedc9995f21ab63ae285184b96b01ec50b1a" +checksum = "079044df30bb07de7d846d41a184c4b00e66ebdac93ee459253474f3a47e50ae" dependencies = [ "enum-ordinalize", "proc-macro2", @@ -1157,9 +1166,9 @@ dependencies = [ [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encode_unicode" @@ -1178,16 +1187,15 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "3.1.12" +version = "3.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bb1df8b45ecb7ffa78dca1c17a438fb193eb083db0b1b494d2a61bcb5096a" +checksum = "e4f76552f53cefc9a7f64987c3701b99d982f7690606fd67de1d09712fbf52f1" dependencies = [ "num-bigint", "num-traits", "proc-macro2", "quote", - "rustc_version", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] @@ -1210,11 +1218,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" dependencies = [ "errno-dragonfly", "libc", @@ -1233,12 +1247,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "1.9.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" [[package]] name = "fixedbitset" @@ -1248,9 +1259,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" dependencies = [ "crc32fast", "miniz_oxide", @@ -1288,9 +1299,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" dependencies = [ "percent-encoding", ] @@ -1384,7 +1395,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -1423,6 +1434,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1435,24 +1455,30 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + [[package]] name = "git2" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7905cdfe33d31a88bb2e8419ddd054451f5432d1da9eaf2ac7804ee1ea12d5" +checksum = "7b989d6a7ca95a362cf2cfc5ad688b3a467be1f87e480b8dad07fee8c79b0044" dependencies = [ - "bitflags", + "bitflags 1.3.2", "libc", "libgit2-sys", "log", @@ -1463,12 +1489,12 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" +checksum = "aca8bbd8e0707c1887a8bbb7e6b40e228f251ff5d62c8220a4a7a53c73aff006" dependencies = [ - "aho-corasick 0.7.20", - "bstr 1.4.0", + "aho-corasick", + "bstr 1.6.0", "fnv", "log", "regex", @@ -1480,7 +1506,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" dependencies = [ - "bitflags", + "bitflags 1.3.2", "ignore", "walkdir", ] @@ -1507,9 +1533,9 @@ dependencies = [ [[package]] name = "gloo-utils" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8fc851e9c7b9852508bc6e3f690f452f474417e8545ec9857b7f7377036b5" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" dependencies = [ "js-sys", "serde", @@ -1520,9 +1546,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.18" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f8a914c2987b688368b5138aa05321db91f4090cf26118185672ad588bce21" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" dependencies = [ "bytes 1.4.0", "fnv", @@ -1530,7 +1556,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 1.9.3", "slab", "tokio", "tokio-util", @@ -1552,6 +1578,12 @@ dependencies = [ "ahash 0.7.6", ] +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + [[package]] name = "heck" version = "0.4.1" @@ -1569,18 +1601,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - -[[package]] -name = "hermit-abi" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" [[package]] name = "hex" @@ -1630,9 +1653,9 @@ dependencies = [ [[package]] name = "http-range-header" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" [[package]] name = "httparse" @@ -1654,9 +1677,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.26" +version = "0.14.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" dependencies = [ "bytes 1.4.0", "futures-channel", @@ -1703,10 +1726,24 @@ dependencies = [ "http", "hyper", "log", - "rustls", + "rustls 0.20.8", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls 0.21.5", + "tokio", + "tokio-rustls 0.24.1", ] [[package]] @@ -1736,9 +1773,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.56" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1750,12 +1787,11 @@ dependencies = [ [[package]] name = "iana-time-zone-haiku" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "cxx", - "cxx-build", + "cc", ] [[package]] @@ -1766,9 +1802,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1798,14 +1834,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", ] [[package]] name = "insta" -version = "1.29.0" +version = "1.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a28d25139df397cbca21408bb742cf6837e04cdbebf1b07b760caf971d6a972" +checksum = "a0770b0a3d4c70567f0d58331f3088b0e4c4f56c9b8d764efe654b4a5d46de3a" dependencies = [ "console", "lazy_static", @@ -1833,17 +1879,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1482d8bbb520daf94c82af87f38cd27cdb3073c6fee7c5805fd2fa9d3a36d494" -[[package]] -name = "io-lifetimes" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" -dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "iovec" version = "0.1.4" @@ -1855,9 +1890,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.7.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" +checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" [[package]] name = "is-docker" @@ -1870,12 +1905,11 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", + "hermit-abi 0.3.2", "rustix", "windows-sys 0.48.0", ] @@ -1901,9 +1935,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "jobserver" @@ -1916,9 +1950,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ "wasm-bindgen", ] @@ -2146,7 +2180,7 @@ dependencies = [ "educe", "futures 0.3.28", "html-escape", - "indexmap", + "indexmap 1.9.3", "itertools", "js-sys", "leptos_reactive", @@ -2170,7 +2204,7 @@ checksum = "d85b0935d1439780ddabc4809dd2ae93a1825e9d05b95c9812942b3fc858ff9e" dependencies = [ "anyhow", "camino", - "indexmap", + "indexmap 1.9.3", "parking_lot", "proc-macro2", "quote", @@ -2234,10 +2268,10 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e702730c3ac1f8a619bf2b139e650b792c96c66f2285a88a1c92c1a53ed262" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "cfg-if", "futures 0.3.28", - "indexmap", + "indexmap 1.9.3", "js-sys", "rustc-hash", "serde", @@ -2293,15 +2327,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.142" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "libgit2-sys" -version = "0.15.1+1.6.4" +version = "0.15.2+1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb4577bde8cdfc7d6a2a4bcb7b049598597de33ffd337276e9c7db6cd4a2cee7" +checksum = "a80df2e11fb4a61f4ba2ab42dbe7f74468da143f1a75c74e11dee7c813f694fa" dependencies = [ "cc", "libc", @@ -2327,9 +2361,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.8" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" dependencies = [ "cc", "libc", @@ -2343,15 +2377,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2369,15 +2394,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.3.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b085a4f2cde5781fc4b1717f2e86c62f5cda49de7ba99a7c2eae02b61c9064c" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" [[package]] name = "lock_api" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" dependencies = [ "autocfg", "scopeguard", @@ -2385,12 +2410,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" [[package]] name = "maplit" @@ -2404,14 +2426,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", + "regex-automata 0.1.10", ] [[package]] name = "matchit" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +checksum = "67827e6ea8ee8a7c4a72227ef4fc08957040acffdb5f122733b24fa12daff41b" [[package]] name = "maybe-async" @@ -2454,9 +2476,9 @@ dependencies = [ [[package]] name = "minidom" -version = "0.15.1" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e9ce45d459e358790a285e7609ff5ae4cfab88b75f237e8838e62029dda397b" +checksum = "f45614075738ce1b77a1768912a60c0227525971b03e09122a05b8a34a2a6278" dependencies = [ "rxml", ] @@ -2469,23 +2491,22 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "log", - "wasi", - "windows-sys 0.45.0", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", ] [[package]] @@ -2561,20 +2582,20 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", ] [[package]] name = "num_cpus" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi 0.3.2", "libc", ] @@ -2589,9 +2610,9 @@ dependencies = [ [[package]] name = "oauth2" -version = "4.3.0" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeaf26a72311c087f8c5ba617c96fac67a5c04f430e716ac8d8ab2de62e23368" +checksum = "09a6e2a2b13a56ebeabba9142f911745be6456163fd6c3d361274ebcd891a80c" dependencies = [ "base64 0.13.1", "chrono", @@ -2607,19 +2628,29 @@ dependencies = [ "url", ] +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" -version = "1.17.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "open" -version = "4.0.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873240a4a404d44c8cd1bf394359245d466a5695771fea15a79cafbc5e5cf4d7" +checksum = "3a083c0c7e5e4a8ec4176346cf61f67ac674e8bfb059d9226e1c54a96b377c12" dependencies = [ "is-wsl", + "libc", "pathdiff", ] @@ -2652,11 +2683,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.51" +version = "0.10.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ea2d98598bf9ada7ea6ee8a30fb74f9156b63bbe495d64ec2b87c269d2dda3" +checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if", "foreign-types", "libc", @@ -2673,7 +2704,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -2684,9 +2715,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.86" +version = "0.9.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "992bac49bdbab4423199c654a5515bd2a6c6a23bf03f2dd3bdb7e5ae6259bc69" +checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" dependencies = [ "cc", "libc", @@ -2710,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" dependencies = [ "dlv-list", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -2737,22 +2768,22 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", + "redox_syscall 0.3.5", "smallvec", - "windows-sys 0.45.0", + "windows-targets 0.48.1", ] [[package]] name = "paste" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" [[package]] name = "pathdiff" @@ -2771,15 +2802,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.5.7" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1403e8401ad5dedea73c626b99758535b342502f8d1e361f4a2dd952749122" +checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" dependencies = [ "thiserror", "ucd-trie", @@ -2787,9 +2818,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.5.7" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be99c4c1d2fc2769b1d00239431d711d08f6efedcecb8b6e30707160aee99c15" +checksum = "666d00490d4ac815001da55838c500eafb0320019bbaa44444137c48b443a853" dependencies = [ "pest", "pest_generator", @@ -2797,22 +2828,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.5.7" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56094789873daa36164de2e822b3888c6ae4b4f9da555a1103587658c805b1e" +checksum = "68ca01446f50dbda87c1786af8770d535423fa8a53aec03b8f4e3d7eb10e0929" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] name = "pest_meta" -version = "2.5.7" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6733073c7cff3d8459fda0e42f13a047870242aed8b509fe98000928975f359e" +checksum = "56af0a30af74d0445c0bf6d9d051c979b516a1a5af790d251daee76005420a48" dependencies = [ "once_cell", "pest", @@ -2826,34 +2857,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 1.9.3", ] [[package]] name = "pin-project" -version = "1.0.12" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.12" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" [[package]] name = "pin-utils" @@ -2863,9 +2894,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" [[package]] name = "ppv-lite86" @@ -2961,9 +2992,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" dependencies = [ "unicode-ident", ] @@ -3024,9 +3055,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.26" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" dependencies = [ "proc-macro2", ] @@ -3080,7 +3111,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -3089,7 +3120,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -3105,13 +3136,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6cf59af1067a3fb53fbe5c88c053764e930f932be1d71d3ffe032cbe147f59" +checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" dependencies = [ - "aho-corasick 1.0.1", + "aho-corasick", "memchr", - "regex-syntax 0.7.0", + "regex-automata 0.3.4", + "regex-syntax 0.7.4", ] [[package]] @@ -3123,6 +3155,17 @@ dependencies = [ "regex-syntax 0.6.29", ] +[[package]] +name = "regex-automata" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.4", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -3131,17 +3174,17 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6868896879ba532248f33598de5181522d8b3d9d724dfd230911e1a7d4822f5" +checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" [[package]] name = "reqwest" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b71749df584b7f4cac2c426c127a7c785a5106cc98f7a8feb044115f0fa254" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "bytes 1.4.0", "encoding_rs", "futures-core", @@ -3150,7 +3193,7 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", + "hyper-rustls 0.24.1", "hyper-tls", "ipnet", "js-sys", @@ -3160,14 +3203,14 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.21.5", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -3201,7 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" dependencies = [ "base64 0.13.1", - "bitflags", + "bitflags 1.3.2", "serde", ] @@ -3266,12 +3309,18 @@ dependencies = [ "serde_derive", "sha2", "thiserror", - "time", + "time 0.3.24", "tokio", "tokio-stream", "url", ] +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3289,13 +3338,12 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.13" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79bef90eb6d984c72722595b5b1348ab39275a5e5123faca6863bf07d75a4e0" +checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" dependencies = [ - "bitflags", + "bitflags 2.3.3", "errno", - "io-lifetimes", "libc", "linux-raw-sys", "windows-sys 0.48.0", @@ -3313,11 +3361,23 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + [[package]] name = "rustls-native-certs" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", "rustls-pemfile", @@ -3327,43 +3387,51 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "1.0.2" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +dependencies = [ + "base64 0.21.2", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +checksum = "513722fd73ad80a71f72b61009ea1b584bcfa1483ca93949c8f290298837fa59" dependencies = [ - "base64 0.21.0", + "ring", + "untrusted", ] [[package]] name = "rustversion" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "rxml" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a071866b8c681dc2cfffa77184adc32b57b0caad4e620b6292609703bceb804" +checksum = "a98f186c7a2f3abbffb802984b7f1dfd65dac8be1aafdaabbca4137f53f0dff7" dependencies = [ "bytes 1.4.0", - "pin-project-lite", "rxml_validation", "smartstring", - "tokio", ] [[package]] name = "rxml_validation" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53bc79743f9a66c2fb1f951cd83735f275d46bfe466259fbc5897bb60a0d00ee" +checksum = "22a197350ece202f19a166d1ad6d9d6de145e1d2a8ef47db299abe164dbd7530" [[package]] name = "ryu" -version = "1.0.13" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" [[package]] name = "same-file" @@ -3376,11 +3444,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" dependencies = [ - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -3409,15 +3477,9 @@ dependencies = [ [[package]] name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "scratch" -version = "1.0.5" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sct" @@ -3441,11 +3503,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.8.2" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -3454,9 +3516,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.8.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys", "libc", @@ -3464,15 +3526,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" [[package]] name = "serde" -version = "1.0.160" +version = "1.0.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" +checksum = "0ea67f183f058fe88a4e3ec6e2788e003840893b91bac4559cabedd00863b3ed" dependencies = [ "serde_derive", ] @@ -3512,13 +3574,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.160" +version = "1.0.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" +checksum = "24e744d7782b686ab3b73267ef05697159cc0e5abbed3f47f9933165e5219036" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -3534,11 +3596,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c" dependencies = [ - "indexmap", + "indexmap 2.0.0", "itoa", "ryu", "serde", @@ -3557,10 +3619,11 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" dependencies = [ + "itoa", "serde", ] @@ -3634,7 +3697,7 @@ version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ - "indexmap", + "indexmap 1.9.3", "ryu", "serde", "yaml-rust", @@ -3642,11 +3705,11 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.21" +version = "0.9.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c" +checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574" dependencies = [ - "indexmap", + "indexmap 2.0.0", "itoa", "ryu", "serde", @@ -3664,7 +3727,21 @@ dependencies = [ "lazy_static", "log", "parking_lot", - "serial_test_derive", + "serial_test_derive 0.9.0", +] + +[[package]] +name = "serial_test" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +dependencies = [ + "dashmap", + "futures 0.3.28", + "lazy_static", + "log", + "parking_lot", + "serial_test_derive 2.0.0", ] [[package]] @@ -3679,6 +3756,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "serial_test_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + [[package]] name = "server_fn" version = "0.2.5" @@ -3733,6 +3821,7 @@ version = "0.1.0" dependencies = [ "ame", "async-trait", + "either", "envconfig", "futures-core", "futures-util", @@ -3745,7 +3834,7 @@ dependencies = [ "rust-s3", "serde", "serde_json", - "serial_test", + "serial_test 0.9.0", "thiserror", "tokio", "tokio-stream", @@ -3763,9 +3852,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" dependencies = [ "cfg-if", "cpufeatures", @@ -3818,14 +3907,13 @@ dependencies = [ [[package]] name = "simple_logger" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78beb34673091ccf96a8816fce8bfd30d1292c7621ca2bcb5f2ba0fae4f558d" +checksum = "2230cd5c29b815c9b699fb610b49a5ed65588f3509d9f0108be3a885da629333" dependencies = [ - "atty", "colored", "log", - "time", + "time 0.3.24", "windows-sys 0.42.0", ] @@ -3850,17 +3938,19 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "smartstring" -version = "0.2.10" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e714dff2b33f2321fdcd475b71cec79781a692d846f37f415fb395a1d2bcd48e" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" dependencies = [ + "autocfg", "static_assertions", + "version_check", ] [[package]] @@ -3926,9 +4016,9 @@ dependencies = [ [[package]] name = "subtle" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" @@ -3943,9 +4033,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.15" +version = "2.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" dependencies = [ "proc-macro2", "quote", @@ -3982,24 +4072,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.5.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" +checksum = "5486094ee78b2e5038a6382ed7645bc084dc2ec433426ca4c3cb61e2007b8998" dependencies = [ "cfg-if", "fastrand", "redox_syscall 0.3.5", "rustix", - "windows-sys 0.45.0", -] - -[[package]] -name = "termcolor" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" -dependencies = [ - "winapi-util", + "windows-sys 0.48.0", ] [[package]] @@ -4010,22 +4091,22 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" [[package]] name = "thiserror" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -4040,10 +4121,22 @@ dependencies = [ [[package]] name = "time" -version = "0.3.20" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79eabcd964882a646b3584543ccabeae7869e9ac32a46f6f22b7a5bd405308b" +dependencies = [ + "deranged", "itoa", "libc", "num_threads", @@ -4054,15 +4147,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" [[package]] name = "time-macros" -version = "0.2.8" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +checksum = "eb71511c991639bb078fd5bf97757e03914361c48100d52878b8e52b46fb92cd" dependencies = [ "time-core", ] @@ -4084,11 +4177,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.27.0" +version = "1.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" dependencies = [ "autocfg", + "backtrace", "bytes 1.4.0", "libc", "mio", @@ -4098,7 +4192,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.45.0", + "windows-sys 0.48.0", ] [[package]] @@ -4113,13 +4207,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.28", ] [[package]] @@ -4150,16 +4244,26 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls", + "rustls 0.20.8", "tokio", "webpki", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.5", + "tokio", +] + [[package]] name = "tokio-stream" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb52b74f05dbf495a8fba459fdc331812b96aa086d9eb78101fa0d4569c3313" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ "futures-core", "pin-project-lite", @@ -4168,9 +4272,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" dependencies = [ "bytes 1.4.0", "futures-core", @@ -4215,7 +4319,7 @@ dependencies = [ "rustls-native-certs", "rustls-pemfile", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", "tokio-stream", "tokio-util", "tower", @@ -4276,7 +4380,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d21e279964481ddf163bae257766f2ef87b26a8b45a0fa3f0c272f6c1bd72fed" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "byteorder", "bytes 1.4.0", "futures-util", @@ -4302,7 +4406,7 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 1.9.3", "pin-project", "pin-project-lite", "rand", @@ -4322,7 +4426,7 @@ checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" dependencies = [ "async-compression", "base64 0.13.1", - "bitflags", + "bitflags 1.3.2", "bytes 1.4.0", "futures-core", "futures-util", @@ -4343,12 +4447,12 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.4.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" +checksum = "55ae70283aba8d2a8b411c695c437fe25b8b5e44e23e780662002fc72fb47a82" dependencies = [ - "base64 0.20.0", - "bitflags", + "base64 0.21.2", + "bitflags 2.3.3", "bytes 1.4.0", "futures-core", "futures-util", @@ -4388,20 +4492,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.23" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.28", ] [[package]] name = "tracing-core" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", "valuable", @@ -4430,9 +4534,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ "matchers", "nu-ansi-term", @@ -4480,9 +4584,9 @@ checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "ucd-trie" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "unicase" @@ -4501,9 +4605,9 @@ checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" [[package]] name = "unicode-ident" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" [[package]] name = "unicode-normalization" @@ -4534,9 +4638,9 @@ checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] name = "unsafe-libyaml" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1865806a559042e51ab5414598446a5871b561d21b6764f2eabb0dd481d880a6" +checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa" [[package]] name = "untrusted" @@ -4546,9 +4650,9 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" dependencies = [ "form_urlencoded", "idna", @@ -4570,9 +4674,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.3.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55a3fef2a1e3b3a00ce878640918820d3c51081576ac657d23af9fc7928fdb" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom", ] @@ -4616,14 +4720,19 @@ dependencies = [ [[package]] name = "want" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "log", "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -4632,9 +4741,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -4642,24 +4751,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.28", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.34" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" dependencies = [ "cfg-if", "js-sys", @@ -4669,9 +4778,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4679,22 +4788,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.28", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "wasm-streams" @@ -4753,9 +4862,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" dependencies = [ "js-sys", "wasm-bindgen", @@ -4834,7 +4943,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-targets 0.48.0", + "windows-targets 0.48.1", ] [[package]] @@ -4867,7 +4976,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.48.0", + "windows-targets 0.48.1", ] [[package]] @@ -4887,9 +4996,9 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ "windows_aarch64_gnullvm 0.48.0", "windows_aarch64_msvc 0.48.0", @@ -4995,9 +5104,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.4" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" +checksum = "47430998a7b5d499ccee752b41567bc3afc57e1327dc855b1a2aa44ce29b5fa1" [[package]] name = "xxhash-rust" diff --git a/Cargo.toml b/Cargo.toml index f0d0e843..26ff056d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["controller", "service", "cli", "web", "lib"] +members = ["controller", "service", "cli", "web", "lib" ] resolver = "2" diff --git a/Dockerfile.controller b/Dockerfile.controller index aa8912b0..2405ebe5 100644 --- a/Dockerfile.controller +++ b/Dockerfile.controller @@ -1,6 +1,6 @@ # Using the `rust-musl-builder` as base image, instead of the official Rust toolchain. # See https://github.com/clux/muslrust for why this is desirable. -FROM clux/muslrust:1.68.0-stable AS chef +FROM clux/muslrust:1.70.0-stable AS chef RUN cargo install cargo-chef WORKDIR /app diff --git a/Dockerfile.server b/Dockerfile.server index d0a5399d..00b8be3e 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,6 +1,6 @@ # Using the `rust-musl-builder` as base image, instead of # the official Rust toolchain -FROM clux/muslrust:1.68.0-stable AS chef +FROM clux/muslrust:1.70.0-stable AS chef RUN cargo install cargo-chef WORKDIR /app diff --git a/README.md b/README.md index 96e2aafa..4bfdbd65 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ TODO: show an example of this. ### Pipelines -You might have multiple Tasks meant to be executed together, for example downloading data, preparing data, model training, model upload. Each of these Tasks will have different requirements. This can be expressed using a pipeline. Each Task in a pipeline is executed in a separate container potentially on different machines if their compute requirements are different. To ensure that your code will work without modification, all of the state is transferred between steps transparently so it appears as if all of the steps are executed on the same machine. For example data is downloaed in step 1, prepared in step 2 and trained on in step 3 AME will make sure to transfer these files automatically between steps so no adjustments are reqired to the project's code. +You might have multiple Tasks meant to be executed together, for example downloading data, preparing data, model training, model upload. Each of these Tasks will have different requirements. This can be expressed using a pipeline. Each Task in a pipeline is executed in a separate container potentially on different machines if their compute requirements are different. To ensure that your code will work without modification, all of the state is transferred between steps transparently so it appears as if all of the steps are executed on the same machine. For example data is downloaed in step 1, prepared in step 2 and trained on in step 3 AME will make sure to transfer these files automatically between steps so no adjustments are required to the project's code. ```yaml #ame.yaml diff --git a/docs/datasets.md b/ame/docs/datasets.md similarity index 100% rename from docs/datasets.md rename to ame/docs/datasets.md diff --git a/ame/docs/index.md b/ame/docs/index.md new file mode 100644 index 00000000..000ea345 --- /dev/null +++ b/ame/docs/index.md @@ -0,0 +1,17 @@ +# Welcome to MkDocs + +For full documentation visit [mkdocs.org](https://www.mkdocs.org). + +## Commands + +* `mkdocs new [dir-name]` - Create a new project. +* `mkdocs serve` - Start the live-reloading docs server. +* `mkdocs build` - Build the documentation site. +* `mkdocs -h` - Print help message and exit. + +## Project layout + + mkdocs.yml # The configuration file. + docs/ + index.md # The documentation homepage. + ... # Other markdown pages, images and other files. diff --git a/ame/docs/model_validation.md b/ame/docs/model_validation.md new file mode 100644 index 00000000..b4952f1d --- /dev/null +++ b/ame/docs/model_validation.md @@ -0,0 +1,172 @@ +# Guides + +## From zero to live model + +**This guide is focused on using AME** if you are looking for a deployment guide go [here](todo). + + + +This guide will walk through going from zero to having a model served through an the [V2 inference protocol](https://docs.seldon.io/projects/seldon-core/en/latest/reference/apis/v2-protocol.html). +it will be split into multiple sub steps which can be consumed in isolation if you are just looking for a smaller guide on that specific step. + +Almost any python project should be usable but if you want to follow along with the exact same project as the guide clone [this]() repo. + +### Setup the CLI + +Before we can initialise an AME project we need to install the ame [CLI](todo) and connect with your AME instance. + +TODO describe installation + +### Initialising AME in your project + +The first step will be creating an `ame.yaml` file in the project directory. + +This is easiet to do with the ame [CLI]() by running `ame project init`. The [CLI]() will ask for a project and then produce a file +that looks like this: + +```yaml +projectName: sklearn_logistic_regression +``` + +### The first training + +Not very exciting but it is a start. Next we want to set up our model to be run by AME. The most important thing here is the Task that will train the model so +lets start with that. + +Here we need to consider a few things, what command is used to train a model, how are dependencies managed in our project, what python version do we need and +how many resources does our model training require. + +If you are using the [repo]() for this guide, you will want a task configured as below. + +```yaml + +projectid: sklearn_logistic_regression +tasks: + - name: training + !poetry + executor: + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +## Your first Task + +[`Tasks`](TODO) are an important building block for AME. This guide will walk you through the basic of constructing and running [`Task`](todo). + +We assume that the AME [CLI](todo) is setup and connected to an AME instance. If not see this [guide](todo). + +Before we can run a task we must have a project setup. To init a project follow the commands as shown below, replacing myproject with the +path to your project. + +```sh +cd myproject +ame init +``` + +Now you should have an AME file ame.yaml inside your project: +```yaml +name: myproject +``` + +Not very exciting yet. Next we want to add a Task to this file so we can run it. +Update your file to match the changes shown below. + +```yaml +name: myproject +tasks: + - name: training + !poetry + executor: + pythonVersion: 3.11 + command: python train.py + resources: + memory: 2G + cpu: 2 + storage: 10G +``` + +Here we add a list of tasks for our project, containing a single `Task` called training. Lets look at the anatomy of training. + +First we set the name `name: training`, pretty standard YAML. Next we set the [executor](todo). This syntax might seem a bit confusing +if you have not used this YAML feature before. `!poetry` adds a tag to the executor indicating the executor type. In this case we are +using the poetry executor. It requires two fields to be set. the Python version and the command to run. This tells AME how to execute the [`Task`](todo). + +Finally we set the required resources. 2G ram, 2 cpu threads and 10G of storage. + +To run the task we can use the CLI: +```sh +ame task run +``` + + + +## Validating models before deployment + +To ensure that a new model versions perform well before exposing them AME supports model validation. This is done by providing AME with a `Task` which +will succeed if the model passes validation and fail if not. + +Example from [ame-demo](https://github.com/TeaInSpace/ame-demo): + +```yaml + +projectid: sklearn_logistic_regression +models: + - name: logreg + type: mlflow + validationTask: # the validation task is set here. + taskRef: mlflow_validation + training: + task: + taskRef: training + deployment: + auto_train: true + deploy: true + enable_tls: false +tasks: + - name: training + projectid: sklearn_logistic_regression + templateRef: shared-templates.logistic_reg_template + taskType: Mlflow + - name: mlflow_validation + projectid: sklearn_logistic_regression + runcommand: python validate.py +``` + +This approach allows for a lot of flexibility of how models are validated, at the cost of writing the validation your self. In the future AME will provide builtin options for common validation configurations as well, see the [roadmap](todo). + +### Using MLflow metrics + +Here we will walk through how to validate a model based on recorded metrics in MLflow, using the [ame-demo](https://github.com/TeaInSpace/ame-demo) repository as an example. The model is a simple logistic regresser, the training code looks like this: + +```python +import numpy as np +from sklearn.linear_model import LogisticRegression +import mlflow +import mlflow.sklearn +import os + +if __name__ == "__main__": + X = np.array([-2, -1, 0, 1, 2, 1]).reshape(-1, 1) + y = np.array([0, 0, 1, 1, 1, 0]) + lr = LogisticRegression() + lr.fit(X, y) + score = lr.score(X, y) + print("Score: %s" % score) + mlflow.log_metric("score", score) + mlflow.sklearn.log_model(lr, "model", registered_model_name="logreg") + print("Model saved in run %s" % mlflow.active_run().info.run_uuid) +``` + +Notice how the score is logged as a metric. We can use that in our validation. + +AME exposes the necessary environment variables to running tasks so we can access the Mlflow instance during validation just by using the Mlflow library. + +```python +TODO + +``` diff --git a/ame/docs/models.html b/ame/docs/models.html new file mode 100644 index 00000000..fca66d24 --- /dev/null +++ b/ame/docs/models.html @@ -0,0 +1,30 @@ +

Models

+

Models are one of AME's higher level constructs, see what that means here. if you are configuring how a model should be trained, deployed, monitored or validated this is the right place. +Models exist in an AME file along side Datasets Tasks and Templates.

+

Model training

+

Model training is configured described use a Task.

+

AME can be deployed with a an MLflow instance which will be exposed to the Training Task allowing for simply storage and retrievel of models and metrics.

+
# main project ame.yml
+project: xgboost_project
+models:
+  - name: product_recommendor
+    training:
+      task: 
+        taskRef: train_my_model 
+tasks:
+  - name: train_my_model
+    fromTemplate: shared_templates.xgboost_resources
+    executor:
+      !poetry
+      pythonVersion: 3.11
+      command: python train.py
+    resources:
+      memory: 10G 
+      cpu: 4 
+      storage: 30G 
+      nvidia.com/gpu: 1 
+
+

Model deployment

+

Model validation

+

Model monitoring

+

Batch inference

diff --git a/ame/docs/models.md b/ame/docs/models.md new file mode 100644 index 00000000..751d53c3 --- /dev/null +++ b/ame/docs/models.md @@ -0,0 +1,42 @@ +# Models + +Models are one of AME's higher level constructs, see what that means [here](). if you are configuring how a model should be trained, deployed, monitored or validated this is the right place. +Models exist in an AME file along side Datasets Tasks and Templates. + +### Model training + +Model training is configured described use a [Task](tasks.md). + +AME can be deployed with a an MLflow instance which will be exposed to the Training Task allowing for simply storage and retrievel of models and metrics. + + +```yaml +# main project ame.yml +project: xgboost_project +models: + - name: product_recommendor + training: + task: + taskRef: train_my_model +tasks: + - name: train_my_model + fromTemplate: shared_templates.xgboost_resources + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + + +### Model deployment + +#### Model validation + +#### Model monitoring + +### Batch inference diff --git a/docs/project_sources.md b/ame/docs/project_sources.md similarity index 94% rename from docs/project_sources.md rename to ame/docs/project_sources.md index 3fe54669..65402375 100644 --- a/docs/project_sources.md +++ b/ame/docs/project_sources.md @@ -2,11 +2,11 @@ A project source informs AME of a location to check and sync an AME project from. Currently the only supported location is a Git repository. -### Git project sources +## Git project sources Git project sources allow for a Gitops like approach to managing models, data and the surrounding operations using the AME file defined in the repository. -#### How to use Git project sources +### How to use Git project sources You can create a Git project source either through the CLI or the AME frontend. diff --git a/ame/docs/tasks.md b/ame/docs/tasks.md new file mode 100644 index 00000000..62e207df --- /dev/null +++ b/ame/docs/tasks.md @@ -0,0 +1,681 @@ +# Tasks + +`Tasks` are the basic building block for most of AME's functionality. A `Task` represents some work to be done, often in the form of python code to be executed +but in principle it can be anything executable in a container. + +`Tasks` are configured in an AME file `ame.yml`. + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py +``` + +This is an example of a the absolute minimal requirements for a task, a name and an executor. An executor specifies how a task should be executed, a complete list can found (here)[]. +In this case AME will ensure that the specified python version is present and use poetry to install dependencies and enter a shell before executing the command `python train.py`. + +To run the task manually simply enter the directory with the file and project and run `ame task run -l train_my_model`. The `-l` ensures that we are using the local context and not trying +to run a remote task already present in the AME cluster. Alternatively if you don't want to type the name omit it and AME will present a list of the available `Tasks`. + + +### Resource requirements + +By default a task gets limited resources allocated TODO: what is the default? which can be fine for very simple tasks but anything non trivial will require. To change the default see +how to configure default (here)[]. + +Resources include any computational resources that needs to be allocated: + +- CPU +- GPU +- memory +- storage + +They can be specified for a task with the resources field: + + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G # 10 Gigabyte ram + cpu: 4 # 4 CPU threads + storage: 30G # 30Gigabyte of disk space + nvidia.com/gpu: 1 # 1 Nvidia GPU +``` + +AME uses style of string units for resources as Kubernetes. If you are not familiar with that no worries!, the readon for the details. + +**Memory and storage units**: memory units are measured in bytes. Integers with quantity suffixes can be used to express memory quantities. +See the following table for complete list of available units. + +| Unit | Suffix | Example | +|-------------------- |-------- |--------- | +| Exabyte | E | 2.5E | +| Exbibyte | Ei | 2.5Ei | +| Terabyte | T | 2.5T | +| Tebibyte | Ti | 2.5Ti | +| Gigabyte | G | 2.5G | +| Gibibyte | Gi | 2.5Gi | +| Megabyte | M | 2.5M | +| Mebibyte | Mi | 2.5Mi | +| Kilobyte | k | 2.5k | +| Kibibyte | Ki | 2.5Ki | +| Byte | | 2.5 | +| Fraction of a Byte | m | 2500m | + +TODO: make better example +**Example**: +``` + 128974848, 129e6, 129M, 128974848000m, 123Mi +``` + +**CPU units** + +`1` specifies a single CPU thread either virtual or hyperthreaded depending on the underlying machine. `0.5` specifies half a CPU thread and so does `500m`. +`1=1000m`, `m` stands for millicpu/millicores. + +**GPU units** + +GPU scheduling in AME is current pretty barebones. You must specifie whole GPUs, meaning no fractional units and you can't ask for a specific model of device only vendor. +For cases where different GPU models need to be differentiate node labels can be used as a work around. This is essentially the way Kubernetes solves GPU scheduling as well, however we will +be abstracting all this away in the coming release of AME to allow for requesting specific models and fractional GPU sharing directly in the Task specification, see the tracking (issue)[]. +For details on how to use node labels with GPUs see (this)[]. + +### Secrets + +Task's will often require access to private resources such as objectstorage, databases or APIs. AME provides a built in secret store as well as integration with (Vault)[https://www.vaultproject.io/]. +This section will walk through how to use secrets with a Task. For details on AME's secret store and how to integrate with a Vault instance see the relevant (documentation)[]. + +**Quick example** + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G # 10 Gigabyte ram + cpu: 4 # 4 CPU threads + storage: 30G # 30Gigabyte of disk space + nvidia.com/gpu: 1 # 1 Nvidia GPU + secrets: + - !ame # Secret stored by AME + key: blob_storage_key # Key identifying a secret in AME's secret store. + injectAs: MY_BLOB_STORAGE_SECRET # This will inject an environment variable with the name MY_BLOB_STORAGE_SECRET and with the value of the secret. + + # TODO this does not cover all vault cases + - !vault # Secret stored in Vault + vault: company_vault # Name of the vault to use. + key: path/to/secret # Path to the secret in Vault. + injectAs: COMPANY_SECRET # This will inject an environment variable with the name COMPANY_SECRET and with the value of the secret. +``` + +**Explanation** + +`!ame` and `!vault` indicate the type of secret being specified. `key` is the key that identifies the secret for both variants. +`injectAs` specifies the name of the environment variable. `vault` specifies the name of the vault to use. + +A secret can be out in AME's secret store using the secret sub command: `ame secret add`. The will prompts will ask for a key and value. + +### Container images + +The default image is intended to cover most cases. It uses the latest LTS version of Ubuntu with a non slim environment. Most projects should just work inside +this environment. How ever there a few reasons you might want to replace the default image with your own. + +**Special library requiremetents**: if a package you are using requires some system library that is not installed by default you can address this by creating a custom image. In this +case it probably makes sense to take the base AME image and extend it with the dependencies you need. If you think this dependency should be included in the base image feel free to create +and issue on Github. + +**Security**: depending on your needs a full blown Ubuntu environment might have too many security issues due to all of the packages installed by default. In this case creating a minimal image with the +exact requirements you is the way to go. See guide on doing that [here](). + +TODO: how do we ignore large files? + +### Task cleanup + +### Executors + +An executor describes how AME should execute a task. It does this by providing enough information for AME to know how dependencies should be installed and how a Task is run. + +All executes support overriding the default image with the image field. Read more about the default image used by AME and overriding it with your own [here](). + +TODO: How to change versions of dependency managers??? + +#### Quick examples + +##### Poetry executor + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py +``` + +##### Pip executor + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !pip + pythonVersion: 3.11 + command: python train.py +``` + +##### Pipenv executor + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !pipenv + command: python train.py +``` + +##### MLflow executor + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !mlflow + pythonVersion: 3.11 + command: python train.py # TODO should the command be specified here?? +``` + +##### Custom executor + + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !custom + pythonVersion: 3.11 + command: python train.py # TODO should the command be specified here?? +``` + +#### Poetry executor + +The Poetry executor expects a Poetry compatible project. This means a pyproject.taml and poetry.lock file should be present. +Note that the python version is required to be specified, in the future this information will be extracted from pyproject.toml. +The value in the command field is executed using poetry run and it used to start the task. + +```yaml + executor: + !poetry + pythonVersion: 3.11 + command: python train.py +``` + +#### Pipenv executor + +The Pipenv executor expects a Pipenv compatible project. This means a Pipfile and Pipfile.lock file should be present. +The python version is installed by pipenv following the value in the Pipfile. +The value in the command field is executed inside a shell created with pipenv shell. + +```yaml + executor: + !pipenv + command: python train.py +``` + +#### Pip executor + +The expects a project where pip can install dependencies. This means a requirements.txt file should be present. We strongly recommend that +versions are specified in the requirements.txt file to ensure that the project will run just like on your local machine. +The value in the command field is executed inside a virtual environment created used venv with the dependencies installed by pip. + +```yaml + executor: + !pip + pythonVersion: 3.11 + command: python train.py +``` + +#### Custom executor + +The custom executor is meant for special cases where the other executors are insufficient. For example if you are not using python. +No setup is performed in this case, the command is simply executed inside a container. + +```yaml + executor: + !pip + pythonVersion: 3.11 + command: python train.py + image: myimage +``` +### Common task examples + +### Templates + +If you find yourself repeating a lot of Task configuration it might be useful to create templates for common configuration. Templates are partial Tasks that can be used +as the base for a Task, any fields set in the Task will then override the Template. The combination of Task and Template fields must yield a valid Task. + +#### Quick examples + +##### Template and Task in the same project +```yaml +# ame.yml + +tasks: + - name: train_my_model + fromTemplate: + name: xgboost_resources + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + +templates: + - name: xgboost_resources # Note that this is the name of the template + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 + secrets: + - !ame # Secret stored by AME + key: blob_storage_key # Key identifying a secret in AME's secret store. + injectAs: MY_BLOB_STORAGE_SECRET # This will inject an environment variable with the name MY_BLOB_STORAGE_SECRET and with the value of the secret. + + # TODO this does not cover all vault cases + - !vault # Secret stored in Vault + vault: company_vault # Name of the vault to use. + key: path/to/secret # Path to the secret in Vault. + injectAs: COMPANY_SECRET # This will inject an environment variable with the name COMPANY_SECRET and with the value of the secret. +``` + +##### Template imported from a separate project +```yaml +# main project ame.yml +project: xgboost_project +tasks: + - name: train_my_model + fromTemplate: + name: xgboost_resources + project: shared_templates + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + +# other project ame.yml +project: shared_templates +templates: + - name: xgboost_resources # Note that this is the name of the template + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 + secrets: + - !ame # Secret stored by AME + key: blob_storage_key # Key identifying a secret in AME's secret store. + injectAs: MY_BLOB_STORAGE_SECRET # This will inject an environment variable with the name MY_BLOB_STORAGE_SECRET and with the value of the secret. + + # TODO this does not cover all vault cases + - !vault # Secret stored in Vault + vault: company_vault # Name of the vault to use. + key: path/to/secret # Path to the secret in Vault. + injectAs: COMPANY_SECRET # This will inject an environment variable with the name COMPANY_SECRET and with the value of the secret. +``` +###### Example notes + +See the (section)[] on importing from other projects for more details. + +### Importing from other projects + +### Task input and output data (artifacts and saving) + +Tasks can load data using [Datasets](todo) and save artifacts to the object storage AME is configured too use. + +#### Quick examples + +##### Save data in paths to object storage + +```yaml +name: artifact_example +tasks: + - name: artifact_task + executor: + !pipEnv + command: python artifacts.py + artifacts: + paths: + - path/to/artifact_dir + +``` + +##### Automatically store new or changed files as artifacts + +```yaml +name: artifact_example +tasks: + - name: artifact_task + executor: + !pipEnv + command: python artifacts.py + artifacts: + saveChangedFiles: true + +``` + +##### Load data from a dataset + +```yaml +name: artifact_example +tasks: + - name: artifact_task + executor: + !pipEnv + command: python artifacts.py + dataSets: + - ref: + name: somedataset + project: anotherproject + artifacts: + saveChangedFiles: true + +``` + + +### Task reference + +Tasks can reference other Tasks. This is intended for usecases where inlining a Task is cumbersome for example when adding a model training Task or a Task pipeline. + +#### Quick examples + +##### Referencing a Task for model training + +```yaml +# main project ame.yml +project: xgboost_project +models: + - name: product_recommendor + training: + task: # Remember this field expects a Task + taskRef: train_my_model # This is considered a complete Task +tasks: + - name: train_my_model + fromTemplate: shared_templates.xgboost_resources + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +Alternatively if we were to inline the Task it would look like: + +```yaml +# main project ame.yml +project: xgboost_project +models: + - name: product_recommendor + training: + task: + name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +##### Referencing a Task from a Task +```yaml +# main project ame.yml +project: xgboost_project +models: + - name: product_recommendor + training: + task: + taskRef: train_my_model +tasks: + - name: other_task # TODO: how do we handle names for Tasks with a reference. + taskRef: train_my_model + - name: train_my_model + fromTemplate: shared_templates.xgboost_resources + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +**Currently it is not supported to reference a Task from a different project but that is likely to be implemented in some form in the near future** + +### Triggering Tasks + +For some usecases it might be required to automatically kick off a Task. If you are working with a high level construct such as a dataset or Model we recommended that you first check +if it has a mechanism that suits your needs as AME can often help you do less work that way. For example a Model automatically keep deployment up to date and trigger validation before +deployming any new versions. Doing this manually would be non trivial. If you have a need not already covered read on. + +The main mechanism of triggering independent Tasks is time based. We will be implementing more triggers for example git based triggers in the coming release. + +In order to have a Task triggered on some recurring basis two things are required. A project with that Task must be present in an AME cluster and the desired Task must have the trigger field configured. + +#### Quick examples + +##### Trigger Task with cron schedule + +```yaml +# main project ame.yml +project: xgboost_project +tasks: + - name: my_task + taskRef: train_my_model + - name: train_my_model + fromTemplate: shared_templates.xgboost_resources + triggers: + schedule: ***** # TODO some cron schedule + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +### DAGS + +### Working with Tasks + +### Pipelines + +A task can consist of sub tasks. + + +#### Quick examples + +##### Pipeline with inline sequential tasks + + +```yaml +# main project ame.yml +project: xgboost_project +tasks: + - name: other_task # TODO: how do we handle names for Tasks with a reference. + taskRef: train_my_model + - name: train_my_model + pipeline: + - name: preprocessing + executor: + !poetry + pythonVersion: 3.11 + command: python prepare_data.py + resources: + memory: 4G + cpu: 2 + storage: 30G + - name: training + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 +``` + +##### Pipeline with equential tasks referenced + +```yaml +# main project ame.yml +project: xgboost_project +tasks: + - name: other_task # TODO: how do we handle names for Tasks with a reference. + taskRef: train_my_model + - name: train_my_model + pipeline: + - taskref: preprocessing + - taskref: training + - name: preprocessing + executor: + !poetry + pythonVersion: 3.11 + command: python prepare_data.py + resources: + memory: 4G + cpu: 2 + storage: 30G + - name: training + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 + +``` + +##### Pipeline with parallel tasks + + +```yaml +# main project ame.yml +project: xgboost_project +tasks: + - name: other_task # TODO: how do we handle names for Tasks with a reference. + taskRef: train_my_model + - name: train_my_model + pipeline: + - - taskref: prepare_dataset_one + - taskref: prepare_datast_two + - taskref: training + - name: prepare_dataset_one + executor: + !poetry + pythonVersion: 3.11 + command: python prepare_data_one.py + resources: + memory: 4G + cpu: 2 + storage: 30G + - name: prepare_dataset_two + executor: + !poetry + pythonVersion: 3.11 + command: python prepare_data_two.py + resources: + memory: 4G + cpu: 2 + storage: 30G + - name: training + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G + cpu: 4 + storage: 30G + nvidia.com/gpu: 1 + +``` + +### Environment variables + +Environment variable can be set with the `environment` field. The field accepts a list of key value pairs, see the example below. + +**Note** that there are a few environment variables set by AME, future releases of AME will return an error if you attempt to override them, see this (issue)[]. + +```shell +# Provided environment variables +# TODO fill this out +MLFLOW... + +``` + +**Quick example** + +```yaml +# ame.yml + +tasks: + - name: train_my_model + executor: + !poetry + pythonVersion: 3.11 + command: python train.py + resources: + memory: 10G # 10 Gigabyte ram + cpu: 4 # 4 CPU threads + storage: 30G # 30Gigabyte of disk space + nvidia.com/gpu: 1 # 1 Nvidia GPU + environment: + # inject environment variable SOME_VAR=SOME_VAL + - key: SOME_VAR + val: SOME_VAL +``` + +### Techinical details of Tasks diff --git a/ame/mkdocs.yml b/ame/mkdocs.yml new file mode 100644 index 00000000..097a907b --- /dev/null +++ b/ame/mkdocs.yml @@ -0,0 +1,32 @@ +site_name: AME +theme: + name: material + features: + - navigation.tabs + palette: + # Palette toggle for light mode + - scheme: default + primary: green + accent: light green + toggle: + icon: material/brightness-7 + + name: Switch to dark mode + + # Palette toggle for dark mode + - scheme: slate + primary: green + accent: light green + toggle: + icon: material/brightness-4 + name: Switch to light mode + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c7e78f5d..b641dcee 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -3,49 +3,55 @@ name = "cli" version = "0.1.0" edition = "2021" +[[bin]] +name = "ame" +path = "src/main.rs" + [dependencies] -async-stream = "0.3.3" -clap = { version = "4.0.27", features = ["derive"] } -console = "0.15.2" +async-stream = "0.3.5" +clap = { version = "4.3.19", features = ["derive"] } +console = "0.15.7" envconfig = "0.10.0" -futures-util = "0.3.25" -serde = "1.0.147" -serde_yaml = "0.9.14" -ame = { path = "../lib", features = ["native-client"], default-features = false } -thiserror = "1.0.37" -tokio = "1.22.0" -tonic = { version= "0.8.2", features = ["transport","tls", "tls-roots"] } -walkdir = "2.3.2" +futures-util = "0.3.28" +serde = "1.0.180" +serde_yaml = "0.9.25" +ame = { path = "../lib", features = ["native-client", "project-tools", "custom-resources", "ame-control"], default-features = false } +thiserror = "1.0.44" +tokio = {version = "1.29.1", features = ["fs"]} +tonic = { version= "0.8.3", features = ["transport","tls", "tls-roots"] } +walkdir = "2.3.3" rand = "0.8.5" confy = "0.5.1" tokio-rustls = { version = "0.23.4", features = ["dangerous_configuration"] } -rustls-native-certs = "0.6.2" -hyper = "0.14.23" +rustls-native-certs = "0.6.3" +hyper = "0.14.27" tower = "0.4.13" hyper-rustls = { version = "0.23.2", features = ["http2"] } http-body = "0.4.5" -openidconnect = "2.5.0" -url = "2.3.1" -serde_json = "1.0.91" -reqwest = "0.11.14" +openidconnect = "2.5.1" +url = "2.4.0" +serde_json = "1.0.104" +reqwest = "0.11.18" tower-http = { version = "0.3.5", features = ["auth"] } -http = "0.2.8" -bytes = "1.3.0" +http = "0.2.9" +bytes = "1.4.0" tracing = "0.1.37" -oauth2 = "4.3.0" +oauth2 = "4.4.1" spinners = "4.1.0" -colored = "2.0.0" -dialoguer = "0.10.3" +colored = "2.0.4" +dialoguer = {version = "0.10.4", features=["fuzzy-select"]} +atty = "0.2.14" +anyhow = "1.0.72" [dev-dependencies] -assert_cmd = "2.0.6" -assert_fs = "1.0.9" +assert_cmd = "2.0.12" +assert_fs = "1.0.13" kube = "0.80.0" -insta = { version = "1.21.1", features = ["filters", "redactions", "yaml"] } -fs_extra = "1.2.0" +insta = { version = "1.31.0", features = ["filters", "redactions", "yaml"] } similar-asserts = "1.4.2" serial_test = "0.9.0" -temp-env = { version = "0.3.1", features = ["async_closure"] } +temp-env = { version = "0.3.4", features = ["async_closure"] } rstest = "0.16.0" k8s-openapi = { version = "0.17.0", features = ["v1_23", "schemars" ] } -ame = { path = "../lib", features = ["native-client", "custom-resources", "ame-control"] } +ame = { path = "../lib", features = ["native-client", "custom-resources", "ame-control", "project-tools"] } +fs_extra = "1.3.0" diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 77a4bd39..e2d9303b 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,7 +1,9 @@ pub mod project; -use ame::client::native_client::{build_ame_client, AmeClient}; -use ame::AmeServiceClientCfg; +use ame::{ + client::native_client::{build_ame_client, AmeClient}, + AmeServiceClientCfg, +}; use envconfig::Envconfig; use http::uri::InvalidUri; @@ -17,10 +19,10 @@ pub enum Error { #[error("Ame errored: {0}")] FileError(#[from] std::io::Error), - #[error("Ame errored: {0}")] + #[error("The AME server could not be reached: {0}")] TonicError(#[from] tonic::transport::Error), - #[error("Ame errored: {0}")] + #[error("The AME sever failed a request: {0}")] TonicStatusError(#[from] tonic::Status), #[error("Ame errored: {0}")] @@ -56,8 +58,10 @@ pub enum Error { pub type Result = std::result::Result; +pub mod project_cmd; pub mod projectsrc; pub mod secrets; +pub mod task; #[derive(Clone, Default, Deserialize, Serialize, Envconfig, PartialEq, Debug)] pub struct CliConfiguration { diff --git a/cli/src/main.rs b/cli/src/main.rs index ff5afb6f..e32be467 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,19 +1,22 @@ use ame::{ client::{auth::browser_login, native_client::build_ame_client}, - grpc::TaskIdentifier, - grpc::TrainRequest, + grpc::{ProjectCfg, TaskIdentifier, TrainRequest}, AmeServiceClientCfg, }; use clap::{Parser, Subcommand}; use cli::{ project::Project, + project_cmd::{exec_project_command, ProjectCommands}, projectsrc::ProjectSrcCommands, secrets::{exec_secret_command, SecretCommand}, - CliConfiguration, Result, + task::{exec_task_command, TaskCommand}, + CliConfiguration, }; use http::StatusCode; +use anyhow::Result; + use tonic::Request; #[derive(Parser)] @@ -28,9 +31,6 @@ enum Commands { Init { name: String, }, - Run { - name: String, - }, Setup { endpoint: String, }, @@ -43,6 +43,11 @@ enum Commands { Projectsrc(ProjectSrcCommands), #[command(subcommand)] Secret(SecretCommand), + #[command(subcommand)] + Task(TaskCommand), + #[command(subcommand)] + Project(ProjectCommands), + Validate, } #[tokio::main] @@ -53,20 +58,6 @@ async fn main() -> Result<()> { match &cli.command { // TODO: if an error is returned here the output will be confusing to the user. Commands::Init { name } => Project::init(name), - Commands::Run { name: name_arg } => { - let task_template_name = name_arg.as_ref(); - let project = Project::init_from_working_dir()?; - let mut client = build_ame_client(AmeServiceClientCfg { - disable_tls_cert_check: true, - endpoint: config.endpoint.parse().unwrap(), - id_token: config.id_token, - }) - .await?; - - project.run_task(&mut client, task_template_name).await?; - - Ok(()) - } Commands::Setup { endpoint } => { let cli_cfg = CliConfiguration::init_with_endpoint(endpoint.to_string()); let mut client = build_ame_client(cli_cfg.clone().try_into()?).await?; @@ -135,7 +126,15 @@ async fn main() -> Result<()> { } Commands::Projectsrc(cmd) => cmd.run(&config).await, Commands::Secret(cmd) => exec_secret_command(config, cmd).await, - } + Commands::Task(cmd) => exec_task_command(config, cmd).await, + Commands::Project(cmd) => exec_project_command(config, cmd).await, + Commands::Validate => { + ProjectCfg::try_from_working_dir().unwrap(); + Ok(()) + } + }?; + + Ok(()) } #[test] diff --git a/cli/src/project.rs b/cli/src/project.rs index 7ac9479b..3814eafc 100644 --- a/cli/src/project.rs +++ b/cli/src/project.rs @@ -1,19 +1,11 @@ -use crate::{Error, Result}; use ame::client::native_client::AmeClient; -use ame::grpc::TaskLogRequest; -use ame::grpc::{ - project_file_chunk::Messages, CreateTaskRequest, FileChunk, ProjectFileChunk, - ProjectFileIdentifier, TaskProjectDirectoryStructure, TaskTemplate, -}; +use anyhow::Result; + +use ame::grpc::TaskCfg; use console::Emoji; -use futures_util::StreamExt; -use rand::distributions::{Alphanumeric, DistString}; use serde::{Deserialize, Serialize}; -use std::fs; -use std::fs::File; -use std::io::Read; -use std::time::Duration; +use std::{fs, fs::File}; #[derive(Clone, Default, Deserialize, Serialize)] pub struct Project { @@ -21,10 +13,10 @@ pub struct Project { id: String, #[serde(skip_serializing_if = "Option::is_none")] - tasks: Option>, + tasks: Option>, #[serde(skip_serializing_if = "Option::is_none")] - templates: Option>, + templates: Option>, } impl Project { @@ -54,15 +46,19 @@ impl Project { } } + pub fn task_names(&self) -> Vec { + self.tasks + .as_ref() + .map(|tasks| tasks.iter().filter_map(|t| t.name.clone()).collect()) + .unwrap_or(vec![]) + } pub fn init_from_working_dir() -> Result { Ok(serde_yaml::from_str(&fs::read_to_string("ame.yaml")?)?) } - pub fn get_task_template(&self, name: &str) -> Result { - let Some(task_templates) = self.tasks.clone() else { - return Err(Error::EmptyProjectField("tasks".to_string())); - }; - + pub fn get_task_template(&self, _name: &str) -> Result { + todo!(); + /* let valid_task_templates: Vec<&TaskTemplate> = task_templates.iter().filter(|t| t.name == name).collect(); @@ -77,119 +73,121 @@ impl Project { } Ok(valid_task_templates[0].clone()) + */ } - pub async fn run_task(&self, client: &mut AmeClient, template_name: &str) -> Result<()> { + pub async fn run_task(&self, _client: &mut AmeClient, template_name: &str) -> Result<()> { let project_file: Project = serde_yaml::from_str(&fs::read_to_string("ame.yaml")?)?; - let task_template = project_file.get_task_template(template_name)?; - - // TODO: handle name clashes in the cluster. - let random_task_name = format!( - "{}{}", - template_name, - Alphanumeric.sample_string(&mut rand::thread_rng(), 6) - ) - .to_lowercase(); - - client - .create_task_project_directory(tonic::Request::new(TaskProjectDirectoryStructure::new( - &random_task_name, - &self.id, - vec![], - ))) - .await?; - - let _chunk_size = 500; - - for entry in walkdir::WalkDir::new(".").into_iter().flatten() { - if entry.metadata()?.is_dir() { - continue; - } - - let task_name = random_task_name.clone(); - - let Ok(mut f) = File::open(entry.clone().path()) else { - continue; - }; - - let mut buf: [u8; 100] = [0; 100]; - - // TODO: How do we test that files are uploaded transferred correctly? in the - // common ame-client library perhaps? - let stre = async_stream::stream! { - - yield ProjectFileChunk { - messages: Some(Messages::Identifier(ProjectFileIdentifier{ - taskid: task_name, - filepath:entry.clone().path().to_str().unwrap().to_string(), - })) - }; - - loop { - - let n = match f.read(&mut buf) { - Ok(0) => { - break; - } - - Ok(n) => n, - - // TODO: how do we handle errors here? - Err(_) => { - break; - }, - }; - - yield ProjectFileChunk { - messages: Some(Messages::Chunk(FileChunk { - contents: buf.get(0..n).unwrap().to_vec() - })), - }; - - buf = [0; 100]; - } - - }; - - client.upload_project_file(stre).await?; - } - - println!("uploaded project!"); - - client - .create_task(tonic::Request::new(CreateTaskRequest::new( - &random_task_name, - task_template, - ))) - .await?; - - tokio::time::sleep(Duration::from_secs(1)).await; - - let mut log_stream = client - .stream_task_logs(tonic::Request::new(TaskLogRequest::stream_from_beginning( - &random_task_name, - true, - ))) - .await? - .into_inner(); - - while let Some(entry) = log_stream.next().await { - let Ok(line) = String::from_utf8(entry.clone()?.contents) else { - println!("failed to parse log entry: {entry:?}"); - return Ok(()); - }; - - if line.contains("s3") || line.contains("argo") { - continue; - } - - if line.contains("WARNING:") { - break; - } + let _task_template = project_file.get_task_template(template_name)?; + Ok(()) - print!("{line}"); - } + // // TODO: handle name clashes in the cluster. + // let random_task_name = format!( + // "{}{}", + // template_name, + // Alphanumeric.sample_string(&mut rand::thread_rng(), 6) + // ) + // .to_lowercase(); + + // client + // .create_task_project_directory(tonic::Request::new(TaskProjectDirectoryStructure::new( + // &random_task_name, + // &self.id, + // vec![], + // ))) + // .await?; + + // let _chunk_size = 500; + + // for entry in walkdir::WalkDir::new(".").into_iter().flatten() { + // if entry.metadata()?.is_dir() { + // continue; + // } + + // let task_name = random_task_name.clone(); + + // let Ok(mut f) = File::open(entry.clone().path()) else { + // continue; + // }; + + // let mut buf: [u8; 100] = [0; 100]; + + // // TODO: How do we test that files are uploaded transferred correctly? in the + // // common ame-client library perhaps? + // let stre = async_stream::stream! { + + // yield ProjectFileChunk { + // messages: Some(Messages::Identifier(ProjectFileIdentifier{ + // taskid: task_name, + // filepath:entry.clone().path().to_str().unwrap().to_string(), + // })) + // }; + + // loop { + + // let n = match f.read(&mut buf) { + // Ok(0) => { + // break; + // } + + // Ok(n) => n, + + // // TODO: how do we handle errors here? + // Err(_) => { + // break; + // }, + // }; + + // yield ProjectFileChunk { + // messages: Some(Messages::Chunk(FileChunk { + // contents: buf.get(0..n).unwrap().to_vec() + // })), + // }; + + // buf = [0; 100]; + // } + + // }; + + // client.upload_project_file(stre).await?; + // } + + // println!("uploaded project!"); + + // client + // .create_task(tonic::Request::new(CreateTaskRequest::new( + // &random_task_name, + // task_template, + // ))) + // .await?; + + // tokio::time::sleep(Duration::from_secs(1)).await; + + // let mut log_stream = client + // .stream_task_logs(tonic::Request::new(TaskLogRequest::stream_from_beginning( + // &random_task_name, + // true, + // ))) + // .await? + // .into_inner(); + + // while let Some(entry) = log_stream.next().await { + // let Ok(line) = String::from_utf8(entry.clone()?.contents) else { + // println!("failed to parse log entry: {entry:?}"); + // return Ok(()); + // }; + + // if line.contains("s3") || line.contains("argo") { + // continue; + // } + + // if line.contains("WARNING:") { + // break; + // } + + // print!("{line}"); + // } - Ok(()) + // Ok(()) } } diff --git a/cli/src/project_cmd.rs b/cli/src/project_cmd.rs new file mode 100644 index 00000000..e5ead639 --- /dev/null +++ b/cli/src/project_cmd.rs @@ -0,0 +1,113 @@ +use ame::{ + client::native_client::build_ame_client, + grpc::{ + project_file_chunk::Messages, CreateProjectRequest, FileChunk, ProjectCfg, + ProjectFileChunk, ProjectFileIdentifier, + }, + AmeServiceClientCfg, +}; +use clap::Subcommand; + +use tokio::{fs::File, io::AsyncReadExt}; + +use crate::CliConfiguration; +use anyhow::Result; + +#[derive(Subcommand)] +pub enum ProjectCommands { + Push { + #[clap(short, long)] + triggers: bool, + }, + Delete, +} + +pub async fn exec_project_command(cfg: CliConfiguration, cmd: &ProjectCommands) -> Result<()> { + let mut client = build_ame_client(AmeServiceClientCfg { + disable_tls_cert_check: true, + endpoint: cfg.endpoint.parse().unwrap(), + id_token: cfg.id_token, + }) + .await?; + + let triggers = if let ProjectCommands::Push { triggers } = cmd { + *triggers + } else { + false + }; + + let project = ProjectCfg::try_from_working_dir()?; + + let project_id = client + .create_project(CreateProjectRequest { + cfg: Some(project), + enable_triggers: Some(triggers), + }) + .await? + .into_inner(); + + let _chunk_size = 500; + + let _is_tty = atty::is(atty::Stream::Stdout); + + println!("Uploading project!"); + + for entry in walkdir::WalkDir::new(".").into_iter().flatten() { + let project_id = project_id.clone(); + if entry.metadata()?.is_dir() { + continue; + } + + let Ok(mut f) = File::open(entry.clone().path()).await else { + continue; + }; + + println!("Uploading file: {}", entry.clone().path().to_str().unwrap()); + + let mut buf: [u8; 100] = [0; 100]; + + // TODO: How do we test that files are uploaded transferred correctly? in the + // common ame-client library perhaps? + let stre = async_stream::stream! { + + // TODO: using the taskid needs to be changed to project id. + yield ProjectFileChunk { + messages: Some(Messages::Identifier(ProjectFileIdentifier{ + taskid: project_id.name.clone(), + filepath:entry.clone().path().to_str().unwrap().to_string(), + })) + }; + + loop { + + let n = match f.read(&mut buf).await { + Ok(0) => { + break; + } + + Ok(n) => n, + + // TODO: how do we handle errors here? + Err(_) => { + break; + }, + }; + + yield ProjectFileChunk { + messages: Some(Messages::Chunk(FileChunk { + contents: buf.get(0..n).unwrap().to_vec() + })), + }; + + buf = [0; 100]; + } + + }; + + client.upload_project_file(stre).await?; + } + + println!("Done!"); + + Ok(()) +} diff --git a/cli/src/projectsrc.rs b/cli/src/projectsrc.rs index 866b7b0f..760e185e 100644 --- a/cli/src/projectsrc.rs +++ b/cli/src/projectsrc.rs @@ -1,12 +1,9 @@ use std::time::Duration; -use ame::grpc::GitProjectSource; -use ame::grpc::ProjectSourceCfg; -use ame::grpc::ProjectSourceListParams; -use ame::grpc::ProjectSourceState; -use ame::grpc::ProjectSourceStatus; -use ame::grpc::ProjectSrcIdRequest; -use ame::grpc::ProjectSrcPatchRequest; +use ame::grpc::{ + GitProjectSource, ProjectSourceCfg, ProjectSourceListParams, ProjectSourceState, + ProjectSourceStatus, ProjectSrcIdRequest, ProjectSrcPatchRequest, +}; use clap::Subcommand; use colored::Colorize; use futures_util::StreamExt; @@ -14,7 +11,7 @@ use spinners::Spinner; use tonic::Request; use crate::CliConfiguration; -use crate::Result; +use anyhow::Result; /// handles all operations on Project Sources. /// diff --git a/cli/src/secrets.rs b/cli/src/secrets.rs index f92ff58d..33d3c139 100644 --- a/cli/src/secrets.rs +++ b/cli/src/secrets.rs @@ -1,16 +1,18 @@ use std::time::Duration; -use ame::client::native_client::build_ame_client; -use ame::grpc::Empty; -use ame::grpc::{AmeSecret, AmeSecretId}; -use ame::AmeServiceClientCfg; +use ame::{ + client::native_client::build_ame_client, + grpc::{AmeSecret, AmeSecretId, Empty}, + AmeServiceClientCfg, +}; use clap::Subcommand; use colored::Colorize; use dialoguer::{Input, Password}; use spinners::Spinner; use tonic::Request; -use crate::{CliConfiguration, Result}; +use crate::CliConfiguration; +use anyhow::Result; /// Manage secrets in secret store. #[derive(Subcommand)] diff --git a/cli/src/task.rs b/cli/src/task.rs new file mode 100644 index 00000000..0bffe06a --- /dev/null +++ b/cli/src/task.rs @@ -0,0 +1,458 @@ +use std::cmp::max; + +use ame::{ + client::native_client::{build_ame_client, AmeClient}, + grpc::{ + CreateProjectRequest, ListTasksRequest, ProjectCfg, RunTaskRequest, TaskIdentifier, + TaskLogRequest, + }, + AmeServiceClientCfg, +}; +use anyhow::Result; +use clap::Subcommand; +use colored::Colorize; +use console::Term; +use dialoguer::theme::ColorfulTheme; +use futures_util::StreamExt; +use spinners::Spinner; +use tokio::{fs::File, io::AsyncReadExt}; +use tonic::Request; + +use crate::CliConfiguration; +use ame::grpc::{project_file_chunk::Messages, FileChunk, ProjectFileChunk, ProjectFileIdentifier}; +use dialoguer::FuzzySelect; + +/// Manage Tasks +#[derive(Subcommand)] +pub enum TaskCommand { + /// Run a Task present from your local project on a remote `AME` instance. + /// + /// Note: This command assumes that it is run from the root of an `AME` project directory. + /// Note: Every file in your project directory is uploaded. Options for ignoring files + /// will be implemented in the very near future, see https://github.com/TeaInSpace/ame/issues/151. + /// + /// This command functions in three steps: + /// 1. Upload your local project configuration from `ame.yaml` to `AME`. This will appear as project with your + /// local machine as the source. + /// + /// 2. Upload the files from the project directory to `AME's` object. + /// Note that all files will be uploaded. + /// + /// 3. Start the chosen Task with the uploaded Project and files as the context. + #[clap(verbatim_doc_comment)] + Run { + /// Name of the Task to run. + name: Option, + + /// Stream logs live while the Task runs. + #[clap(long)] + logs: bool, + }, + + /// Stream the logs a completed or running Task. + /// + /// Logs for a running Task will be streamed until the Task + /// stops. + Logs { + /// Name of Task to run. + name: Option, + }, + + /// List Tasks + /// + /// Note that this command list Tasks present in the `AME` instance, not in your `AME` file. + List {}, + + /// Remove a Task from the `AME` instance. + /// + /// Note that this is a destructive action and can not be recovered, use with care. + /// + /// If the Task is not approved for deletion removal will be blocked. + Remove { + name: Option, + + /// Automatically approve deletion, this can be very destructive use with care! + #[clap(long)] + approve: bool, + }, + + /// View the configuration for a Task + View { name: Option }, +} + +pub async fn select_task(client: &mut AmeClient) -> Result { + let tasks = client + .list_tasks(ListTasksRequest {}) + .await + .map_err(|e| crate::Error::from(e))? + .into_inner() + .tasks; + + let task_table: Vec = tasks + .clone() + .into_iter() + .map(|(k, v)| format!("{k} {}", v.time_stamp)) + .collect(); + + let task_names: Vec = tasks.into_keys().collect(); + + let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) + .items(&task_table) + .default(0) + .interact_on_opt(&Term::stderr())? + .unwrap(); + + Ok(task_names[selection].clone()) +} + +pub async fn logs(mut client: AmeClient) -> Result<()> { + let tasks = client + .list_tasks(ListTasksRequest {}) + .await + .map_err(|e| crate::Error::from(e))? + .into_inner() + .tasks; + + let task_table: Vec = tasks + .clone() + .into_iter() + .map(|(k, v)| format!("{k} {}", v.time_stamp)) + .collect(); + + let task_names: Vec = tasks.into_keys().collect(); + + let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) + .items(&task_table) + .default(0) + .interact_on_opt(&Term::stderr())? + .unwrap(); + + let task_name = task_names[selection].clone(); + + let mut log_stream = client + .stream_task_logs(tonic::Request::new(TaskLogRequest::stream_from_beginning( + &task_name, true, + ))) + .await? + .into_inner(); + + while let Some(entry) = log_stream.next().await { + let Ok(line) = String::from_utf8(entry.clone()?.contents) else { + println!("failed to parse log entry: {entry:?}"); + return Ok(()); + }; + + print!("{line}"); + } + + Ok(()) +} + +async fn exec_task_rm( + mut client: AmeClient, + name: Option, + approve: Option, +) -> Result<()> { + let task_name = if let Some(name) = name { + name + } else { + select_task(&mut client).await? + }; + + client + .remove_task(Request::new(ame::grpc::RemoveTaskRequest { + name: task_name.clone(), + approve, + })) + .await?; + + println!("{task_name} {}", "Deleted".red().bold()); + + return Ok(()); +} + +struct Table { + rows: Vec>, + sort: bool, +} + +impl Table { + pub fn new(header: Vec, rows: Vec>) -> Self { + let mut row = vec![header]; + row.extend(rows); + + Self { + rows: row, + sort: false, + } + } + + fn sort(&mut self, sort: bool) -> &mut Self { + self.sort = sort; + self + } + + fn row_len(&self) -> usize { + self.rows.get(0).map(|r| r.len()).unwrap_or(0) + } + + pub fn try_string_colored(&self) -> Result { + if self.rows.iter().any(|row| (row.len() != self.row_len())) { + todo!("error"); + } + + let widths: Vec = + self.rows + .iter() + .fold(vec![0 as usize; self.row_len()], |acc, r| { + acc.iter() + .zip(r.iter()) + .map(|(acc_l, v)| max(*acc_l, v.chars().count())) + .collect() + }); + + let headers: String = self.rows[0] + .iter() + .zip(widths.clone()) + .map(|(v, width)| { + format!( + "{}{} ", + v.white().bold(), + vec![" "; width - v.chars().count()].join("") + ) + }) + .collect(); + + let mut rows = self.rows.as_slice().split_first().unwrap().1.to_owned(); + + if self.sort { + rows.sort_by_key(|row| row[0].clone()); + } + + let rows: String = rows.iter().fold("\n".to_string(), |acc, row| { + acc + &row + .iter() + .zip(widths.clone()) + .map(|(v, width)| { + format!("{}{} ", v, vec![" "; width - v.chars().count()].join("")) + }) + .collect::() + + "\n" + }); + + Ok(headers + &rows) + } +} + +async fn exec_task_view(mut client: AmeClient, name: Option) -> Result<()> { + let task_name = if let Some(name) = name { + name + } else { + select_task(&mut client).await? + }; + + let task_info = client + .get_task(Request::new(TaskIdentifier { name: task_name })) + .await? + .into_inner(); + + println!("{}", serde_yaml::to_string(&task_info)?); + + Ok(()) +} + +async fn exec_task_list(mut client: AmeClient) -> Result<()> { + let tasks = client.list_tasks(Request::new(ListTasksRequest {})).await?; + let _widths: Vec = vec![0, 0]; + + let mut table = Table::new( + vec![ + "Name".to_string(), + "Status".to_string(), + "Project".to_string(), + ], + tasks + .into_inner() + .tasks + .iter() + .map(|t| { + vec![ + t.0.to_string(), + t.1.status + .as_ref() + .unwrap() + .phase + .as_ref() + .unwrap() + .to_string(), + "Unknown".to_string(), + ] + }) + .collect(), + ); + + table.sort(true); + + println!("{}", table.try_string_colored()?); + + return Ok(()); +} + +pub async fn exec_task_command(cfg: CliConfiguration, cmd: &TaskCommand) -> Result<()> { + let mut client = build_ame_client(AmeServiceClientCfg { + disable_tls_cert_check: true, + endpoint: cfg.endpoint.parse().unwrap(), + id_token: cfg.id_token, + }) + .await?; + + match cmd { + TaskCommand::Logs { name: _ } => { + return logs(client).await; + } + TaskCommand::List {} => { + return exec_task_list(client).await; + } + TaskCommand::Remove { name, approve } => { + return exec_task_rm(client, name.to_owned(), Some(*approve)).await; + } + TaskCommand::View { name } => { + return exec_task_view(client, name.to_owned()).await; + } + _ => (), + }; + + let project = ProjectCfg::try_from_working_dir()?; + + let (task_cfg, display_logs) = if let TaskCommand::Run { + name: Some(ref name), + logs: display_logs, + } = cmd + { + // TODO: migrate CLI code to use common error type. + (project.get_task_cfg(name).unwrap(), *display_logs) + } else { + let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) + .items(&project.task_names()) + .default(0) + .interact_on_opt(&Term::stderr())? + .unwrap(); + + ( + project + .get_task_cfg(&project.task_names()[selection]) + .unwrap(), + false, + ) + }; + + let is_tty = atty::is(atty::Stream::Stdout); + + if is_tty { + let _spinner = Spinner::new( + spinners::Spinners::Dots, + format!(" {} project", "Uploading".cyan().bold()), + ); + } else { + println!("Uploading project!") + } + + let project_id = client + .create_project(CreateProjectRequest { + cfg: Some(project), + enable_triggers: Some(false), + }) + .await? + .into_inner(); + + let task_name = task_cfg.clone().name.unwrap(); + + let _chunk_size = 500; + + for entry in walkdir::WalkDir::new(".").into_iter().flatten() { + let project_id = project_id.clone(); + if entry.metadata()?.is_dir() { + continue; + } + + let Ok(mut f) = File::open(entry.clone().path()).await else { + continue; + }; + + let _task_name = task_name.clone(); + + let mut buf: [u8; 100] = [0; 100]; + + // TODO: How do we test that files are uploaded transferred correctly? in the + // common ame-client library perhaps? + let stre = async_stream::stream! { + + // TODO: using the taskid needs to be changed to project id. + yield ProjectFileChunk { + messages: Some(Messages::Identifier(ProjectFileIdentifier{ + taskid: project_id.name.clone(), + filepath:entry.clone().path().to_str().unwrap().to_string(), + })) + }; + + loop { + + let n = match f.read(&mut buf).await { + Ok(0) => { + break; + } + + Ok(n) => n, + + // TODO: how do we handle errors here? + Err(_) => { + break; + }, + }; + + yield ProjectFileChunk { + messages: Some(Messages::Chunk(FileChunk { + contents: buf.get(0..n).unwrap().to_vec() + })), + }; + + buf = [0; 100]; + } + + }; + + client.upload_project_file(stre).await?; + } + + let task_id = client + .run_task(RunTaskRequest { + project_id: Some(project_id), + task_cfg: Some(task_cfg), + }) + .await?; + + if display_logs { + let mut log_stream = client + .stream_task_logs(tonic::Request::new(TaskLogRequest::stream_from_beginning( + &task_id.into_inner().name, + true, + ))) + .await? + .into_inner(); + + while let Some(entry) = log_stream.next().await { + // TODO: What to do with errors here instead of default? + let Ok(line) = String::from_utf8(entry.clone().unwrap_or_default().contents) else { + println!("failed to parse log entry: {entry:?}"); + return Ok(()); + }; + + print!("{line}"); + } + } + + // TODO: handle ignoring large files. + + Ok(()) + // todo!() +} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 4826f03c..3a8c90d4 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -1,24 +1,19 @@ -use ame::custom_resources::project::{ModelValidationStatus, Project}; -use ame::custom_resources::secrets::SecretCtrl; use ame::custom_resources::{ common::{find_ame_endpoint, private_repo_gh_pat, setup_cluster}, project_source_ctrl::ProjectSrcCtrl, + secrets::SecretCtrl, }; use assert_cmd::prelude::*; use assert_fs::prelude::*; -use fs_extra::dir::CopyOptions; - -use futures_util::StreamExt; +use fs_extra::dir::CopyOptions; use insta::assert_snapshot; -use k8s_openapi::api::apps::v1::Deployment; -use kube::api::ListParams; -use kube::runtime::{watcher, WatchStreamExt}; -use kube::Api; + +use anyhow::anyhow; use kube::Client; use rstest::*; use serial_test::serial; -use std::time::Duration; + use std::{ path::{Path, PathBuf}, process::Command, @@ -39,45 +34,13 @@ async fn test_setup() -> Result<(), Box> { Ok(()) } -fn prepare_test_project(path_from_root: &str) -> Result> { - let mut test_project_path = PathBuf::from(path_from_root); - - for i in 0..10 { - if test_project_path.is_dir() && i < 9 { - break; - } - - if i < 9 { - test_project_path = Path::new("..").join(test_project_path); - continue; - } - - return Err(format!( - "failed to find test project directory: {}", - test_project_path.display() - ))?; - } - - let temp_dir = assert_fs::TempDir::new()?.into_persistent(); - - fs_extra::copy_items( - &[test_project_path], - temp_dir.path(), - &CopyOptions::default(), - )?; - - Ok(temp_dir - .path() - .join(Path::new(path_from_root).file_name().unwrap())) -} - #[test] fn ame_file_already_exists() -> Result<(), Box> { let temp = assert_fs::TempDir::new()?; let test_file = temp.child(AME_FILE_NAME); test_file.touch()?; - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; cmd.current_dir(temp.path()) .arg("init") @@ -85,7 +48,7 @@ fn ame_file_already_exists() -> Result<(), Box> { .assert() .success(); - // If a file already exists we expect the CLI to inform the user and exist gracefully. + // If a file already exists we expect the CLI to inform the user and exit gracefully. // Therefore nothing should be written. test_file.assert(""); @@ -96,7 +59,7 @@ fn ame_file_already_exists() -> Result<(), Box> { fn ame_file_does_not_exist() -> Result<(), Box> { let temp = assert_fs::TempDir::new()?; let test_file = temp.child(AME_FILE_NAME); - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; let project_id = "myproject"; cmd.current_dir(temp.path()) @@ -110,58 +73,6 @@ fn ame_file_does_not_exist() -> Result<(), Box> { Ok(()) } -#[rstest] -#[case("test_data/test_projects/new_echo", "echo")] -#[case("test_data/test_projects/sklearn_logistic_regression", "training")] -#[ignore] -#[tokio::test] -async fn ame_run_task( - #[case] project_dir: &str, - #[case] task_id: &str, -) -> Result<(), Box> { - setup_cluster("ame-system").await?; - let temp = prepare_test_project(project_dir)?; - let mut cmd = Command::cargo_bin("cli")?; - test_setup().await?; - - let res = cmd - .current_dir(temp) - .arg("run") - .arg(task_id) - .assert() - .success(); - - let mut settings = insta::Settings::clone_current(); - settings.add_filter("time=\".*\"", "timestamp=\"redacted\""); - settings.add_filter( - "created virtual environment .*", - "created virtual environment \"redacted\"", - ); - settings.add_filter("creator .*", "creator \"redacted\""); - settings.add_filter( - "\\d\\d\\d\\d/\\d\\d/\\d\\d \\d\\d:\\d\\d:\\d\\d", - "\"redacted timestamp\"", - ); - settings.add_filter("run .*", "\"redacted run ID\""); - settings.add_filter("ID '.*'", "\"redacted run ID\""); - settings.add_filter("tmp/tmp.*\\s", "\"redacted temporary directory\""); - settings.add_filter("mlflow-.*/", "\"redacted MLflow env ID\""); - settings.add_filter(".*: UserWarning: .*\\n", ""); - settings.add_filter("warnings\\.warn.*\\n", ""); - settings.add_filter(" \"redacted timestamp", "\"redacted timestamp"); - settings.add_filter(" Score:", "Score:"); - settings.add_filter("added seed packages.*", "redacted"); - settings.add_filter("Registered model '.*' already exists.*", "redacted"); - settings.add_filter("Created version '.'.*\\n", ""); - settings.add_filter(", version .", ", version %"); - settings.add_filter("Successfully registered model .*", "redacted"); - let _guard = settings.bind_to_scope(); - - insta::assert_snapshot!(&String::from_utf8(res.get_output().stdout.clone())?); - - Ok(()) -} - #[tokio::test] #[serial] async fn ame_setup_cli() -> Result<(), Box> { @@ -176,7 +87,7 @@ async fn ame_setup_cli() -> Result<(), Box> { temp_env::with_vars( vec![("AME_ENDPOINT", None), ("XDG_CONFIG_HOME", Some(temp_path))], || { - Command::cargo_bin("cli") + Command::cargo_bin("ame") .unwrap() .current_dir(temp_path) .arg("setup") @@ -201,7 +112,7 @@ async fn fail_bad_server_endpoint() -> Result<(), Box> { temp_env::with_vars( vec![("AME_ENDPOINT", None), ("XDG_CONFIG_HOME", Some(temp_path))], || { - let output = Command::cargo_bin("cli") + let output = Command::cargo_bin("ame") .unwrap() .current_dir(temp_path) .arg("setup") @@ -220,6 +131,129 @@ async fn kube_client() -> Result { Client::try_default().await } +fn prepare_test_project(path_from_root: &str) -> anyhow::Result { + let mut test_project_path = PathBuf::from(format!("test_data/test_projects/{path_from_root}")); + + for i in 0..10 { + if test_project_path.is_dir() && i < 9 { + break; + } + + if i < 9 { + test_project_path = Path::new("..").join(test_project_path); + continue; + } + + return Err(anyhow!( + "failed to find test project directory: {}", + test_project_path.display() + )); + } + + let temp_dir = assert_fs::TempDir::new()?.into_persistent(); + + fs_extra::copy_items( + &[test_project_path], + temp_dir.path(), + &CopyOptions::default(), + )?; + + Ok(temp_dir + .path() + .join(Path::new(path_from_root).file_name().unwrap())) +} + +// TODO; test failure cases and messages +#[tokio::test] +#[cfg(ignored)] +async fn can_remove_task() -> anyhow::Result<()> { + let _ = setup_cluster(AME_NAMESPACE).await; + let temp = prepare_test_project("executors/poetry")?; + let mut cmd = Command::cargo_bin("ame")?; + + cmd.current_dir(temp.clone()); + + cmd.arg("task") + .arg("run") + .arg("training") + .assert() + .success(); + + let tasks: Api = Api::namespaced(kube_client().await?, AME_NAMESPACE); + + let task_name = tasks.list(&ListParams::default()).await?.items[0].name_any(); + let mut cmd = Command::cargo_bin("ame")?; + + cmd.current_dir(temp); + + cmd.args(["task", "remove", "--approve", &task_name]) + .assert() + .success(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + let task_list = tasks.list(&ListParams::default()).await?.items; + assert!( + task_list.is_empty(), + "found tasks {:?}", + task_list + .iter() + .map(|t| t.name_any()) + .collect::>() + ); + + Ok(()) +} + +#[tokio::test] +#[cfg(ignored)] +async fn snap_shot_task_view() -> anyhow::Result<()> { + let _ = setup_cluster(AME_NAMESPACE).await; + let temp = prepare_test_project("executors/poetry")?; + let mut cmd = Command::cargo_bin("ame")?; + + cmd.current_dir(temp.clone()); + + cmd.arg("task") + .arg("run") + .arg("training") + .assert() + .success(); + + let tasks: Api = Api::namespaced(kube_client().await?, AME_NAMESPACE); + let task_name = tasks.list(&ListParams::default()).await?.items[0].name_any(); + let mut cmd = Command::cargo_bin("ame")?; + + cmd.current_dir(temp); + + let res = cmd.args(["task", "view", &task_name]).assert(); + + insta::assert_snapshot!(String::from_utf8(res.get_output().stdout.clone())?); + + Ok(()) +} + +#[rstest] +#[case::invalid_project_file("invalid_project", &["task", "run"])] +#[case::missing_project_file("missing_project", &["task", "run"])] +// #[case::can_reach_server("invalid_project", &["task", "logs"])] +fn snap_shot_test_cli_error_messages( + #[case] project_dir: &str, + #[case] args: &[&str], +) -> anyhow::Result<()> { + let temp = prepare_test_project(project_dir)?; + let mut cmd = Command::cargo_bin("ame")?; + + let res = cmd.current_dir(temp.clone()).args(args).assert().failure(); + + insta::assert_snapshot!( + format!("{}{}", project_dir.to_string(), args.join("").to_string()), + &String::from_utf8(res.get_output().stderr.clone())? + ); + + Ok(()) +} + #[rstest] #[case::public_repo(vec!["https://github.com/TeaInSpace/ame-demo.git"], true)] #[case::none_existent_repo(vec!["https://github.com/TeaInSpace/fake-repo.git"], false)] @@ -237,6 +271,8 @@ async fn can_create_project_source( settings.add_filter("([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏].*)", ""); let _guard = settings.bind_to_scope(); + let _ = setup_cluster(AME_NAMESPACE).await; + test_setup().await?; let secret_ctrl = SecretCtrl::new(kube_client.await?, AME_NAMESPACE); @@ -245,7 +281,7 @@ async fn can_create_project_source( .store_secret_if_empty(PRIVATE_GH_REPO_SECRET_KEY, private_repo_gh_pat()?) .await?; - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; let output = cmd .arg("projectsrc") @@ -270,7 +306,7 @@ async fn can_create_project_source( } let src_ctrl = ProjectSrcCtrl::try_namespaced(AME_NAMESPACE).await?; - src_ctrl.delete_project_src_for_repo(args[0]).await?; + let _ = src_ctrl.delete_project_src_for_repo(args[0]).await; secret_ctrl .delete_secret(PRIVATE_GH_REPO_SECRET_KEY) @@ -290,7 +326,7 @@ async fn cannot_create_multiple_sources_for_the_same_repo() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> -{ - test_setup().await?; - - // The template repo is required as the ame-demo requires it to train. - let template_repo = "https://github.com/TeaInSpace/ame-template-demo.git"; - let data_set_repo = "https://github.com/TeaInSpace/ame-dataset-demo.git"; - let repo = "https://github.com/TeaInSpace/ame-demo.git"; - let model_name = "logreg"; // this name from the ame-demo repo. - let project_src_ctrl = ProjectSrcCtrl::new(kube_client().await?, AME_NAMESPACE); - - let kube_client = kube_client().await?; - let deployments: Api = Api::namespaced(kube_client.clone(), AME_NAMESPACE); - let projects: Api = Api::namespaced(kube_client.clone(), AME_NAMESPACE); - // TODO: whyid this no throw an error with AME_FILE_NAME. - let secret_ctrl = SecretCtrl::try_default(AME_NAMESPACE).await?; - let s3_secret = "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG"; - - secret_ctrl - .store_secret_if_empty("s3secretkey", s3_secret.to_string()) - .await?; - - let _ = project_src_ctrl.delete_project_src_for_repo(repo).await; - let _ = project_src_ctrl - .delete_project_src_for_repo(data_set_repo) - .await; - let _ = project_src_ctrl - .delete_project_src_for_repo(template_repo) - .await; - - let mut cmd = Command::cargo_bin("cli")?; - - let _output = cmd - .arg("projectsrc") - .arg("create") - .arg(template_repo) - .assert() - .success(); - - let mut cmd = Command::cargo_bin("cli")?; - - let _output = cmd - .arg("projectsrc") - .arg("create") - .arg(data_set_repo) - .assert() - .success(); - - let mut cmd = Command::cargo_bin("cli")?; - - let _output = cmd - .arg("projectsrc") - .arg("create") - .arg(repo) - .assert() - .success(); - - // Note that the model will start training now if now version is present. - // We will need to check for this in future tests. - - // It is important that we only have 2 projects as the watcher - // does not perform any filtering. - assert_eq!( - projects - .list_metadata(&ListParams::default()) - .await? - .items - .len(), - 3 - ); - - // No deployment for the model should be present. - assert!(deployments.get(model_name).await.is_err()); - - let mut project_watcher = watcher(projects, ListParams::default()) - .applied_objects() - .boxed(); - - while let Some(e) = project_watcher.next().await { - let Some(mut status) = e?.status else { - continue; - }; - - let Some(model_status) = status.get_model_status(model_name) else { - continue ; - }; - - match model_status.validation { - Some(ModelValidationStatus::Validated { .. }) => break, - Some(ModelValidationStatus::FailedValidation { .. }) => { - return Err("model failed validation".into()); - } - _ => (), - }; - } - - let mut deployment_watcher = watcher( - deployments, - ListParams::default().fields(&format!("metadata.name=={model_name}")), - ) - .applied_objects() - .boxed(); - - let timeout = Duration::from_secs(60); - let start = std::time::Instant::now(); - - while let Some(e) = deployment_watcher.next().await { - let Some(status) = e?.status else { - return Err("missing deployment status".into()); - }; - - if let Some(1) = status.ready_replicas { - break; - } - - if start.elapsed() > timeout { - return Err("failed to deploy model within timeout".into()); - } - } - - project_src_ctrl.delete_project_src_for_repo(repo).await?; - project_src_ctrl - .delete_project_src_for_repo(data_set_repo) - .await?; - project_src_ctrl - .delete_project_src_for_repo(template_repo) - .await?; - - Ok(()) -} - #[ignore] #[tokio::test] #[serial] @@ -459,7 +361,7 @@ async fn can_delete_project_src() -> Result<(), Box> { )) .await?; - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; cmd.arg("projectsrc") .arg("delete") .arg(repo) @@ -489,7 +391,7 @@ async fn can_list_project_srcs() -> Result<(), Box> { .await?; } - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; let output = cmd.arg("projectsrc").arg("list").assert().success(); assert_snapshot!(&String::from_utf8(output.get_output().stdout.clone())?); @@ -517,7 +419,7 @@ async fn can_edit_project_src() -> Result<(), Box> { )) .await?; - let mut cmd = Command::cargo_bin("cli")?; + let mut cmd = Command::cargo_bin("ame")?; cmd.arg("projectsrc") .arg("edit") .arg(repo) diff --git a/cli/tests/snapshots/cli__ame_run_task-2.snap b/cli/tests/snapshots/cli__ame_run_task-2.snap index d496d256..f30bb310 100644 --- a/cli/tests/snapshots/cli__ame_run_task-2.snap +++ b/cli/tests/snapshots/cli__ame_run_task-2.snap @@ -3,9 +3,6 @@ source: cli/tests/cli.rs expression: "&String::from_utf8(res.get_output().stdout.clone())?" --- uploaded project! -"redacted timestamp" INFO mlflow.utils.virtualenv: Installing python 3.8.10 if it does not exist -Downloading Python-3.8.10.tar.xz... --> https://www.python.org/ftp/python/3.8.10/Python-3.8.10.tar.xz Installing Python-3.8.10... patching file Misc/NEWS.d/next/Build/2021-10-11-16-27-38.bpo-45405.iSfdW5.rst patching file configure @@ -20,7 +17,7 @@ created virtual environment "redacted" "redacted timestamp" INFO mlflow.utils.virtualenv: Installing dependencies "redacted timestamp" INFO mlflow.projects.utils: === Created directory /"redacted temporary directory""redacted timestamp" INFO mlflow.projects.backend.local: === Running command 'source /home/ame/.mlflow/envs/"redacted MLflow env ID"activate && python train.py' in "redacted run ID" redacted -"redacted timestamp" INFO mlflow.tracking._model_registry.client: Waiting up to 300 seconds for model version to finish creation. Model name: test_model, version % +"redacted timestamp" INFO mlflow.tracking._model_registry.client: Waiting up to 300 seconds for model version to finish creation. Model name: test_model, version % Score: 0.6666666666666666 Model saved in "redacted run ID" "redacted timestamp" INFO mlflow.projects: === Run ("redacted run ID") succeeded === diff --git a/cli/tests/snapshots/cli__ame_run_task.snap b/cli/tests/snapshots/cli__ame_run_task.snap index 54a6acf6..ab62953e 100644 --- a/cli/tests/snapshots/cli__ame_run_task.snap +++ b/cli/tests/snapshots/cli__ame_run_task.snap @@ -2,6 +2,27 @@ source: cli/tests/cli.rs expression: "&String::from_utf8(res.get_output().stdout.clone())?" --- -uploaded project! -myoutput +Uploading project! +download: 's3://ame/tasks/redacted/projectfiles/ame.yaml' -> './ame.yaml' (149 bytes in redactedseconds, redactedKB/s) +download: 's3://ame/tasks/redacted/projectfiles/nn.py' -> './nn.py' (58 bytes in redactedseconds, redactedKB/s) +download: 's3://ame/tasks/redacted/projectfiles/poetry.lock' -> './poetry.lock' (4587 bytes in redactedseconds, redactedMB/s) +download: 's3://ame/tasks/redacted/projectfiles/pyproject.toml' -> './pyproject.toml' (297 bytes in redactedseconds, redactedKB/s) +/home/ame/.pyenv/shims:/home/ame/.pyenv/bin:/home/ame/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Downloading Python-3.10.9.tar.xz... +-> https://www.python.org/ftp/python/3.10.9/Python-3.10.9.tar.xz +Installing Python-3.10.9... +Installed Python-3.redactedto /home/ame/.pyenv/versions/3.10.9 +The currently activated Python version 3.redactedis not supported by the project (3.10.9). +Trying to find and use a compatible version. +Using python3 (3.10.9) +Creating virtualenv test-6gE1vKXj-pyredactedin /home/ame/.cache/pypoetry/virtualenvs +Installing dependencies from lock file + +Package operations: 1 install, 0 updates, 0 removals + + • Installing numpy (1.24.3) +The currently activated Python version 3.redactedis not supported by the project (3.10.9). +Trying to find and use a compatible version. +Using python3 (3.10.9) +np array: [1 2 3] diff --git a/cli/tests/snapshots/cli__invalid_projecttasklogs.snap b/cli/tests/snapshots/cli__invalid_projecttasklogs.snap new file mode 100644 index 00000000..0b1fd307 --- /dev/null +++ b/cli/tests/snapshots/cli__invalid_projecttasklogs.snap @@ -0,0 +1,11 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: status: Unavailable, message: "error trying to connect: tcp connect error: Connection refused (os error 111)", details: [], metadata: MetadataMap { headers: {} } + +Caused by: + 0: error trying to connect: tcp connect error: Connection refused (os error 111) + 1: tcp connect error: Connection refused (os error 111) + 2: Connection refused (os error 111) + diff --git a/cli/tests/snapshots/cli__invalid_projecttaskrun.snap b/cli/tests/snapshots/cli__invalid_projecttaskrun.snap new file mode 100644 index 00000000..b0e847c3 --- /dev/null +++ b/cli/tests/snapshots/cli__invalid_projecttaskrun.snap @@ -0,0 +1,9 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: Could not parse ame.yaml :( + +Caused by: + name: invalid type: sequence, expected a string at line 1 column 7 + diff --git a/cli/tests/snapshots/cli__missing_projecttaskrun.snap b/cli/tests/snapshots/cli__missing_projecttaskrun.snap new file mode 100644 index 00000000..404ff4fa --- /dev/null +++ b/cli/tests/snapshots/cli__missing_projecttaskrun.snap @@ -0,0 +1,9 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: Could not read ame.yaml, are you in an AME project? + +Caused by: + No such file or directory (os error 2) + diff --git a/cli/tests/snapshots/cli__snap_shot_task_view.snap b/cli/tests/snapshots/cli__snap_shot_task_view.snap new file mode 100644 index 00000000..f74c75c3 --- /dev/null +++ b/cli/tests/snapshots/cli__snap_shot_task_view.snap @@ -0,0 +1,13 @@ +--- +source: cli/tests/cli.rs +expression: "String::from_utf8(res.get_output().stdout.clone())?" +--- +name: training +resources: {} +env: [] +secrets: [] +executor: !poetry + pythonVersion: 3.10.9 + command: python nn.py + + diff --git a/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-2.snap b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-2.snap new file mode 100644 index 00000000..404ff4fa --- /dev/null +++ b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-2.snap @@ -0,0 +1,9 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: Could not read ame.yaml, are you in an AME project? + +Caused by: + No such file or directory (os error 2) + diff --git a/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-3.snap b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-3.snap new file mode 100644 index 00000000..0b1fd307 --- /dev/null +++ b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages-3.snap @@ -0,0 +1,11 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: status: Unavailable, message: "error trying to connect: tcp connect error: Connection refused (os error 111)", details: [], metadata: MetadataMap { headers: {} } + +Caused by: + 0: error trying to connect: tcp connect error: Connection refused (os error 111) + 1: tcp connect error: Connection refused (os error 111) + 2: Connection refused (os error 111) + diff --git a/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages.snap b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages.snap new file mode 100644 index 00000000..b0e847c3 --- /dev/null +++ b/cli/tests/snapshots/cli__snap_shot_test_cli_error_messages.snap @@ -0,0 +1,9 @@ +--- +source: cli/tests/cli.rs +expression: "&String::from_utf8(res.get_output().stderr.clone())?" +--- +Error: Could not parse ame.yaml :( + +Caused by: + name: invalid type: sequence, expected a string at line 1 column 7 + diff --git a/controller/Cargo.toml b/controller/Cargo.toml index f273d309..adf4e45f 100644 --- a/controller/Cargo.toml +++ b/controller/Cargo.toml @@ -29,26 +29,30 @@ name = "data_set_crdgen" path = "src/data_set_crd_gen.rs" [dependencies] -ame = { version = "0.1.0", path = "../lib", features = ["ame-control", "custom-resources"] } -async-trait = "0.1.66" +ame = { version = "0.1.0", path = "../lib", features = ["ame-control", "custom-resources", "project-tools"] } +async-trait = "0.1.72" +chrono = "0.4.26" +cron-parser = "0.8.0" duration-string = "0.2.0" -either = "1.8.0" +either = "1.9.0" envconfig = "0.10.0" -futures = "0.3.25" -git2 = "0.17.0" +futures = "0.3.28" +git2 = "0.17.2" humantime = "2.1.0" k8s-openapi = { version = "0.17.0", features = ["v1_23", "schemars" ] } kube = { version = "0.80.0", features = ["runtime", "client", "derive"] } -reqwest = { version = "0.11.14", features = ["json"] } -schemars = "0.8.11" -serde = {version = "1.0.147", features = ["derive"]} -serde_json = "1.0.87" -serde_yaml = "0.9.14" -thiserror = "1.0.37" -tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] } +reqwest = { version = "0.11.18", features = ["json"] } +schemars = "0.8.12" +serde = {version = "1.0.180", features = ["derive"]} +serde_json = "1.0.104" +serde_merge = "0.1.3" +serde_yaml = "0.9.25" +thiserror = "1.0.44" +tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread"] } tonic = "0.8.3" tracing = "0.1.37" -tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } [dev-dependencies] k8s-openapi = { version = "0.17.0", features = ["v1_23", "schemars" ] } +insta = { version = "1.31.0", features = ["filters", "redactions", "yaml"] } diff --git a/controller/src/crdgen.rs b/controller/src/crdgen.rs index 438a77dd..8fd17db9 100644 --- a/controller/src/crdgen.rs +++ b/controller/src/crdgen.rs @@ -1,4 +1,4 @@ -use ame::custom_resources::task::Task; +use ame::custom_resources::new_task::Task; use kube::CustomResourceExt; fn main() { diff --git a/controller/src/data_set.rs b/controller/src/data_set.rs index 7603f5aa..68e0d009 100644 --- a/controller/src/data_set.rs +++ b/controller/src/data_set.rs @@ -1,20 +1,21 @@ -use ame::custom_resources::task::Task; -use std::sync::Arc; -use std::time::Duration; +use ame::custom_resources::{common::parent_project, new_task::Task, project::Project}; +use std::{sync::Arc, time::Duration}; -use ame::custom_resources::data_set::{DataSet, DataSetPhase, DataSetSpec, DataSetStatus}; -use ame::error::AmeError; +use ame::{ + custom_resources::data_set::{DataSet, DataSetPhase, DataSetStatus}, + error::AmeError, +}; use ame::Result; -use futures::future::BoxFuture; -use futures::{FutureExt, StreamExt}; +use futures::{future::BoxFuture, FutureExt, StreamExt}; use kube::api::{ListParams, Patch, PatchParams}; -use kube::core::ObjectMeta; -use kube::runtime::controller::Action; -use kube::runtime::{finalizer, Controller}; -use kube::{Api, Client, ResourceExt}; + +use kube::{ + runtime::{controller::Action, finalizer, Controller}, + Api, Client, Resource, ResourceExt, +}; use std::default::Default; -use tracing::{error, info}; +use tracing::{debug, error, info}; static DATA_SET_CONTROLLER: &str = "datasets.ame.teainspace.com"; @@ -42,13 +43,16 @@ pub struct DataSetControllerCfg { async fn reconcile(data_set: Arc, ctx: Arc) -> Result { let data_sets = Api::::namespaced(ctx.client.clone(), &ctx.namespace); let tasks = Api::::namespaced(ctx.client.clone(), &ctx.namespace); + let projects = Api::::namespaced(ctx.client.clone(), &ctx.namespace); info!("reconciling data set {}", data_set.name_any()); Ok( finalizer(&data_sets, DATA_SET_CONTROLLER, data_set, |event| async { match event { - finalizer::Event::Apply(data_set) => apply(&data_set, &data_sets, &tasks).await, + finalizer::Event::Apply(data_set) => { + apply(&data_set, &data_sets, &tasks, &projects).await + } finalizer::Event::Cleanup(data_set) => cleanup(&data_set).await, } }) @@ -68,7 +72,7 @@ pub async fn start_data_set_controller( let context = Arc::new(Context::from(config.clone())); let data_sets = Api::::namespaced(config.client.clone(), &config.namespace); - data_sets.list(&ListParams::default()).await?; + data_sets.list(&ListParams::default()).await.unwrap(); let tasks = Api::::namespaced(config.client.clone(), &config.namespace); @@ -80,9 +84,24 @@ pub async fn start_data_set_controller( .boxed()) } -async fn apply(data_set: &DataSet, data_sets: &Api, tasks: &Api) -> Result { - let task = data_set.generate_task()?; +async fn apply( + data_set: &DataSet, + data_sets: &Api, + tasks: &Api, + projects: &Api, +) -> Result { + let mut task = data_set.generate_task()?; + + let parent_project = projects + .get(&parent_project(data_set.owner_references().to_vec())?) + .await?; + + let mut project_oref = parent_project.controller_owner_ref(&()).unwrap(); + project_oref.controller = Some(false); + + task.owner_references_mut().push(project_oref); + debug!("patching data set task: {:?}", task); let task = tasks .patch( &task.name_any(), @@ -92,18 +111,22 @@ async fn apply(data_set: &DataSet, data_sets: &Api, tasks: &Api) .await?; let status = DataSetStatus { - phase: DataSetPhase::from_task(task), + phase: Some(DataSetPhase::from_task(task)), }; + debug!("patching data set status {:?} ", status.clone()); + + let mut data_set = data_set.clone(); + + // let mut data_set = data_set.clone(); + data_set.metadata.managed_fields = None; + data_set.status = Some(status.clone()); + data_sets .patch_status( &data_set.name_any(), - &PatchParams::default(), - &Patch::Merge(&DataSet { - metadata: ObjectMeta::default(), - status: Some(status), - spec: DataSetSpec::default(), - }), + &PatchParams::apply(DATA_SET_CONTROLLER).force(), + &Patch::Apply(&data_set), ) .await?; @@ -124,35 +147,48 @@ async fn cleanup(data_set: &DataSet) -> Result { mod test { use ame::{ custom_resources::data_set::DataSetSpec, - grpc::{DataSetCfg, TaskCfg, TaskRef}, + grpc::{DataSetCfg, ProjectCfg, TaskCfg, TaskRef}, }; use kube::{ api::{DeleteParams, PostParams}, core::ObjectMeta, + ResourceExt, }; use super::*; - use std::time::Duration; + use std::{collections::BTreeMap, time::Duration}; #[tokio::test] #[ignore = "requires a k8s cluster"] async fn can_create_data_set_task_and_finalize_data_set() -> Result<()> { let client = Client::try_default().await?; let namespace = "default".to_string(); - let _tasks = Api::::namespaced(client.clone(), &namespace); let data_sets = Api::::namespaced(client.clone(), &namespace); + let projects = Api::::namespaced(client.clone(), &namespace); let context = super::Context { client: client.clone(), namespace, }; + let project = ProjectCfg { + name: "parentproject".to_string(), + models: vec![], + data_sets: vec![], + tasks: vec![], + templates: vec![], + enable_triggers: None, + }; + let project = Project::from_cfg(project); + let project = projects.create(&PostParams::default(), &project).await?; let data_set = DataSet { metadata: ObjectMeta { generate_name: Some("testdataset2".to_string()), finalizers: Some(vec![super::DATA_SET_CONTROLLER.to_string()]), + owner_references: Some(vec![project.controller_owner_ref(&()).unwrap()]), ..ObjectMeta::default() }, spec: DataSetSpec { + project: None, cfg: DataSetCfg { name: "test".to_string(), path: "data".to_string(), @@ -162,13 +198,21 @@ mod test { name: "test_data_task".to_string(), project: None, }), + executor: None, + resources: BTreeMap::new(), + data_sets: Vec::new(), + from_template: None, + artifact_cfg: None, + triggers: None, + env: vec![], + secrets: vec![], }), size: None, }, deletion_approved: false, }, status: Some(DataSetStatus { - phase: DataSetPhase::Pending {}, + phase: Some(DataSetPhase::Pending {}), }), }; diff --git a/controller/src/lib.rs b/controller/src/lib.rs index 84009eb3..7b922f3f 100644 --- a/controller/src/lib.rs +++ b/controller/src/lib.rs @@ -1 +1,3 @@ pub mod data_set; +pub mod project; +pub mod task; diff --git a/controller/src/main.rs b/controller/src/main.rs index da01683c..16dc3d90 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -1,18 +1,20 @@ -use ame::custom_resources::project::ProjectCtrlCfg; -use ame::custom_resources::*; -use ame::custom_resources::{ - project_source::ProjectSrcCtrlCfg, task::start_task_controller, task::TaskControllerConfig, +use ame::custom_resources::{project_source::ProjectSrcCtrlCfg, *}; + +use controller::{ + data_set::{start_data_set_controller, DataSetControllerCfg}, + project::{start_project_controller, ProjectControllerCfg}, + task::{start_task_controller, TaskControllerCfg}, }; -use controller::data_set::{start_data_set_controller, DataSetControllerCfg}; use envconfig::Envconfig; use kube::Client; +use tracing::info; use tracing_subscriber::{prelude::*, EnvFilter, Registry}; #[tokio::main] async fn main() -> Result<()> { - let task_ctrl_cfg = TaskControllerConfig::init_from_env().unwrap(); + let mut task_ctrl_cfg = TaskControllerCfg::init_from_env().unwrap(); let project_src_ctrl_cfg = ProjectSrcCtrlCfg::init_from_env().unwrap(); - let project_ctrl_cfg = ProjectCtrlCfg::from_env().unwrap(); + let mut project_ctrl_cfg = ProjectControllerCfg::from_env().unwrap(); let logger = tracing_subscriber::fmt::layer(); let env_filter = EnvFilter::try_from_default_env() @@ -25,16 +27,41 @@ async fn main() -> Result<()> { // Start kubernetes controller let client = Client::try_default().await?; + let data_set_controller = start_data_set_controller(DataSetControllerCfg { - client, - namespace: task_ctrl_cfg.namespace.clone(), + client: client.clone(), + namespace: task_ctrl_cfg + .namespace + .clone() + .unwrap_or("ame-system".to_string()), }) .await .unwrap(); - let task_controller = start_task_controller(task_ctrl_cfg).await; + + if task_ctrl_cfg.namespace.is_none() { + task_ctrl_cfg.namespace = Some("ame-system".to_string()); + } + + info!("Task controller configuration: {:?}", task_ctrl_cfg); + + let task_controller = start_task_controller(client.clone(), task_ctrl_cfg.clone()) + .await + .unwrap(); let projectsrc_controller = project_source::start_project_source_controller(project_src_ctrl_cfg).await; - let project_controller = project::start_project_controller(project_ctrl_cfg).await; + + if project_ctrl_cfg.deployment_image.is_none() { + project_ctrl_cfg.deployment_image = Some(task_ctrl_cfg.executor_image); + } + + if project_ctrl_cfg.mlflow_url.is_none() { + project_ctrl_cfg.mlflow_url = + Some("http://mlflow.default.svc.cluster.local:5000".to_string()); + } + + let project_controller = start_project_controller(client.clone(), project_ctrl_cfg) + .await + .unwrap(); tokio::select! { _ = task_controller=> println!("task controller exited"), diff --git a/controller/src/project.rs b/controller/src/project.rs new file mode 100644 index 00000000..146ba0a1 --- /dev/null +++ b/controller/src/project.rs @@ -0,0 +1,525 @@ +use ame::{ + ctrl::AmeResource, + custom_resources::{ + new_task::{Task, TaskBuilder}, + project::{generate_task_name, get_latest_model_version, Project}, + }, +}; + +use ame::{error::AmeError, Result}; + +use ame::grpc::{task_status::Phase, ModelDeploymentCfg, ModelTrainingCfg, TaskStatus, TriggerCfg}; + +use chrono::Utc; +use futures::{future::BoxFuture, FutureExt, StreamExt}; +use k8s_openapi::api::{apps::v1::Deployment, core::v1::Service, networking::v1::Ingress}; +use kube::{ + api::{ListParams, Patch, PatchParams}, + runtime::{controller::Action, finalizer, Controller}, + Api, Client, ResourceExt, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use std::{sync::Arc, time::Duration}; +use tracing::{debug, error, info}; + +static PROJECT_CONTROLLER: &str = "projects.ame.teainspace.com"; + +#[derive(Clone)] +struct Context { + cfg: ProjectControllerCfg, + pub client: Client, +} +impl Context { + fn new(client: Client, cfg: ProjectControllerCfg) -> Self { + Self { client, cfg } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)] +pub struct ProjectControllerCfg { + pub namespace: Option, + pub deployment_image: Option, + pub mlflow_url: Option, + model_deployment_ingress: Option, + model_ingress_annotations: Option>, + model_ingress_host: Option, +} + +impl ProjectControllerCfg { + pub fn from_env() -> Result { + let prefix = "AME"; + Ok(ProjectControllerCfg { + namespace: std::env::var(format!("{prefix}_NAMESPACE")).ok(), + deployment_image: std::env::var("EXECUTOR_IMAGE").ok(), + model_deployment_ingress: serde_yaml::from_str( + &std::env::var(format!("{prefix}_MODEL_DEPLOYMENT_INGRESS")) + .unwrap_or("".to_string()), + ) + .ok(), + model_ingress_annotations: Some(BTreeMap::new()), + model_ingress_host: std::env::var(format!("{prefix}_MODEL_INGRESS_HOST")).ok(), + mlflow_url: std::env::var(format!("{prefix}_MLFLOW_URL")).ok(), + }) + } +} + +impl ProjectControllerCfg { + pub fn new(namespace: Option) -> Self { + Self { + namespace, + deployment_image: None, + mlflow_url: None, + model_deployment_ingress: None, + model_ingress_annotations: None, + model_ingress_host: None, + } + } +} + +async fn reconcile(project: Arc, ctx: Arc) -> Result { + let projects = if let Some(ref namespace) = ctx.cfg.namespace { + Api::::namespaced(ctx.client.clone(), namespace) + } else { + Api::::namespaced(ctx.client.clone(), "ame-system") + }; + + info!("reconciling project {}", project.name_any()); + + Ok( + finalizer(&projects, PROJECT_CONTROLLER, project, |event| async { + match event { + finalizer::Event::Apply(project) => { + apply(&project, &projects, ctx.client.clone(), ctx).await + } + finalizer::Event::Cleanup(project) => cleanup(&project, &projects).await, + } + }) + .await?, + ) +} + +async fn apply( + project: &Project, + _projects: &Api, + client: Client, + ctx: Arc, +) -> Result { + let project_status = project.status.clone().unwrap_or_default(); + let tasks = Api::::namespaced(client.clone(), &project.namespace().unwrap()); + let deployments = Api::::namespaced(client.clone(), &project.namespace().unwrap()); + let services = Api::::namespaced(client.clone(), &project.namespace().unwrap()); + let ingresses = Api::::namespaced(client, &project.namespace().unwrap()); + + let Some(project_oref) = project.gen_owner_ref() else { + return Err(AmeError::FailedToCreateOref(project.name_any())); + }; + + if project.spec.enable_triggers.unwrap_or(false) { + info!("checking for triggered tasks"); + let instant = Utc::now(); + + for task in project.spec.cfg.tasks.iter() { + if let Some(TriggerCfg { + schedule: Some(ref schedule), + }) = task.triggers + { + // TODO this parser is brittle and panics if len < 3; + let schedule = match cron_parser::parse(schedule, &instant) { + Ok(schedule) => schedule, + e @ Err(_) => { + error!( + "failed to pass cron schedule for task {}: {e:?}", + task.name.as_ref().unwrap_or(&"".to_string()) + ); + continue; + } + }; + + if schedule.signed_duration_since(instant).num_seconds() < 120 { + let mut task_builder = TaskBuilder::from_cfg(task.clone()); + task_builder.add_owner_reference(project_oref.clone()); + + task_builder.set_name(generate_task_name( + project.name_any(), + task.name.clone().unwrap_or("".to_string()), + )); + task_builder.set_project(project.spec.cfg.name.clone()); + + let task = task_builder.build(); + + let Some(ref task_name) = task.metadata.name else { + error!("task is missing a name in project {}", project.name_any()); + continue; + }; + + let res = tasks + .patch( + task_name, + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(task.clone()), + ) + .await; + + if let e @ Err(_) = res { + error!( + "failed to patch task {} in project {}: {:?}", + task.name_any(), + project.name_any(), + e + ); + continue; + }; + } + } + } + } else { + debug!("triggers are disabled"); + } + + // Note task controller is not updating task status to succeeded. + // Why is controller not reacting owned task status changing? + for model in project.spec.cfg.models.iter() { + info!( + "reconciling model {} in project {}", + model.name, project.spec.cfg.name + ); + if let Some(ModelTrainingCfg { + task: Some(ref _task), + .. + }) = model.training + { + let Ok(training_task) = project.generate_model_training_task(&model.name) else { + continue; + }; + + debug!("Patching training task: {:?}", training_task); + + tasks + .patch( + &training_task.name_any(), + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(training_task), + ) + .await?; + } + + if let Some(ModelDeploymentCfg { ref image, .. }) = model.deployment { + let deployment_image = if let Some(image) = image { + Some(image.clone()) + } else { + ctx.cfg.deployment_image.clone() + }; + + let Some(deployment_image) = deployment_image else { + error!( + "missing deployment image for model {} in project {}", + model.name, + project.name_any() + ); + continue; + }; + + let Some(mlflow_url) = ctx.cfg.mlflow_url.clone() else { + error!("missing MLflow URL, skipping deployment"); + continue; + }; + + let model_source = match get_latest_model_version(model, mlflow_url).await { + Ok(ms) => ms, + Err(e) => { + error!("failed to get latest model version, skipping deployment error: {e}"); + continue; + } + }; + + let mut model_status = project_status + .models + .get(&model.name) + .cloned() + .unwrap_or_default(); + + if model_status + .latest_validated_model_version + .as_ref() + .map(|s| s != &model_source.source) + .unwrap_or(true) + && model.validation_task.is_some() + { + debug!( + "Generating validation task for model {} in project {}", + model.name, project.spec.cfg.name + ); + + let val_task = match project + .generate_validation_task(model, model_source.version.clone()) + { + Ok(t) => Some(t), + Err(e) => { + error!("failed to generate validaion task for model {} in project {}, aborting model validation and deployment: {}", model.name, project.spec.cfg.name, e); + None + } + }; + + let Some(ref val_task) = val_task else { + continue; + }; + + let Some(ref task_name) = val_task.metadata.name else { + error!("validation task for model {} in project {} is missing a name, aborting model validation and deployment", model.name, project.spec.cfg.name); + continue; + }; + + info!( + "patching validation task for model {} in project {}", + model.name, + project.name_any() + ); + + // TODO; we need to look at the name generation for validated model tasks. + tasks + .patch( + task_name, + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(val_task), + ) + .await?; + + // TODO: what about when validation has failed? + if let Ok(Task { + status: + Some(TaskStatus { + phase: Some(Phase::Succeeded(_)), + }), + .. + }) = tasks.get_status(task_name).await + { + info!( + "model {} in project {} is validated for version {}", + model.name, + project.name_any(), + model_source.version + ); + model_status.latest_validated_model_version = Some(model_source.source.clone()); + } else { + info!("model {} in project {} is not validated for version {}, skipping deployment", model.name, project.name_any(), model_source.version); + continue; + } + } + + let deployment = model + .generate_model_deployment(deployment_image.clone(), model_source.source) + .await; + match deployment { + Ok(deployment) => { + let Some(ref name) = deployment.metadata.name else { + error!("Deployment object for model {} in project {} is missing a name, skipping deployment", model.name, project.name_any()); + continue; + }; + + info!("Patching model deployment"); + + // TODO: adjust error handling to avoid breaking out of the reconciliation for errors on a single object. + deployments + .patch( + name, + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(deployment.clone()), + ) + .await?; + } + Err(e) => error!( + "Failed to generated model deployment for {} in project {} error: {}", + model.name, + project.name_any(), + e + ), + }; + + let service = model.generate_model_service(); + + match service { + Ok(service) => { + let Some(ref name) = service.metadata.name else { + error!("Service object for model {} in project {} is missing a name, skipping service", model.name, project.name_any()); + continue; + }; + + info!("Patching model service"); + services + .patch( + name, + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(service.clone()), + ) + .await?; + } + Err(e) => error!( + "Failed to generated service for model {} in project {}: {}", + model.name, + project.name_any(), + e + ), + }; + + // TODO: put some thought into how the project name is used and the path for a model created. + let ingress = model.generate_model_ingress( + ctx.cfg.model_ingress_host.clone().unwrap_or("".to_string()), + None, + project.name_any(), + ); + + match ingress { + Ok(ingress) => { + let Some(ref name) = ingress.metadata.name else { + error!("Ingress object for model {} in project {} is missing a name, skipping ingress", model.name, project.name_any()); + continue; + }; + + info!("Patching model ingress"); + ingresses + .patch( + name, + &PatchParams::apply(PROJECT_CONTROLLER), + &Patch::Apply(ingress.clone()), + ) + .await?; + } + Err(e) => error!( + "Failed to generated ingress for model {} in project {}: {}", + model.name, + project.name_any(), + e + ), + } + } + } + + Ok(Action::requeue(Duration::from_secs(60))) +} + +pub async fn cleanup(project: &Project, _projects: &Api) -> Result { + if !project.deletion_approved() { + info!( + "Blocking deletion as it was not approved for {}", + project.name_any() + ); + return Err(AmeError::DeletionNotApproved(project.name_any())); + } + + Ok(Action::await_change()) +} + +fn error_policy(_project: Arc, error: &AmeError, _ctx: Arc) -> Action { + error!("failed to reconcile: {:?}", error); + Action::requeue(Duration::from_secs(5 * 60)) +} + +pub async fn start_project_controller( + client: Client, + config: ProjectControllerCfg, +) -> Result> { + let context = Arc::new(Context::new(client.clone(), config.clone())); + + let projects = if let Some(ref namespace) = config.namespace { + Api::::namespaced(client.clone(), namespace) + } else { + Api::::all(client.clone()) + }; + + let tasks = if let Some(ref namespace) = config.namespace { + Api::::namespaced(client.clone(), namespace) + } else { + Api::::all(client.clone()) + }; + + info!("Starting Project controller"); + debug!("Project controller cfg: {:?}", config); + + Ok(Controller::new(projects, ListParams::default()) + .owns(tasks, ListParams::default()) + .run(reconcile, error_policy, context) + .filter_map(|x| async move { std::result::Result::ok(x) }) + .for_each(|_| futures::future::ready(())) + .boxed()) +} + +#[cfg(test)] +mod test { + use ame::{ + custom_resources::project::{Project, ProjectSpec}, + grpc::ProjectCfg, + Result, + }; + use kube::{ + api::{DeleteParams, Patch, PatchParams, PostParams}, + core::ObjectMeta, + Api, Client, + }; + + use super::*; + + #[tokio::test] + #[ignore = "requires a k8s cluster"] + async fn can_block_deletion() -> Result<()> { + let client = Client::try_default().await?; + let namespace = "default".to_string(); + let projects = Api::::namespaced(client.clone(), &namespace); + + let ctx = Context { + client, + cfg: ProjectControllerCfg::new(Some(namespace)), + }; + + let project = Project { + metadata: ObjectMeta { + generate_name: Some("myproject".to_string()), + finalizers: Some(vec![super::PROJECT_CONTROLLER.to_string()]), + ..ObjectMeta::default() + }, + spec: ProjectSpec { + cfg: ProjectCfg { + name: "myproject".to_string(), + ..ProjectCfg::default() + }, + deletion_approved: false, + enable_triggers: Some(false), + }, + status: None, + }; + + let project = projects.create(&PostParams::default(), &project).await?; + + reconcile(Arc::new(project.clone()), Arc::new(ctx.clone())).await?; + + let project = projects.get(&project.name_any()).await?; + + reconcile(Arc::new(project.clone()), Arc::new(ctx.clone())).await?; + + projects + .delete(&project.name_any(), &DeleteParams::default()) + .await?; + + let mut project = projects.get(&project.name_any()).await?; + reconcile(Arc::new(project.clone()), Arc::new(ctx.clone())) + .await + .unwrap_err(); + + project.approve_deletion(); + + let project = projects + .patch( + &project.name_any(), + &PatchParams::default(), + &Patch::Merge(project), + ) + .await?; + + projects + .delete(&project.name_any(), &DeleteParams::default()) + .await?; + + Ok(()) + } +} diff --git a/controller/src/snapshots/controller__task__test__can_create_workflow_and_finalize_task.snap b/controller/src/snapshots/controller__task__test__can_create_workflow_and_finalize_task.snap new file mode 100644 index 00000000..58916803 --- /dev/null +++ b/controller/src/snapshots/controller__task__test__can_create_workflow_and_finalize_task.snap @@ -0,0 +1,114 @@ +--- +source: controller/src/task.rs +expression: "&workflow.spec" +--- +entrypoint: main +templates: + - name: main + metadata: + labels: ~ + annotations: ~ + steps: + - - name: setup + inline: + name: setup + metadata: + labels: + ame-task: redacted + annotations: ~ + steps: ~ + securityContext: + fsGroup: 2000 + runAsUser: 1001 + script: + command: + - bash + env: + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + key: MINIO_ROOT_USER + name: ame-minio-secret + optional: false + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + key: MINIO_ROOT_PASSWORD + name: ame-minio-secret + optional: false + - name: MLFLOW_TRACKING_URI + value: "http://mlflow.default.svc.cluster.local:5000" + - name: MINIO_URL + value: "http://ame-minio.ame-system.svc.cluster.local:9000" + - name: PIPENV_YES + value: "1" + image: "main.localhost:45373/ame-executor:latest" + name: "" + resources: + limits: {} + volumeMounts: + - mountPath: /project + name: redacted + source: " \n\n s3cmd --no-ssl --region eu-central-1 --host=$MINIO_URL --host-bucket=$MINIO_URL get --recursive s3://somepath ./" + podSpecPatch: ~ + - - name: redacted + inline: + name: redacted + metadata: + labels: + ame-task: redacted + annotations: ~ + steps: ~ + securityContext: + fsGroup: 2000 + runAsUser: 1001 + script: + command: + - bash + env: + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + key: MINIO_ROOT_USER + name: ame-minio-secret + optional: false + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + key: MINIO_ROOT_PASSWORD + name: ame-minio-secret + optional: false + - name: MLFLOW_TRACKING_URI + value: "http://mlflow.default.svc.cluster.local:5000" + - name: MINIO_URL + value: "http://ame-minio.ame-system.svc.cluster.local:9000" + - name: PIPENV_YES + value: "1" + image: "main.localhost:45373/ame-executor:latest" + name: "" + resources: + limits: {} + volumeMounts: + - mountPath: /project + name: redacted + source: "\n source ~/.bashrc\n \n pyenv install 3.11\n\n pyenv global 3.11\n\n poetry install\n \n poetry run python train.py\n " + podSpecPatch: ~ + securityContext: ~ + script: ~ + podSpecPatch: ~ +imagePullSecrets: ~ +volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: redacted + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 50Gi + status: {} +volumes: ~ +serviceAccountName: ame-task + diff --git a/controller/src/task.rs b/controller/src/task.rs new file mode 100644 index 00000000..0463c550 --- /dev/null +++ b/controller/src/task.rs @@ -0,0 +1,443 @@ +use std::{ + sync::Arc, + time::{self, Duration}, +}; + +use ame::{ + ctrl::AmeResource, + custom_resources::{ + argo::{Workflow, WorkflowPhase}, + data_set::{DataSet, DataSetPhase, DataSetStatus}, + find_project, + new_task::{build_workflow, resolve_task_templates, Task}, + project::{local_name, project_name, Project}, + task_ctrl::TaskCtrl, + }, + error::AmeError, + grpc::{task_status::Phase, TaskPhaseFailed, TaskPhaseRunning, TaskPhaseSucceeded, TaskStatus}, + Result, +}; +use envconfig::Envconfig; +use futures::{future::BoxFuture, FutureExt, StreamExt}; +use kube::{ + api::{ListParams, Patch, PatchParams}, + runtime::{controller::Action, finalizer, Controller}, + Api, Client, ResourceExt, +}; +use tracing::{debug, error, info}; + +static TASK_CONTROLLER: &str = "tasks.ame.teainspace.com"; + +#[derive(Clone)] +struct Context { + cfg: TaskControllerCfg, + client: Client, +} + +impl Context { + fn new(client: Client, cfg: TaskControllerCfg) -> Self { + Self { client, cfg } + } +} + +#[derive(Clone, Envconfig, Debug)] +pub struct TaskControllerCfg { + #[envconfig( + from = "AME_EXECUTOR_IMAGE", + default = "main.localhost:45373/ame-executor:latest" + )] + pub executor_image: String, + #[envconfig(from = "NAMESPACE")] + pub namespace: Option, + #[envconfig(from = "AME_SERVICE_ACCOUNT", default = "ame-task")] + pub service_account: String, +} + +async fn reconcile(task: Arc, ctx: Arc) -> Result { + info!("reconciling task {}", task.name_any()); + + let tasks = if let Some(ref namespace) = ctx.cfg.namespace { + Api::::namespaced(ctx.client.clone(), namespace) + } else { + todo!("we need to handle this case better??"); + }; + + Ok(finalizer(&tasks, TASK_CONTROLLER, task, |event| async { + match event { + finalizer::Event::Apply(task) => apply(&task, &ctx).await, + finalizer::Event::Cleanup(task) => cleanup(&task, &tasks).await, + } + }) + .await?) +} + +// TODO: do not allow nonexistent fields in project.yaml. + +async fn apply(task: &Task, ctx: &Context) -> Result { + let (tasks, workflows, projects, data_sets) = if let Some(ref namespace) = ctx.cfg.namespace { + ( + Api::::namespaced(ctx.client.clone(), namespace), + Api::::namespaced(ctx.client.clone(), namespace), + Api::::namespaced(ctx.client.clone(), namespace), + Api::::namespaced(ctx.client.clone(), namespace), + ) + } else { + todo!("we need to handle this case better??"); + }; + + let task_ctrl = TaskCtrl::new(data_sets.clone(), projects.clone()); + + debug!("checking datasets for task {:?}", task.name_any()); + + let project = projects.get(&task.parent_project_name()?).await?; + + if !task.spec.cfg.data_sets.is_empty() { + info!( + "reconciling data sets for task {}", + task.spec.cfg.name.as_ref().unwrap() + ); + + let mut ds_statuses: Vec> = vec![]; + + debug!("reconciling datasets {:?}", task.spec.cfg.data_sets); + + for ds in task.spec.cfg.data_sets.iter() { + let ds_name = local_name(ds.clone()); + + let ds_project = if let Some(project_name) = project_name(ds.clone()) { + find_project(projects.clone(), project_name, "".to_string()) + .await + .or(Err(AmeError::MissingProject(0)))? + } else { + project.clone() + }; + + let mut data_set = match ds_project.generate_data_set(ds_name.clone()) { + Ok(ds) => ds, + Err(e) => { + error!( + "failed to reconcile data set {} for project {}: {}", + ds_name, + ds_project.name_any(), + e + ); + continue; + } + }; + let Some(task_oref) = task.gen_owner_ref() else { + error!( + "failed to generate owner reference for task {} stopping data set creation", + task.name_any() + ); + continue; + }; + + data_set.owner_references_mut().push(task_oref); + + debug!("patching dataset: {}", ds); + + let ds_obj_name = + data_set + .metadata + .name + .clone() + .ok_or(AmeError::ReconcilitationFailure( + "Task".to_string(), + task.name_any(), + format!( + "data set {} in project {} is missing a name", + ds, project.spec.cfg.name + ), + ))?; + + let data_set = data_sets + .patch( + &ds_obj_name, + &PatchParams::apply(TASK_CONTROLLER).force(), + &Patch::Apply(data_set), + ) + .await?; + + ds_statuses.push(data_set.status); + } + + for stat in ds_statuses { + let Some(stat) = stat else { + info!("waiting for datasets to complete"); + return Ok(Action::requeue(Duration::from_secs(10))); + }; + + match stat.phase { + Some(DataSetPhase::Ready { .. }) => continue, + Some(DataSetPhase::Failed { .. }) => { + error!( + "Data set has failed, can not schedule Task {}", + task.spec + .cfg + .name + .as_ref() + .unwrap_or(&"unknown name".to_string()) + ); + } + _ => { + info!("waiting for datasets to complete"); + return Ok(Action::requeue(Duration::from_secs(10))); + } + } + } + } + + let task_ctx = task_ctrl + .gather_task_ctx( + task, + ctx.cfg.executor_image.to_string(), + ctx.cfg.service_account.clone(), + ) + .await?; + + debug!("resolving task {}", task.name_any()); + + let resolved_task = resolve_task_templates(task.clone(), project, projects).await?; + + debug!("resolved task {:?}", task.spec.cfg); + + let workflow = build_workflow(resolved_task, task_ctx)?; + + debug!("patching workflow for task {:?} ", task.name_any(),); + + let workflow = workflows + .patch( + &workflow.name_any(), + &PatchParams::apply(TASK_CONTROLLER).force(), + &Patch::Apply(&workflow), + ) + .await?; + + debug!("workflow phase: {:?}", workflow.status); + + let phase = workflow + .status + .as_ref() + .map(|s| match s.phase { + WorkflowPhase::Pending | WorkflowPhase::Running | WorkflowPhase::Error => { + Phase::Running(TaskPhaseRunning { + workflow_name: workflow.name_any(), + }) + } + WorkflowPhase::Failed => Phase::Failed(TaskPhaseFailed { + workflow_name: workflow.name_any(), + }), + WorkflowPhase::Succeeded => Phase::Succeeded(TaskPhaseSucceeded { + workflow_name: workflow.name_any(), + }), + }) + .unwrap_or(Phase::Running(TaskPhaseRunning { + workflow_name: workflow.name_any(), + })); + + let mut task = task.clone(); + + task.status = Some(TaskStatus { phase: Some(phase) }); + task.metadata.managed_fields = None; + + debug!("patching status for task {}", task.name_any()); + tasks + .patch_status( + &task.name_any(), + &PatchParams::apply(TASK_CONTROLLER).force(), + &Patch::Apply(task), + ) + .await?; + + Ok(Action::requeue(std::time::Duration::from_secs(60))) +} + +pub async fn cleanup(task: &Task, _tasks: &Api) -> Result { + info!("cleanup dataset: {}", task.name_any()); + + if task.spec.deletion_approved { + Ok(Action::requeue(time::Duration::from_secs(300))) + } else { + Err(AmeError::ApiError("deletion not approved".to_string())) + } +} + +pub async fn start_task_controller( + client: Client, + config: TaskControllerCfg, +) -> Result> { + info!("Start Task controller"); + let context = Arc::new(Context::new(client.clone(), config.clone())); + + let tasks = if let Some(ref namespace) = config.namespace { + Api::::namespaced(client.clone(), namespace) + } else { + Api::::all(client.clone()) + }; + + let workflow = if let Some(ref namespace) = config.namespace { + Api::::namespaced(client, namespace) + } else { + Api::::all(client) + }; + + Ok(Controller::new(tasks, ListParams::default()) + .owns(workflow, ListParams::default()) + .run(reconcile, error_policy, context) + .filter_map(|x| async move { std::result::Result::ok(x) }) + .for_each(|_| futures::future::ready(())) + .boxed()) +} + +fn error_policy(_src: Arc, error: &AmeError, _ctx: Arc) -> Action { + error!("failed to reconcile: {:?}", error); + Action::requeue(Duration::from_secs(5 * 60)) +} + +#[cfg(test)] +mod test { + use std::collections::BTreeMap; + + use ame::{ + custom_resources::{ + argo::Workflow, + new_task::{ProjectSource, TaskSpec}, + }, + grpc::{ + task_cfg::Executor, task_status::Phase, PoetryExecutor, ProjectCfg, TaskCfg, + TaskPhaseRunning, + }, + Result, + }; + use envconfig::Envconfig; + + use kube::{ + api::{DeleteParams, PostParams}, + core::ObjectMeta, + Api, Client, Resource, + }; + + use super::*; + + #[tokio::test] + #[ignore = "requires a k8s cluster"] + async fn can_create_workflow_and_finalize_task() -> Result<()> { + let client = Client::try_default().await?; + let namespace = "default".to_string(); + let tasks = Api::::namespaced(client.clone(), &namespace); + let workflows = Api::::namespaced(client.clone(), &namespace); + let projects = Api::::namespaced(client.clone(), &namespace); + + let mut controller_cfg = TaskControllerCfg::init_from_env().unwrap(); + controller_cfg.namespace = Some(namespace); + + let ctx = Context::new(client, controller_cfg); + + let project = ProjectCfg { + name: "parentproject".to_string(), + models: vec![], + data_sets: vec![], + tasks: vec![], + templates: vec![], + enable_triggers: None, + }; + + let project = Project::from_cfg(project); + let project = projects.create(&PostParams::default(), &project).await?; + + let task = Task { + metadata: ObjectMeta { + generate_name: Some("mytask".to_string()), + owner_references: Some(vec![project.controller_owner_ref(&()).unwrap()]), + ..ObjectMeta::default() + }, + spec: TaskSpec { + cfg: TaskCfg { + name: Some("mytask".to_string()), + task_ref: None, + executor: Some(Executor::Poetry(PoetryExecutor { + python_version: "3.11".to_string(), + command: "python train.py".to_string(), + })), + resources: BTreeMap::new(), + data_sets: Vec::new(), + from_template: None, + artifact_cfg: None, + triggers: None, + env: vec![], + secrets: vec![], + }, + source: Some(ProjectSource::Ame { + path: "somepath".to_string(), + }), + deletion_approved: false, + project: None, + }, + status: None, + }; + + let task = tasks.create(&PostParams::default(), &task).await?; + + reconcile(Arc::new(task.clone()), Arc::new(ctx.clone())).await?; + + let task = tasks.get(&task.name_any()).await?; + + reconcile(Arc::new(task.clone()), Arc::new(ctx.clone())).await?; + + let task_status = tasks.get_status(&task.name_any()).await?.status.unwrap(); + + let Phase::Running(TaskPhaseRunning { workflow_name }) = task_status.phase.unwrap() else { + panic!("task was not running"); + }; + + let workflow = workflows.get(&workflow_name).await?; + + let mut settings = insta::Settings::clone_current(); + + settings.add_filter( + &format!("{}.+", task.clone().metadata.generate_name.unwrap()), + "redacted", + ); + + let _guard = settings.bind_to_scope(); + insta::assert_yaml_snapshot!(&workflow.spec); + + tasks + .delete(&task.name_any(), &DeleteParams::default()) + .await?; + + let data_set = tasks.get(&task.name_any()).await?; + + // An error is expected here as the cleanup is rejected. + reconcile(Arc::new(data_set.clone()), Arc::new(ctx.clone())) + .await + .unwrap_err(); + + // Deletion should not be possible until deletion has been approved. + let mut task = tasks.get(&task.name_any()).await?; + + // Approve deletion and verify that the data set is now deleted. + task.spec.deletion_approved = true; + + tasks + .patch( + &task.name_any(), + &PatchParams::default(), + &Patch::Merge(task.clone()), + ) + .await?; + + reconcile(Arc::new(task.clone()), Arc::new(ctx.clone())) + .await + .unwrap(); + + // The cluster is allowed a minimum of 100ms to delete the data set + // object after the finalizer has approved deletion during the last + // reconcile call. + tokio::time::sleep(Duration::from_millis(100)).await; + + tasks.get(&task.name_any()).await.unwrap_err(); + + Ok(()) + } +} diff --git a/docs/model_validation.md b/docs/model_validation.md deleted file mode 100644 index 6ddd581e..00000000 --- a/docs/model_validation.md +++ /dev/null @@ -1,115 +0,0 @@ -# Validating models before deployment - -To ensure that a new model versions perform well before exposing them AME supports model validation. This is done by providing AME with a `Task` which -will succeed if the model passes validation and fail if not. - -Example from [ame-demo](https://github.com/TeaInSpace/ame-demo): - -```yaml - -projectid: sklearn_logistic_regression -models: - - name: logreg - type: mlflow - validationTask: # the validation task is set here. - taskRef: mlflow_validation - training: - task: - taskRef: training - deployment: - auto_train: true - deploy: true - enable_tls: false -tasks: - - name: training - projectid: sklearn_logistic_regression - templateRef: shared-templates.logistic_reg_template - taskType: Mlflow - - name: mlflow_validation - projectid: sklearn_logistic_regression - runcommand: python validate.py -``` - -This approach allows for a lot of flexibility of how models are validated, at the cost of writing the validation your self. In the future AME will provide builtin options for common validation configurations as well, see the [roadmap](todo). - -### Using MLflow metrics - -Here we will walk through how to validate a model based on recorded metrics in MLflow, using the [ame-demo](https://github.com/TeaInSpace/ame-demo) repository as an example. The model is a simple logistic regressor, the training code looks like this: - -```python -import numpy as np -from sklearn.linear_model import LogisticRegression -import mlflow -import mlflow.sklearn -import os - -X = np.array([-2, -1, 0, 1, 2, 1]).reshape(-1, 1) -y = np.array([0, 0, 1, 1, 1, 0]) -lr = LogisticRegression() -lr.fit(X, y) -score = lr.score(X, y) -mlflow.log_metric("score", score) -mlflow.sklearn.log_model(lr, "model", registered_model_name="logreg") -print("Model saved in run %s" % mlflow.active_run().info.run_uuid) -``` - -Notice how the score is logged as a metric. We can use that in our validation. - -AME exposes the necessary environment variables to running tasks so we can access the Mlflow instance during validation just by using the Mlflow library. - -**Note**: the model name is hard coded , in the future the model name will be made available as an environment variable allowing for better reusability of validation code. - -```python -# validation.py -import sys -import mlflow -from mlflow.entities import ViewType - -# We fetch the metrics for latest run for the logreg model. -models = mlflow.search_registered_models(filter_string="name='logreg'") -run_id = models[0].latest_versions[-1].run_id -run = mlflow.get_run(run_id) - -if run.data.metrics['score'] < 0.6: - sys.exit(1) - -``` - -A validation task indicates failure with a non zero exit code. In this example if our model scores below 0.6 the task will exit with code 1 indicating a failure. - -In this example we keep `validation.py` in the same repository as our training code, that is how ever not required. We could use a task from a completely separate project and share the validation logic between multiple projects. - -Our AME file will look as follows: -```yaml - -projectid: mlflow_validation_example -models: - - name: logreg # Note the model name is what mlflow will use as well and the name used during validation. - type: mlflow # This tells AME to train the model using mlfow. - validationTask: # the validation task is set here. - taskRef: mlflow_validation - training: - task: - taskRef: training - deployment: - auto_train: true - deploy: true - enable_tls: false -tasks: - - name: training - projectid: mlflow_validation_example - taskType: Mlflow # Since this is an mlflow task, AME knows how to run it. - - name: mlflow_validation - projectid: mlflow_validation_example - runcommand: python validate.py -``` - -See [ame-demo](https://github.com/TeaInSpace/ame-demo) for a full list of files. - -We can add these files a git repository and add that repository as a project source in AME. - -```bash -ame projectsrc create https://github.com/myuser/myrepo.git -``` - -Now you will be able to observe the model being trained, validated and then deployed. diff --git a/executor/Dockerfile b/executor/Dockerfile index 549e39d1..0f870236 100644 --- a/executor/Dockerfile +++ b/executor/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:20.04 +FROM ubuntu:22.04 # These packages are required by pyenv, see https://github.com/pyenv/pyenv/wiki#suggested-build-environment RUN apt-get update \ @@ -6,7 +6,7 @@ RUN apt-get update \ git make build-essential libssl-dev zlib1g-dev \ libbz2-dev libreadline-dev libsqlite3-dev wget \ curl llvm libncursesw5-dev xz-utils tk-dev \ - libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev python pip -y + libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev -y # The execute script is called by the argo workflow to execute the command supplied by the Task. COPY ./execute.sh /usr/bin/execute @@ -23,9 +23,9 @@ RUN mkdir /project RUN chown ame -R /project USER ame -# THe ame user gets its home directory where pyenv and pipenv will end up installed +# THe ame user gets its home directory where pyenv and pipenv will end up installed # therefore the .local/bin needs to be included in the PATH environment variable. -ENV PATH="/home/ame/.local/bin:${PATH}" +ENV PATH="/home/ame/.pyenv/shims:/home/ame/.pyenv/bin:/home/ame/.local/bin:${PATH}" # Pipenv uses pyenv to install new python versions, the installation process is # ducumented here: https://github.com/pyenv/pyenv#installation @@ -34,6 +34,10 @@ RUN PYENV_GIT_TAG=v2.3.4 curl https://pyenv.run | bash RUN echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc RUN echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc RUN echo 'eval "$(pyenv init --path)"' >> ~/.bashrc +RUN pyenv install 3.11 +RUN pyenv global 3.11 +RUN pyenv rehash +RUN python --version # Install pipenv RUN pip install --upgrade pip @@ -44,6 +48,8 @@ RUN pip install pipenv RUN pip install s3cmd RUN pip install mlflow[extras] -RUN pip install poetry +# TODO: pin poetry versionk +RUN curl -sSL https://install.python-poetry.org | python - + WORKDIR /project diff --git a/justfile b/justfile index 109391f2..0f3bbd4d 100644 --- a/justfile +++ b/justfile @@ -42,6 +42,7 @@ tools: cargo install --locked cargo-audit cargo install --locked cargo-outdated cargo install --locked cargo-udeps + cargo install --locked cargo-watch fix: fmt cargo fix --workspace --allow-dirty --tests --allow-staged @@ -56,11 +57,15 @@ check: cargo check --workspace --all-targets --all-features cargo spellcheck check typos --exclude **/primer.css ./ - cargo audit cargo +nightly fmt --check - cargo +nightly clippy --workspace --tests --all -- -D warnings - # cargo outdated --exclude leptos + + # TODO: reintroduce failure on clippy warnings + + cargo +nightly clippy --workspace --tests --all -- -W clippy::panic -W clippy::unwrap_used -W clippy::expect_used cargo +nightly udeps --all-targets --workspace --show-unused-transitive --exclude web # TODO: solve false positives for web package. + # TODO: chrono 0.5 will deal with this CVE, update when it is released. + cargo audit --ignore RUSTSEC-2020-0071 + # cargo outdated --exclude leptos test *ARGS: cargo test --workspace {{ARGS}} @@ -92,8 +97,20 @@ crdgen: cargo run --bin project_crdgen > manifests/project_crd.yaml cargo run --bin data_set_crdgen > manifests/data_set_crd.yaml +watch_controller: + AME_EXECUTOR_IMAGE={{LOCAL_EXECUTOR_IMAGE_TAG}} AME_MODEL_INGRESS_HOST={{AME_HOST}} AME_MLFLOW_URL=http://localhost:5000 cargo watch -x 'run --bin controller' + +watch_server: + #!/bin/sh + just crdgen + just install_crd + export S3_ENDPOINT=http://$(kubectl get svc -n {{TARGET_NAMESPACE}} ame-minio -o jsonpath='{.status.loadBalancer.ingress[0].ip}'):9000 + export S3_ACCESS_ID=minio + export S3_SECRET=minio123 + cargo watch -x 'run --bin ame-server' + start_controller: - cargo run --bin controller + AME_EXECUTOR_IMAGE={{LOCAL_EXECUTOR_IMAGE_TAG}} AME_MODEL_INGRESS_HOST={{AME_HOST}} AME_MLFLOW_URL=http://localhost:5000 cargo run --bin controller start_server: #!/bin/sh @@ -105,6 +122,9 @@ start_server: run_cli *ARGS: cargo run -p cli {{ARGS}} +setup_cli_local_host: + cargo run -p cli setup http://localhost:3342 + setup_cli: cargo run -p cli setup http://$(kubectl get svc -n {{TARGET_NAMESPACE}} ame-server-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}'):3342 @@ -121,9 +141,10 @@ k3s: --servers 1 \ --registry-create main \ --k3s-arg "--disable=traefik@server:*" \ - --k3s-arg '--kubelet-arg=eviction-hard=imagefs.available<0.1%,nodefs.available<0.1%@agent:*' \ - --k3s-arg '--kubelet-arg=eviction-minimum-reclaim=imagefs.available=0.1%,nodefs.available=0.1%@agent:*' \ - --image rancher/k3s:v1.25.5-rc2-k3s1 + --k3s-arg '--kubelet-arg=eviction-hard=imagefs.available<1Gi,nodefs.available<1Gi@agent:*' \ + --k3s-arg '--kubelet-arg=eviction-minimum-reclaim=imagefs.available=1Gi,nodefs.available=1Gi@agent:*' \ + --image rancher/k3s:v1.26.6-k3s1 + create_namespace: kubectl create ns {{TARGET_NAMESPACE}} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 739f9ef4..2b09e508 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -7,64 +7,76 @@ edition = "2021" [dependencies] http = {version = "0.2.9", optional = true } -hyper = { version = "0.14.24", features = ["client"], optional = true } +hyper = { version = "0.14.27", features = ["client"], optional = true } leptos = { version = "0.2", default-features = false, features = ["serde", "stable"], optional = true } -prost = "0.11.7" -thiserror = "1.0.38" +prost = "0.11.9" +thiserror = "1.0.44" tokio-rustls = { version = "0.23.4", features = ["dangerous_configuration"], optional = true } tonic = { version = "0.8.3", default-features = false } tower = {version = "0.4.13", optional = true} -tower-http = { version = "0.4.0", features = ["auth"], optional = true } -tonic-web-wasm-client = {version = "0.3.2", optional = true} -url = "2.3.1" +tower-http = { version = "0.4.3", features = ["auth"], optional = true } +tonic-web-wasm-client = {version = "0.3.3", optional = true} +url = "2.4.0" http-body = "0.4.5" -tokio = "1.25.0" -tokio-stream = "0.1.12" -async-stream = "0.3.4" -futures-util = "0.3.26" -futures = "0.3.26" +tokio = "1.29.1" +tokio-stream = "0.1.14" +async-stream = "0.3.5" +futures-util = "0.3.28" +futures = "0.3.28" kube = {version = "0.80.0", optional = true, features = ["derive", "runtime", "client"]} -async-trait = "0.1.64" -schemars = "0.8.11" +async-trait = "0.1.72" +schemars = "0.8.12" serde = "1.0" -serde_json = "1.0.91" +serde_json = "1.0.104" serde_merge = {version = "0.1.3", optional = true} -rustls-native-certs = {version = "0.6.2", optional = true} +rustls-native-certs = {version = "0.6.3", optional = true} hyper-rustls = { version = "0.23.2", features = ["http2"], optional = true } -open = {version = "4.0.1", optional = true} +open = {version = "4.2.0", optional = true} openidconnect = {version = "2.5.1", optional = true} -oauth2 = {version = "4.3.0", optional = true} -reqwest = {version = "0.11.15", optional = true, features = ["json"]} +oauth2 = {version = "4.4.1", optional = true} +reqwest = {version = "0.11.18", optional = true, features = ["json"]} cfg-if = "1.0.0" k8s-openapi = {optional=true, version = "0.17.0", features = ["v1_23", "schemars" ] } serde_tuple = { version = "0.5.0", optional = true } -git2 = {version = "0.17.0", optional = true} -serde_yaml = {version = "0.9.21", optional = true} +git2 = {version = "0.17.2", optional = true} +serde_yaml = {version = "0.9.25", optional = true} tracing = {version = "0.1.37", optional = true} duration-string = {version = "0.2.0", optional = true} envconfig = {version = "0.10.0", optional = true} similar = {version = "2.2.1", optional = true} humantime = {version = "2.1.0", optional = true} -similar-asserts = {version = "1.4.2", optional = true} -either = {version = "1.8.0", optional = true} +either = {version = "1.9.0", optional = true} +rand = {version = "*", optional = true} +cron-parser = "0.8.0" +anyhow = "1.0.72" [dev-dependencies] -either = "1.8.0" -assert_fs = "1.0.10" -insta = {version = "1.21.0", features = ["yaml"] } -predicates = "2.1.4" -serial_test = "0.9.0" +either = "1.9.0" +assert_fs = "1.0.13" +predicates = "2.1.5" +serial_test = "2.0.0" k8s-openapi = { version = "0.17.0", features = ["v1_23", "schemars" ] } tonic = { version = "0.8.3" } +assert_cmd = "2.0.12" +kube = "0.80.0" +insta = { version = "1.31.0", features = ["filters", "redactions", "yaml"] } +fs_extra = "1.3.0" +rstest = "0.16.0" +ame = { path = "../lib", features = ["native-client", "custom-resources", "ame-control", "project-tools"] } +tracing-subscriber = {version = "0.3", default-features = false, features = ["env-filter", "fmt"]} +chrono = "0.4.26" +time = "0.3.24" [features] web-components = ["dep:leptos", "dep:tonic-web-wasm-client", "dep:hyper"] -native-client = ["dep:hyper", "dep:tokio-rustls", "dep:tower", "dep:tower-http", "dep:rustls-native-certs", "dep:hyper-rustls", "dep:open", "dep:openidconnect", "dep:oauth2", "dep:reqwest", "dep:http"] +native-client = ["dep:serde_yaml", "dep:hyper", "dep:tokio-rustls", "dep:tower", "dep:tower-http", "dep:rustls-native-certs", "dep:hyper-rustls", "dep:open", "dep:openidconnect", "dep:oauth2", "dep:reqwest", "dep:http"] ame-control = ["dep:kube", "dep:serde_merge"] -custom-resources = ["dep:kube", "dep:k8s-openapi", "dep:serde_tuple", "dep:reqwest", "dep:git2", "dep:serde_merge", "dep:serde_yaml", "dep:tracing", "dep:duration-string", "dep:envconfig", "dep:similar", "dep:humantime", "dep:similar-asserts", "dep:either", "tonic/default"] +custom-resources = ["dep:kube", "dep:k8s-openapi", "dep:serde_tuple", "dep:reqwest", "dep:git2", "dep:serde_merge", "dep:serde_yaml", "dep:tracing", "dep:duration-string", "dep:envconfig", "dep:similar", "dep:humantime", "dep:either", "tonic/default"] +project-tools = ["dep:rand"] [build-dependencies] tonic-build = { version = "0.8.4", default-features = false, features = [ "prost", ] } +prost-build = "*" diff --git a/lib/ame.proto b/lib/ame.proto index 1c3047f7..e4175c7c 100644 --- a/lib/ame.proto +++ b/lib/ame.proto @@ -12,19 +12,6 @@ enum TaskType { Poetry = 2; } -message TaskTemplate { - string projectid = 1; - string command = 2; - string name = 3; - optional string image = 4; - optional TaskType taskType = 5; -} - -message CreateTaskRequest { - TaskIdentifier id = 1; - TaskTemplate template = 2; -} - message Empty { } @@ -117,7 +104,7 @@ message AmeSecretId { } message AmeSecrets { - repeated AmeSecretId secrets = 1; +repeated AmeSecretId secrets = 1; } message ResourceCfg { @@ -165,7 +152,99 @@ message TaskRef { message TaskCfg { optional string name = 1; - TaskRef taskRef = 2; + optional TaskRef taskRef = 2; + map resources = 3; + oneof executor { + PoetryExecutor poetry = 4; + MlflowExecutor mlflow = 5; + PipEnvExecutor pipEnv = 6; + PipExecutor pip = 8; + CustomExecutor custom = 9; + } + repeated string dataSets = 7; + optional TemplateRef fromTemplate = 10; + optional ArtifactCfg artifactCfg = 11; + optional TriggerCfg triggers = 12; + repeated EnvVar env = 13; + repeated Secret secrets = 14; +} + +message Secret { + oneof variant { + AmeSecretVariant ame = 1; + } +} + +message AmeSecretVariant { + string key = 1; + string injectAs = 2; +} + +message EnvVar { + string key = 1; + string val = 2; +} + +message TriggerCfg { + optional string schedule = 1; +} + +message ArtifactCfg { + bool save_changed_files = 1; + repeated string paths = 2; +} + +message TemplateRef { + string name = 1; + optional string project = 2; +} + +message CustomExecutor { + string pythonVersion = 1; + string command = 2; +} + +message PoetryExecutor { + string pythonVersion = 1; + string command = 2; +} + + +message PipExecutor { + string pythonVersion = 1; + string command = 2; +} + +message MlflowExecutor { + +} + +message PipEnvExecutor { + string command = 1; +} + +// TODO: should there be an error case? +message TaskStatus { + oneof phase { + TaskPhasePending pending = 2; + TaskPhaseRunning running = 3; + TaskPhaseFailed failed = 4; + TaskPhaseSucceeded succeeded= 5; + } +} + +message TaskPhasePending { + +} + +message TaskPhaseRunning { + string workflowName= 1; +} +message TaskPhaseFailed { + string workflowName= 1; +} +message TaskPhaseSucceeded { + string workflowName= 1; } message DataSetCfg { @@ -175,9 +254,81 @@ message DataSetCfg { optional string size = 4; } +message ProjectCfg { + string name = 1; + repeated Model models = 2; + repeated DataSetCfg dataSets = 3; + repeated TaskCfg tasks = 4; + repeated TaskCfg templates = 5; + optional bool enableTriggers = 6; +} + +message ProjectStatus { + map models = 1; +} + +message ModelStatus { + optional string latestValidatedModelVersion = 1; +} + +message CreateProjectRequest { + ProjectCfg cfg = 1; + optional bool enableTriggers = 2; +} + +message ProjectId { + string name = 1; +} + +message Model { + string name = 1; + optional TaskCfg validationTask = 2; + optional ModelTrainingCfg training = 3; + optional ModelDeploymentCfg deployment = 4; +} + +message ModelTrainingCfg { + TaskCfg task = 1; +} + +message ModelDeploymentCfg { + map ingressAnnotations = 1; + optional int32 replicas = 2; + optional string image = 3; + map resources = 4; + optional bool enableTls = 5; +} + +message RunTaskRequest { + ProjectId projectId= 1; + TaskCfg taskCfg = 2; +} + +message TaskId { + string name = 1; +} + +message ListTasksRequest { + +} + +message ListTasksResponse { + map tasks = 1; +} + +message TaskListEntry { + TaskStatus status = 1; + string timeStamp = 2; +} + +message RemoveTaskRequest { + string name = 1; + optional bool approve = 2; +} + service AmeService { - rpc GetTask(TaskIdentifier) returns (TaskTemplate) {} - rpc CreateTask(CreateTaskRequest) returns (TaskIdentifier) {} + rpc RunTask(RunTaskRequest) returns (TaskIdentifier) {} + rpc GetTask(TaskIdentifier) returns (TaskCfg) {} rpc DeleteTask(TaskIdentifier) returns (Empty) {} rpc CreateTaskProjectDirectory(TaskProjectDirectoryStructure) returns (Empty) {} rpc UploadProjectFile(stream ProjectFileChunk) returns (Empty) {} @@ -196,4 +347,7 @@ service AmeService { rpc GetProjectSrcStatus(ProjectSourceId) returns (ProjectSourceStatus) {} rpc GetProjectSrcId(ProjectSrcIdRequest) returns (ProjectSourceId) {} rpc ListProjectSrcs(ProjectSourceListParams) returns (ListProjectSrcsResponse) {} -} + rpc CreateProject(CreateProjectRequest) returns (ProjectId) {} + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) {} + rpc RemoveTask(RemoveTaskRequest) returns (Empty) {} + } diff --git a/lib/build.rs b/lib/build.rs index 1f4f3c1a..a830df5c 100644 --- a/lib/build.rs +++ b/lib/build.rs @@ -1,6 +1,10 @@ +use prost_build::Config; use std::io; fn main() -> io::Result<()> { + let mut prost_cfg = Config::new(); + prost_cfg.btree_map(["resources", "ingressAnnotations"]); + tonic_build::configure() .build_server(true) .build_client(true) @@ -8,6 +12,58 @@ fn main() -> io::Result<()> { ".", "#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema )]", ) + .type_attribute(".", "#[serde(rename_all = \"camelCase\")]") + .field_attribute( + "ProjectCfg.models", + "#[serde(skip_serializing_if = \"Vec::is_empty\", default)]", + ) + .field_attribute( + "ProjectCfg.dataSets", + "#[serde(skip_serializing_if = \"Vec::is_empty\", default)]", + ) + .field_attribute( + "TaskCfg.dataSets", + "#[serde(skip_serializing_if = \"Vec::is_empty\", default)]", + ) + .field_attribute( + "ProjectCfg.templates", + "#[serde(skip_serializing_if = \"Vec::is_empty\", default)]", + ) + .field_attribute( + "ProjectCfg.tasks", + "#[serde(default = \"Vec::::new\")]", + ) + .field_attribute("paths", "#[serde(default = \"Vec::::new\")]") + .field_attribute( + "TaskCfg.secrets", + "#[serde(default = \"Vec::::new\")]", + ) + .field_attribute( + "TaskCfg.taskRef", + "#[serde(skip_serializing_if = \"Option::is_none\")]", + ) + .field_attribute( + "TaskCfg.fromTemplate", + "#[serde(skip_serializing_if = \"Option::is_none\")]", + ) + .field_attribute( + "TaskCfg.artifactCfg", + "#[serde(skip_serializing_if = \"Option::is_none\")]", + ) + .field_attribute( + "TaskCfg.triggers", + "#[serde(skip_serializing_if = \"Option::is_none\")]", + ) + .field_attribute("TaskCfg.env", "#[serde(default = \"Vec::::new\")]") + .field_attribute( + "resources", + "#[serde(default = \"std::collections::BTreeMap::::new\")]", + ) + .field_attribute( + "ingressAnnotations", + "#[serde(default = \"std::collections::BTreeMap::::new\")]", + ) + .field_attribute("Secret.variant", "#[serde(flatten)]") .protoc_arg("--experimental_allow_proto3_optional") - .compile(&["ame.proto"], &["./"]) + .compile_with_config(prost_cfg, &["ame.proto"], &["./"]) } diff --git a/lib/src/ctrl.rs b/lib/src/ctrl.rs index 4eb9551a..4a19e957 100644 --- a/lib/src/ctrl.rs +++ b/lib/src/ctrl.rs @@ -1,16 +1,18 @@ use std::fmt::Debug; use crate::{ + custom_resources::common::kind_is_project, error::AmeError, grpc::{resource_id, ProjectSourceId, ResourceId}, Result, }; use async_trait::async_trait; use futures::StreamExt; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; use kube::{ api::{DeleteParams, ListParams, Patch, PatchParams, PostParams}, runtime::{watcher, WatchStreamExt}, - Api, Resource, ResourceExt, + Api, CustomResourceExt, Resource, ResourceExt, }; use serde::{de::DeserializeOwned, Serialize}; use serde_merge::omerge; @@ -18,6 +20,43 @@ use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use tonic::Status; +fn oref_is_project(oref: &OwnerReference) -> bool { + kind_is_project(&oref.kind) +} + +impl + CustomResourceExt> AmeResource for K { + fn gen_owner_ref(&self) -> Option { + let mut oref = self.controller_owner_ref(&())?; + oref.controller = Some(false); + Some(oref) + } +} + +pub trait AmeResource: ResourceExt { + fn parent_project_oref(&self) -> Result { + let potential_orefs: Vec = self + .meta() + .owner_references + .to_owned() + .map(|orefs| orefs.into_iter().filter(oref_is_project).collect()) + .unwrap_or(vec![]); + if potential_orefs.len() != 1 { + return Err(AmeError::FailedToFindParentProjectOref( + self.name_any(), + potential_orefs.len(), + )); + } + + Ok(potential_orefs[0].clone()) + } + + fn parent_project_name(&self) -> Result { + Ok(self.parent_project_oref()?.name) + } + + fn gen_owner_ref(&self) -> Option; +} + #[async_trait] pub trait AmeKubeResourceCtrl { type KubeResource: Resource diff --git a/lib/src/custom_resources/argo.rs b/lib/src/custom_resources/argo.rs index 5349a0f4..e46f2e8f 100644 --- a/lib/src/custom_resources/argo.rs +++ b/lib/src/custom_resources/argo.rs @@ -1,14 +1,27 @@ -// WARNING: generated by kopium - manual changes will be overwritten -use k8s_openapi::api::core::v1::{ - Container, LocalObjectReference, PersistentVolumeClaim, PodSecurityContext, Volume, +use super::new_task::Task; +use crate::{ + grpc::{secret::Variant, AmeSecretVariant}, + Result, +}; +use k8s_openapi::{ + api::core::v1::{ + Container, EnvVar, EnvVarSource, LocalObjectReference, PersistentVolumeClaim, + PersistentVolumeClaimSpec, PersistentVolumeClaimStatus, PodSecurityContext, + ResourceRequirements, SecretKeySelector, Volume, + }, + apimachinery::pkg::{ + api::resource::Quantity, + apis::meta::v1::{ObjectMeta, OwnerReference}, + }, }; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference}; use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json::json; use serde_tuple::*; -use std::collections::BTreeMap; -use std::default::Default; +use std::{collections::BTreeMap, default::Default}; + +use super::new_task::TaskContext; #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default, PartialEq)] #[kube( @@ -127,7 +140,6 @@ impl WorkflowTemplate { pub struct ArgoScriptTemplate { #[serde(flatten)] pub container: Container, - pub source: String, } @@ -148,6 +160,15 @@ pub struct WorkflowStep { pub inline: Option>, } +impl WorkflowStep { + fn new_inline(name: String, template: WorkflowTemplate) -> Self { + Self { + name, + inline: Some(Box::new(template)), + } + } +} + impl Default for Workflow { fn default() -> Self { Workflow { @@ -198,6 +219,12 @@ impl Workflow { self } + pub fn set_entrypoint(&mut self, template: WorkflowTemplate) -> &mut Workflow { + self.spec.entrypoint = template.name.clone(); + self.add_template(template); + self + } + pub fn add_template(&mut self, template: WorkflowTemplate) -> &mut Workflow { let mut templates = if let Some(templates) = self.spec.templates.clone() { templates @@ -244,6 +271,221 @@ impl Workflow { } } +pub struct WorkflowBuilder { + templates: Vec, + task_name: String, + service_account: String, + owner_reference: Option, + volumes: Vec<(String, BTreeMap)>, +} + +impl WorkflowBuilder { + pub fn new(task_name: String, service_account: String) -> Self { + Self { + templates: vec![], + task_name, + service_account, + owner_reference: None, + volumes: vec![], + } + } + + pub fn add_owner_reference(&mut self, owner_reference: OwnerReference) -> &mut Self { + self.owner_reference = Some(owner_reference); + self + } + + pub fn add_template(&mut self, template: WorkflowTemplate) -> &mut Self { + self.templates.push(template); + + self + } + + pub fn add_volume(&mut self, name: String, resources: BTreeMap) -> &mut Self { + self.volumes.push((name, resources)); + self + } + + pub fn build(self) -> Result { + let mut workflow = Workflow::default() + .set_name(self.task_name.clone()) + .set_service_account(self.service_account) + .label("ame-task".to_string(), self.task_name.clone()) + .clone(); + + let mut main_template = WorkflowTemplate::new("main".to_string()); + for mut template in self.templates { + template.label("ame-task".to_string(), self.task_name.clone()); + main_template.add_parallel_step(vec![WorkflowStep::new_inline( + template.name.clone(), + template, + )]); + } + + if let Some(owner_reference) = self.owner_reference { + workflow.add_owner_reference(owner_reference); + } + + workflow.set_entrypoint(main_template); + for (name, resources) in self.volumes { + workflow.add_volume_claim_template(new_pvc( + name, + vec!["ReadWriteOnce".to_string()], + ResourceRequirements { + requests: Some(resources), + limits: None, + }, + )); + } + Ok(workflow) + } +} + +fn new_pvc( + name: String, + access_mode: Vec, + resources: ResourceRequirements, +) -> PersistentVolumeClaim { + PersistentVolumeClaim { + metadata: ObjectMeta { + name: Some(name), + ..ObjectMeta::default() + }, + spec: Some(PersistentVolumeClaimSpec { + // TODO: make access modes configurable. + access_modes: Some(access_mode), + resources: Some(resources), + ..PersistentVolumeClaimSpec::default() + }), + + // Note that it is important to create the equivalent of an empty struct here + // and not just a None. + // Otherwise the Workflow controller will disagree with AME's controller on + // how an empty status should be specified. + status: Some(PersistentVolumeClaimStatus::default()), + } +} + +pub struct WorkflowTemplateBuilder<'a> { + env: Vec, + ctx: &'a TaskContext, + script: String, + name: String, +} + +impl<'a> WorkflowTemplateBuilder<'a> { + pub fn new(ctx: &'a TaskContext, script: String, name: String) -> Result { + let required_env: Vec = serde_json::from_value(json!([ + { + "name": "AWS_ACCESS_KEY_ID", + "valueFrom": { + "secretKeyRef": { + "key": "MINIO_ROOT_USER", + "name": "ame-minio-secret", + "optional": false, + } + }, + }, + { + "name": "AWS_SECRET_ACCESS_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "MINIO_ROOT_PASSWORD", + "name": "ame-minio-secret", + "optional": false + } + }, + }, + { + "name": "MLFLOW_TRACKING_URI", + "value": "http://mlflow.default.svc.cluster.local:5000" + }, + { + "name": "MINIO_URL", + "value": "http://ame-minio.ame-system.svc.cluster.local:9000", + }, + + { + "name": "PIPENV_YES", + "value": "1", + }, + ]))?; + + Ok(Self { + env: required_env, + ctx, + script, + name, + }) + } + + fn add_env_var(&mut self, var: EnvVar) -> &mut Self { + self.env.push(var); + self + } + + fn add_secret_env_var(&mut self, secret: Variant) -> &mut Self { + let Variant::Ame(AmeSecretVariant { key, inject_as }) = secret; + let var = EnvVar { + name: inject_as, + value_from: Some(EnvVarSource { + secret_key_ref: Some(SecretKeySelector { + key: "secret".to_string(), + name: Some(key), + ..SecretKeySelector::default() + }), + ..EnvVarSource::default() + }), + ..EnvVar::default() + }; + + self.add_env_var(var) + } + + pub fn build(mut self, task: &Task) -> Result { + for var in task.spec.cfg.env.clone() { + self.add_env_var(EnvVar { + name: var.key, + value: Some(var.val), + value_from: None, + }); + } + + for secret in task.spec.cfg.secrets.clone() { + if let Some(variant) = secret.variant { + self.add_secret_env_var(variant); + } + } + + let script_template = ArgoScriptTemplate { + source: self.script, + container: serde_json::from_value(json!( + { + "image": self.ctx.executor_image, + "command": ["bash"], + "volumeMounts": [{ + "name": self.ctx.task_volume, + "mountPath": "/project", + }], + "env": self.env, + "resources": { + "limits": task.spec.cfg.resources, + } + } + ))?, + }; + Ok(WorkflowTemplate { + security_context: Some(serde_json::from_value(json!({ + "runAsUser": 1001, + "fsGroup": 2000 + } + ))?), + script: Some(script_template), + ..WorkflowTemplate::new(self.name) + }) + } +} + #[cfg(test)] mod test { diff --git a/lib/src/custom_resources/common.rs b/lib/src/custom_resources/common.rs index 23d863f9..e78d565e 100644 --- a/lib/src/custom_resources/common.rs +++ b/lib/src/custom_resources/common.rs @@ -1,27 +1,40 @@ -use crate::custom_resources::argo::Workflow; -use crate::custom_resources::task::Task; +use crate::{ + custom_resources::{argo::Workflow, data_set::DataSet, new_task::Task, project::Project}, + error::AmeError, +}; use either::Either; -use k8s_openapi::api::core::v1::{LoadBalancerStatus, Service, ServiceSpec, ServiceStatus}; +use k8s_openapi::{ + api::core::v1::{LoadBalancerStatus, Service, ServiceSpec, ServiceStatus}, + apimachinery::pkg::apis::meta::v1::OwnerReference, +}; use kube::{ - api::{DeleteParams, ListParams}, - Api, Client, + api::{DeleteParams, ListParams, PatchParams}, + Api, Client, ResourceExt, }; + use std::time::Duration; +use super::project_source::ProjectSource; + pub async fn find_ame_endpoint( namespace: &str, service_name: &str, ) -> Result> { + return Ok("http://localhost:3342".to_string()); let client = Client::try_default().await?; let services = Api::::namespaced(client.clone(), namespace); let service = services.get(service_name).await?; - let Service { spec: Some(ServiceSpec{ - ports: Some(ports), - .. - }), - ..} = service else { - return Err(format!("failed to extract service ips and ports: {service:#?}"))?; + let Service { + spec: Some(ServiceSpec { + ports: Some(ports), .. + }), + .. + } = service + else { + return Err(format!( + "failed to extract service ips and ports: {service:#?}" + ))?; }; let port = ports @@ -43,18 +56,24 @@ pub async fn find_service_endpoint( let services = Api::::namespaced(client.clone(), namespace); let service = services.get(service_name).await?; - let Service { spec: Some(ServiceSpec{ - ports: Some(ports), - .. - }), status: Some(ServiceStatus{ - - load_balancer: Some(LoadBalancerStatus{ - ingress: Some(ingress) + let Service { + spec: Some(ServiceSpec { + ports: Some(ports), .. }), + status: + Some(ServiceStatus { + load_balancer: + Some(LoadBalancerStatus { + ingress: Some(ingress), + }), + .. + }), .. - }), - ..} = service else { - return Err(format!("failed to extract service ips and ports: {service:#?}"))?; + } = service + else { + return Err(format!( + "failed to extract service ips and ports: {service:#?}" + ))?; }; if ingress.len() != 1 { @@ -75,11 +94,28 @@ pub async fn find_service_endpoint( ))?; } - Ok(format!( - "http://{}:{}", - ingress[0].ip.as_ref().unwrap(), - ports[0].port - )) + let Some(ip) = ingress[0].ip.as_ref() else { + return Err("could not find IP from ingress".to_string().into()); + }; + + Ok(format!("http://{}:{}", ip, ports[0].port)) +} + +pub fn parent_project(owner_references: Vec) -> crate::Result { + let projects: Vec = owner_references + .into_iter() + .filter(|o| kind_is_project(&o.kind)) + .collect(); + + if projects.len() != 1 { + return Err(AmeError::MissingProject(projects.len())); + } + + Ok(projects[0].name.clone()) +} + +pub fn kind_is_project(kind: &str) -> bool { + kind == "Project" } /// Prepare a cluster for tests, under the assumptions that the `just setup_cluster` recipe has been run successfully. @@ -91,12 +127,124 @@ pub async fn setup_cluster( let client = Client::try_default().await?; let tasks = Api::::namespaced(client.clone(), namespace); let workflows = Api::::namespaced(client.clone(), namespace); + let project_srcs = Api::::namespaced(client.clone(), namespace); + let projects = Api::::namespaced(client.clone(), namespace); + let data_sets = Api::::namespaced(client.clone(), namespace); let dp = DeleteParams::default(); let lp = ListParams::default(); + let patch_params = PatchParams::apply("AME_TEST").force(); + + for mut project in projects.list(&lp).await?.into_iter() { + project.spec.deletion_approved = true; + project.metadata.managed_fields = None; + + let res = projects + .patch( + &project.name_any(), + &patch_params, + &kube::api::Patch::Apply(project), + ) + .await; + + match res { + Err(kube::Error::Api(e)) => { + if e.code == 409 { + return Ok((tasks, workflows)); + } + } + Err(e) => return Err(Box::new(e)), + _ => (), + } + } + + match project_srcs.delete_collection(&dp, &lp).await? { + Either::Left(_) => { + while !project_srcs.list(&lp).await?.items.is_empty() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Either::Right(status) => { + println!("Deleted collection of tasks: {status:?}"); + } + }; + for mut task in tasks.list(&lp).await?.into_iter() { + task.spec.deletion_approved = true; + task.metadata.managed_fields = None; + + tasks + .patch( + &task.name_any(), + &patch_params, + &kube::api::Patch::Apply(task), + ) + .await?; + } + + match projects.delete_collection(&dp, &lp).await? { + Either::Left(_) => { + for mut project in projects.list(&lp).await?.into_iter() { + project.spec.deletion_approved = true; + project.metadata.managed_fields = None; + + let _ = projects + .patch( + &project.name_any(), + &patch_params, + &kube::api::Patch::Apply(project), + ) + .await; + } + while !projects.list(&lp).await?.items.is_empty() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Either::Right(status) => { + println!("Deleted collection of tasks: {status:?}"); + } + }; + + match data_sets.delete_collection(&dp, &lp).await? { + Either::Left(_) => { + for mut data_set in data_sets.list(&lp).await?.into_iter() { + data_set.spec.deletion_approved = true; + + data_sets + .patch( + &data_set.name_any(), + &PatchParams::default(), + &kube::api::Patch::Merge(data_set), + ) + .await?; + } + + while !data_sets.list(&lp).await?.items.is_empty() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Either::Right(status) => { + println!("Deleted collection of tasks: {status:?}"); + } + }; match tasks.delete_collection(&dp, &lp).await? { Either::Left(_) => { + for mut task in tasks.list(&lp).await?.into_iter() { + task.spec.deletion_approved = true; + task.metadata.managed_fields = None; + + tasks + .patch( + &task.name_any(), + &patch_params, + &kube::api::Patch::Apply(task), + ) + .await?; + } + while !tasks.list(&lp).await?.items.is_empty() { tokio::time::sleep(Duration::from_millis(100)).await; } diff --git a/lib/src/custom_resources/data_set.rs b/lib/src/custom_resources/data_set.rs index ca5209f4..15ba9d01 100644 --- a/lib/src/custom_resources/data_set.rs +++ b/lib/src/custom_resources/data_set.rs @@ -1,15 +1,12 @@ -use crate::grpc::TaskCfg; -use crate::AmeError; -use crate::{custom_resources::task::Task, Result}; +use crate::{custom_resources::new_task::Task, grpc::TaskCfg, AmeError, Result}; -use kube::core::ObjectMeta; -use kube::{Client, CustomResource, Resource, ResourceExt}; +use kube::{core::ObjectMeta, Client, CustomResource, Resource, ResourceExt}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::grpc::DataSetCfg; +use crate::grpc::{task_status::Phase, DataSetCfg}; -use super::task::{TaskPhase, TaskSpec}; +use super::new_task::{ProjectSource, TaskSpec}; #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] #[kube( @@ -24,11 +21,12 @@ pub struct DataSetSpec { #[serde(flatten)] pub cfg: DataSetCfg, pub deletion_approved: bool, + pub project: Option, } #[derive(Debug, Clone, JsonSchema, Serialize, Deserialize, Default)] pub struct DataSetStatus { - pub phase: DataSetPhase, + pub phase: Option, } #[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] @@ -44,14 +42,15 @@ impl DataSetPhase { pub fn from_task(task: Task) -> DataSetPhase { let task_name = task.name_any(); - let task_phase = task.status.unwrap_or_default().phase.unwrap_or_default(); + // TODO: implement default trait for task phase. + let task_phase = task.status.unwrap_or_default().phase.unwrap_or( + crate::grpc::task_status::Phase::Pending(crate::grpc::TaskPhasePending {}), + ); match task_phase { - TaskPhase::Running | TaskPhase::Error | TaskPhase::Pending => { - DataSetPhase::RunningTask { task_name } - } - TaskPhase::Succeeded => DataSetPhase::Ready { task_name }, - TaskPhase::Failed => DataSetPhase::Failed { task_name }, + Phase::Running(_) | Phase::Pending(_) => DataSetPhase::RunningTask { task_name }, + Phase::Succeeded(_) => DataSetPhase::Ready { task_name }, + Phase::Failed(_) => DataSetPhase::Failed { task_name }, } } } @@ -69,15 +68,31 @@ impl Default for &DataSetPhase { } impl DataSet { + pub fn from_cfg(name: &str, cfg: DataSetCfg) -> Self { + Self::new( + name, + DataSetSpec { + cfg, + deletion_approved: false, + project: None, + }, + ) + } + pub async fn running_task(&self, _client: Client) -> Option { self.status.as_ref().and_then(|s| match &s.phase { - DataSetPhase::RunningTask { task_name } => Some(task_name.clone()), + Some(DataSetPhase::RunningTask { task_name }) => Some(task_name.clone()), _ => None, }) } pub fn phase(&self) -> &DataSetPhase { - self.status.as_ref().map(|s| &s.phase).unwrap_or_default() + self.status + .as_ref() + .map(|s| &s.phase) + .unwrap_or(&Some(DataSetPhase::Pending {})) + .as_ref() + .unwrap_or_default() } pub fn task_cfg(&self) -> &Option { @@ -86,18 +101,27 @@ impl DataSet { pub fn generate_task(&self) -> Result { let Some(owner_ref) = self.controller_owner_ref(&()) else { - return Err(AmeError::MissingOwnerRef(self.name_any())); - }; + return Err(AmeError::MissingOwnerRef(self.name_any())); + }; let Some(ref task_cfg) = self.spec.cfg.task else { - return Err(AmeError::MissingTaskCfg(self.name_any())); - }; + return Err(AmeError::MissingTaskCfg(self.name_any())); + }; // TODO: this should be removed once task_cfg is fleshed out. let default_name = "datatask".to_string(); - let name = task_cfg.name.as_ref().unwrap_or(&default_name); + let name = task_cfg + .name + .as_ref() + .unwrap_or(&default_name) + .replace('_', "-"); // TODO sanitize names + + let mut spec = TaskSpec::from(task_cfg.clone()); - let spec = TaskSpec::try_from(task_cfg.clone())?; + spec.project = self.spec.project.clone(); + if let Some(repo) = self.annotations().get("gitrepository") { + spec.source = Some(ProjectSource::from_public_git_repo(repo.to_string())); + } let metadata = ObjectMeta { name: Some(format!("{}{}", self.name_any(), name)), diff --git a/lib/src/custom_resources/mod.rs b/lib/src/custom_resources/mod.rs index 5f34d3c3..f0ca6ecb 100644 --- a/lib/src/custom_resources/mod.rs +++ b/lib/src/custom_resources/mod.rs @@ -1,28 +1,24 @@ -use crate::error::AmeError; use crate::grpc::*; -use k8s_openapi::chrono::OutOfRangeError; -use k8s_openapi::chrono::ParseError; -use kube::core::ObjectMeta; -use kube::ResourceExt; +use k8s_openapi::chrono::{OutOfRangeError, ParseError}; +use kube::{api::ListParams, core::ObjectMeta, Api, ResourceExt}; +use new_task::Task; use secrets::SecretError; use std::env::VarError; -use task::Task; use thiserror::Error; -use self::project_source::ProjectSource; -use self::project_source::ProjectSourceSpec; - -use self::task::TaskSpec; -use self::task::TaskType; +use self::project_source::{ProjectSource, ProjectSourceSpec}; +use project::Project; pub mod argo; pub mod common; pub mod data_set; +pub mod new_task; pub mod project; pub mod project_source; pub mod project_source_ctrl; pub mod secrets; -pub mod task; + +pub mod task_ctrl; #[derive(Error, Debug)] pub enum Error { @@ -35,7 +31,7 @@ pub enum Error { #[error("Received error from kube API: {0}")] KubeApiError(#[from] kube::Error), - #[error("Failed to find projet source: {0}")] + #[error("Failed to find project source: {0}")] MissingProjectSrc(String), #[error("libgit2 produced an error: {0}")] @@ -103,42 +99,19 @@ pub enum Error { #[error("failed to find AME file project source with name : {0}")] MissingAmeFile(String), -} -pub type Result = std::result::Result; + #[error("Task {0} is missing an executor")] + MissingExecutor(String), -impl TryFrom for Task { - type Error = Error; + #[error("Failed to find task cfg {0} referenced in {1}")] + MissingTaskCfg(String, String), - fn try_from(t: CreateTaskRequest) -> Result { - let CreateTaskRequest { - id: Some(TaskIdentifier { name: id }), - template: Some(template), - } = t else { - return Err(Error::ConversionError("Failed to extract id and template from CreateTaskRequest".to_string())) - }; - - Ok(Task { - metadata: ObjectMeta { - name: Some(id), - ..ObjectMeta::default() - }, - spec: TaskSpec { - projectid: Some(template.projectid), - runcommand: Some(template.command), - image: template.image, - task_type: template.task_type.map(|t| match t { - 2 => TaskType::Poetry, - 1 => TaskType::Mlflow, - _ => TaskType::PipEnv, - }), - ..TaskSpec::default() - }, - status: None, - }) - } + #[error("{0}")] + AmeError(#[from] crate::AmeError), } +pub type Result = std::result::Result; + impl From for ProjectSource { fn from(project_src: ProjectSourceCfg) -> Self { ProjectSource { @@ -152,58 +125,28 @@ impl From for ProjectSource { } } -impl From for Task { - fn from(t: TaskTemplate) -> Self { - Task { - metadata: ObjectMeta { - generate_name: Some("mytask".to_string()), - ..ObjectMeta::default() - }, - spec: TaskSpec { - projectid: Some(t.projectid), - runcommand: Some(t.command), - image: t.image, - task_type: t.task_type.map(|t| { - if t == 1 { - TaskType::Mlflow - } else { - TaskType::PipEnv - } - }), - ..TaskSpec::default() - }, - status: None, - } - } -} - -impl From for TaskTemplate { - fn from(t: Task) -> Self { - TaskTemplate { - name: "".to_string(), - command: t.spec.runcommand.unwrap_or("".to_string()), - projectid: t.spec.projectid.unwrap_or("".to_string()), - image: t.spec.image, - task_type: t.spec.task_type.map(|t| match t { - TaskType::Mlflow => 1, - TaskType::Poetry => 2, - TaskType::PipEnv => 0, - }), - } - } -} - impl From for TaskIdentifier { fn from(t: Task) -> Self { TaskIdentifier { name: t.name_any() } } } -impl TryFrom for TaskSpec { - type Error = AmeError; - fn try_from(cfg: TaskCfg) -> crate::Result { - Ok(TaskSpec::from_ref(cfg.task_ref.ok_or( - AmeError::MissingTaskRef(cfg.name.unwrap_or_default()), - )?)) +pub async fn find_project( + projects: Api, + name: String, + _source: String, +) -> Result { + let matches: Vec = projects + .list(&ListParams::default()) + .await? + .items + .into_iter() + .filter(|p| p.spec.cfg.name == name) + .collect(); + + if matches.len() != 1 { + return Err(Error::MissingProject(name)); } + + Ok(matches[0].clone()) } diff --git a/lib/src/custom_resources/new_task.rs b/lib/src/custom_resources/new_task.rs new file mode 100644 index 00000000..2a70ec37 --- /dev/null +++ b/lib/src/custom_resources/new_task.rs @@ -0,0 +1,507 @@ +use std::collections::BTreeMap; + +use super::{ + argo::{Workflow, WorkflowBuilder, WorkflowTemplateBuilder}, + common::parent_project, + data_set::DataSet, + project::{add_owner_reference, Project}, + secrets::SecretReference, +}; +use crate::{ + custom_resources::{find_project, task_ctrl::resolve_data_set_path}, + error::AmeError, + grpc::{task_status, ArtifactCfg, TemplateRef}, + Result, +}; +use k8s_openapi::apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::OwnerReference}; +use kube::{core::ObjectMeta, Api, CustomResource, Resource, ResourceExt}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_merge::omerge; +use tracing::debug; + +use crate::grpc::{TaskCfg, TaskPhasePending, TaskStatus}; + +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + kind = "Task", + group = "ame.teainspace.com", + version = "v1alpha1", + namespaced +)] +#[kube(status = "TaskStatus", shortname = "tk")] +#[serde(rename_all = "camelCase")] +pub struct TaskSpec { + #[serde(flatten)] + pub cfg: TaskCfg, + pub deletion_approved: bool, + pub source: Option, + pub project: Option, +} + +impl TaskStatus { + pub fn pending() -> Self { + TaskStatus { + phase: Some(task_status::Phase::pending()), + } + } +} + +impl task_status::Phase { + pub fn pending() -> Self { + Self::Pending(TaskPhasePending {}) + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ProjectSource { + Git { + repository: String, + reference: String, + user_name: String, + secret: Option, + }, + Ame { + path: String, + }, +} + +impl ProjectSource { + pub fn from_public_git_repo(repository: String) -> Self { + ProjectSource::Git { + repository, + reference: "".to_string(), + user_name: "".to_string(), + secret: None, + } + } + + fn command(&self) -> String { + match self { + ProjectSource::Git { + repository, + reference: _, + user_name: _, + secret: None, + } => { + format!( + " + + git clone {repository} repo + + cd repo + + git fetch origin + + git checkout origin/reference + + cd .. + + cp -r repo/* . + + rm -rf repo + + " + ) + } + ProjectSource::Ame { path } => { + format!("s3cmd --no-ssl --region eu-central-1 --host=$MINIO_URL --host-bucket=$MINIO_URL get --recursive s3://{path} ./" ) + } + _ => todo!("add tests for other git sources"), + } + } +} + +pub fn build_workflow(task: Task, ctx: TaskContext) -> Result { + debug!("building task with context: {:?}", ctx); + + let mut wf_builder = WorkflowBuilder::new(task.name_any(), ctx.service_account.clone()); + + let mut volume_resource_requirements = BTreeMap::new(); + volume_resource_requirements.insert("storage".to_string(), Quantity("50Gi".to_string())); + + wf_builder.add_volume(task.name_any(), volume_resource_requirements); + + if let Some(oref) = task.controller_owner_ref(&()) { + wf_builder.add_owner_reference(oref); + }; + + let setup_template = + WorkflowTemplateBuilder::new(&ctx, task.load_command(&ctx)?, "setup".to_string())? + .build(&task)?; + + wf_builder.add_template(setup_template); + + let main_template = + WorkflowTemplateBuilder::new(&ctx, task.exec_command()?, task.name_any())?.build(&task)?; + wf_builder.add_template(main_template); + + if task.should_save_artifacts() { + let artifact_save_template = WorkflowTemplateBuilder::new( + &ctx, + task.artifact_save_command()?, + "saveartifacts".to_string(), + )? + .build(&task)?; + wf_builder.add_template(artifact_save_template); + } + + wf_builder.build() +} + +impl Task { + pub fn approve_deletion_patch() -> Self { + Task { + metadata: ObjectMeta::default(), + spec: TaskSpec { + deletion_approved: true, + ..TaskSpec::default() + }, + status: None, + } + } + + pub fn project(&self) -> Result { + parent_project(self.owner_references().to_vec()) + } + + fn should_save_artifacts(&self) -> bool { + self.spec.cfg.artifact_cfg.is_some() + } + + fn artifact_save_command(&self) -> Result { + match self.spec.cfg.artifact_cfg { + Some(ArtifactCfg{ save_changed_files, ..}) if save_changed_files => Ok(format!("save_artifacts {}", self.artifact_path()?)), + Some(ArtifactCfg { ref paths, .. }) => Ok(paths.iter().map(|p| format!("s3cmd --no-ssl --region us-east-1 --host=$MINIO_URL --host-bucket=$MINIO_URL put {p} s3://$ARTIFACT_STORAGE_PATH{p}") ).collect::>().join("\n\n")), + None => Err(AmeError::EmptyArtifactCfg(self.spec.cfg.name.clone().unwrap_or_default())) + } + } + + fn artifact_path(&self) -> Result { + if let Some(ref name) = self.metadata.name { + Ok(format!("ame/tasks/{name}/artifacts/")) + } else { + Err(AmeError::MissingName) + } + } + + fn load_command(&self, ctx: &TaskContext) -> Result { + let mut cmd = String::new(); + + for ds in ctx.required_data_sets.iter() { + cmd = format!("cmd \n\n s3cmd --no-ssl --region eu-central-1 --host=$MINIO_URL --host-bucket=$MINIO_URL get --recursive s3://{} ./ + + \n\n", resolve_data_set_path(ds.clone())?) + } + + let parent_project = parent_project(self.owner_references().to_vec())?; + + let load_cmd = if let Some(ref source) = self.spec.source { + source.command() + } else { + ProjectSource::Ame { + path: self.project_dir_path(parent_project), + } + .command() + }; + + Ok(format!("{cmd} \n\n {load_cmd}")) + } + + fn project_dir_path(&self, parent_project: String) -> String { + format!("ame/tasks/{parent_project}/projectfiles/") + } + + fn exec_command(&self) -> Result { + self.spec + .cfg + .executor + .as_ref() + .map(|executor| executor.command()) + .ok_or(AmeError::MissingExecutor(self.name_any())) + } +} + +#[derive(Clone)] +pub struct TaskBuilder { + task: Task, +} + +impl Default for TaskBuilder { + fn default() -> Self { + Self::new() + } +} + +impl TaskBuilder { + pub fn new() -> Self { + Self { + task: Task { + metadata: ObjectMeta::default(), + spec: TaskSpec::default(), + status: None, + }, + } + } + + pub fn set_project(&mut self, name: String) -> &mut Self { + self.task.spec.project = Some(name); + self + } + + pub fn from_cfg(cfg: TaskCfg) -> Self { + Self { + task: Task { + metadata: ObjectMeta::default(), + spec: TaskSpec::from(cfg), + status: None, + }, + } + } + + pub fn set_name_prefix(&mut self, prefix: String) -> &mut Self { + self.task.metadata.generate_name = Some(prefix); + self + } + + pub fn set_name(&mut self, name: String) -> &mut Self { + self.task.metadata.name = Some(name); + self + } + + pub fn set_project_src(&mut self, src: ProjectSource) -> &mut Self { + self.task.spec.source = Some(src); + + self + } + + pub fn set_model_version(&mut self, src: String) -> &mut Self { + self.task + .annotations_mut() + .insert("model_source".to_string(), src); + self + } + + pub fn build(mut self) -> Task { + if self.task.name_any().is_empty() { + let prefix = self + .task + .spec + .cfg + .name + .clone() + .unwrap_or("task".to_string()); + self.set_name_prefix(prefix); + } + + self.task.clone() + } + + pub fn add_owner_reference(&mut self, owner_ref: OwnerReference) -> &mut Self { + self.task.metadata = add_owner_reference(self.task.meta().clone(), owner_ref); + + self + } +} + +impl From for TaskSpec { + fn from(cfg: TaskCfg) -> Self { + TaskSpec { + cfg, + ..TaskSpec::default() + } + } +} + +pub async fn resolve_task_templates( + task: Task, + project: Project, + projects: Api, +) -> Result { + let Some(ref template_ref) = task.spec.cfg.from_template else { + return Ok(task); + }; + + let template = match template_ref { + TemplateRef { + ref name, + project: None, + } => project.get_template(name).ok_or(AmeError::MissingTemplate( + name.clone(), + project.spec.cfg.name, + ))?, + TemplateRef { + name, + project: Some(project_name), + } => find_project(projects, project_name.clone(), "".to_string()) + .await + .map_err(|_| AmeError::MissingProject(0))? + .get_template(name) + .ok_or(AmeError::MissingTemplate( + name.clone(), + project.spec.cfg.name, + ))?, + }; + + debug!("found template {:?}", template); + + let task_spec = TaskSpec { + cfg: omerge(template, task.spec.cfg)?, + ..task.spec + }; + + Ok(Task { + spec: task_spec, + ..task + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskContext { + pub executor_image: String, + pub task_volume: String, + pub required_data_sets: Vec, + pub service_account: String, +} + +#[cfg(test)] +mod test { + + use kube::core::ObjectMeta; + + use crate::grpc::{ + secret::Variant, task_cfg::Executor, AmeSecretVariant, EnvVar, PoetryExecutor, Secret, + TaskPhaseRunning, TaskRef, + }; + + use super::*; + + #[test] + fn snap_shot_task_yaml() -> Result<()> { + let mut resources = BTreeMap::new(); + resources.insert("cpu".to_string(), "2".to_string()); + + let task = Task { + metadata: ObjectMeta { + name: Some("mytask".to_string()), + ..ObjectMeta::default() + }, + + spec: TaskSpec { + cfg: TaskCfg { + name: Some("mytask".to_string()), + task_ref: Some(TaskRef { + name: "othertask".to_string(), + project: None, + }), + executor: None, + resources, + data_sets: Vec::new(), + from_template: None, + artifact_cfg: None, + triggers: None, + env: vec![EnvVar { + key: "SOME_VAR".to_string(), + val: "someval".to_string(), + }], + secrets: vec![Secret { + variant: Some(Variant::Ame(AmeSecretVariant { + key: "secretkey".to_string(), + inject_as: "MY_SECRET".to_string(), + })), + }], + }, + source: Some(ProjectSource::Ame { + path: "test".to_string(), + }), + deletion_approved: false, + project: None, + }, + status: Some(TaskStatus { + phase: Some(task_status::Phase::Running(TaskPhaseRunning { + workflow_name: "someinfo".to_string(), + })), + }), + }; + + insta::assert_yaml_snapshot!(&task); + + Ok(()) + } + #[test] + fn snap_shot_workflow_yaml() -> Result<()> { + let mut resources = BTreeMap::new(); + resources.insert("cpu".to_string(), "2".to_string()); + resources.insert("memory".to_string(), "2Gi".to_string()); + let task = Task { + metadata: ObjectMeta { + name: Some("mytask".to_string()), + owner_references: Some(vec![OwnerReference { + block_owner_deletion: None, + api_version: "sfsdd".to_string(), + controller: None, + kind: "Project".to_string(), + name: "parentproject343".to_string(), + uid: "sdsfdsf".to_string(), + }]), + ..ObjectMeta::default() + }, + + spec: TaskSpec { + cfg: TaskCfg { + name: Some("mytask".to_string()), + task_ref: Some(TaskRef { + name: "othertask".to_string(), + project: None, + }), + executor: Some(Executor::Poetry(PoetryExecutor { + python_version: "3.11".to_string(), + command: "python train.py".to_string(), + })), + data_sets: Vec::new(), + resources, + from_template: None, + artifact_cfg: Some(ArtifactCfg { + save_changed_files: true, + paths: vec![], + }), + triggers: None, + env: vec![EnvVar { + key: "SOME_VAR".to_string(), + val: "someval".to_string(), + }], + secrets: vec![Secret { + variant: Some(Variant::Ame(AmeSecretVariant { + key: "secretkey".to_string(), + inject_as: "MY_SECRET".to_string(), + })), + }], + }, + source: Some(ProjectSource::Ame { + path: "test".to_string(), + }), + deletion_approved: false, + project: None, + }, + status: Some(TaskStatus { + phase: Some(task_status::Phase::Running(TaskPhaseRunning { + workflow_name: "someinfo".to_string(), + })), + }), + }; + + // TODO: "create data set for testing "); + + let task_ctx = TaskContext { + executor_image: "myimage".to_string(), + task_volume: "myvolume".to_string(), + required_data_sets: vec![], + service_account: "ame-task".to_string(), + }; + + insta::assert_yaml_snapshot!(build_workflow(task, task_ctx)?); + + Ok(()) + } +} diff --git a/lib/src/custom_resources/project.rs b/lib/src/custom_resources/project.rs index b189e512..d07ac28e 100644 --- a/lib/src/custom_resources/project.rs +++ b/lib/src/custom_resources/project.rs @@ -1,18 +1,17 @@ use std::{ collections::{BTreeMap, HashMap}, default::Default, - sync::Arc, - time::Duration, }; use crate::{ - custom_resources::task, - custom_resources::task::{TaskPhase, TaskSpec}, - custom_resources::{Error, Result}, + ctrl::AmeResource, + custom_resources::{data_set::DataSet, Error, Result}, + error::AmeError, + grpc::{resource_map_conv, DataSetCfg, Model, ProjectCfg, ProjectStatus, TaskCfg, TaskRef}, }; -use crate::grpc::LogEntry; -use futures::{future::BoxFuture, FutureExt, StreamExt}; +use super::new_task::{ProjectSource, Task, TaskBuilder}; + use k8s_openapi::{ api::{ apps::v1::{Deployment, DeploymentSpec}, @@ -26,21 +25,16 @@ use k8s_openapi::{ }, }, apimachinery::pkg::{ - apis::meta::v1::{LabelSelector, OwnerReference, Time}, + apis::meta::v1::{LabelSelector, OwnerReference}, util::intstr::IntOrString, }, }; -use kube::{ - api::{ListParams, Patch, PatchParams, PostParams}, - core::ObjectMeta, - runtime::{controller::Action, Controller}, - Api, Client, CustomResource, Resource, ResourceExt, -}; -use reqwest::Url; +use kube::{core::ObjectMeta, CustomResource, Resource, ResourceExt}; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; -use tracing::{debug, error, log::info}; +use tracing::debug; #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] #[kube( @@ -53,36 +47,12 @@ use tracing::{debug, error, log::info}; )] #[serde(rename_all = "camelCase")] pub struct ProjectSpec { - #[serde(rename = "projectid")] - pub id: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub models: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub templates: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub log_entry: Option, + #[serde(flatten)] + pub cfg: ProjectCfg, + pub deletion_approved: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub data_sets: Option>, -} - -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, PartialEq, Eq, Default)] -pub struct ProjectStatus { - #[serde(skip_serializing_if = "Option::is_none")] - pub models: Option>, -} - -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] -pub struct DataSet { - pub name: String, - pub task: TaskSpec, - pub path: String, + #[serde(default)] + pub enable_triggers: Option, } pub fn local_name(name: String) -> String { @@ -98,192 +68,159 @@ pub fn local_name(name: String) -> String { } } -impl DataSet { - pub fn gen_task(&self) -> task::Task { - task::Task { - metadata: ObjectMeta { - // TODO: what are the impliciations of using a fixed name here instead of - // generating a random one? - name: Some(self.name.clone()), - ..ObjectMeta::default() - }, - spec: self.task.clone(), - status: None, - } +pub fn project_name(name: String) -> Option { + if !name.contains('.') { + return None; + } + + let splits: Vec = name.split('.').map(String::from).collect(); + + if splits.len() > 1 { + return Some(splits[0].clone()); } + + None } -impl ProjectStatus { - fn set_model_status(&mut self, name: &str, status: ModelStatus) { - if let Some(ref mut statuses) = self.models { - statuses.insert(name.to_string(), status); - } else { - let mut statuses: BTreeMap = BTreeMap::new(); - statuses.insert(name.to_string(), status); - self.models = Some(statuses); +impl From for ProjectSpec { + fn from(cfg: ProjectCfg) -> Self { + Self { + cfg, + deletion_approved: false, + enable_triggers: Some(false), } } +} - fn set_model_validation(&mut self, name: &str, validation: ModelValidationStatus) { - let mut default = ModelStatus::default(); - let mut status = self.get_model_status(name).unwrap_or(&mut default).clone(); +pub fn generate_task_name(project_name: String, task_name: String) -> String { + format!("{project_name}{task_name}") +} - status.validation = Some(validation); +pub fn generate_data_set_task_name(project_name: String, data_set_name: String) -> String { + format!("{}{}_task", project_name, data_set_name) +} - self.set_model_status(name, status); - } +impl Project { + pub fn from_cfg(cfg: ProjectCfg) -> Self { + Self { + metadata: ObjectMeta { + generate_name: Some(cfg.name.clone()), + ..ObjectMeta::default() + }, - pub fn get_model_status(&mut self, name: &str) -> Option<&mut ModelStatus> { - self.models.as_mut().and_then(|models| models.get_mut(name)) + spec: ProjectSpec::from(cfg), + status: None, + } } - fn set_latest_valid_model_version(&mut self, name: &str, version: String) { - let mut status = self - .get_model_status(name) - .map(|s| s.to_owned()) - .unwrap_or_default(); - - status.latest_valid_model_version = Some(version); - self.set_model_status(name, status) + pub fn deletion_approved(&self) -> bool { + self.spec.deletion_approved } - fn get_latest_valid_model_version(&mut self, name: &str) -> Option { - self.get_model_status(name) - .as_ref() - .and_then(|model_status| model_status.latest_valid_model_version.clone()) + pub fn approve_deletion(&mut self) { + self.spec.deletion_approved = true; } -} -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] -#[serde(rename_all = "camelCase")] -pub struct Model { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - model_type: Option, + pub fn get_data_set(&self, data_set_name: String) -> Option { + let data_set_name = local_name(data_set_name); - training: TrainingCfg, - #[serde(skip_serializing_if = "Option::is_none")] - deployment: Option, + self.clone() + .spec + .cfg + .data_sets + .into_iter() + .find(|ds| ds.name == data_set_name) + } - #[serde(skip_serializing_if = "Option::is_none")] - validation_task: Option, -} + // TODO: documentat that this assumes a local data set. + pub fn generate_data_set(&self, data_set_name: String) -> crate::Result { + let Some(cfg) = self.get_data_set(data_set_name.clone()) else { + debug!("failed to get data set {}", data_set_name); + return Err(AmeError::MissingDataSet(data_set_name, self.name_any())); + }; -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] -pub struct TrainingCfg { - task: TaskSpec, - #[serde(skip_serializing_if = "Option::is_none")] - schedule: Option, -} + let mut data_set = DataSet::from_cfg( + &format!("dataset{}{}", self.spec.cfg.name, data_set_name), + cfg, + ); -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] -pub struct ModelDeployment { - deploy: bool, - auto_train: bool, - #[serde(skip_serializing_if = "Option::is_none")] - image: Option, - #[serde(skip_serializing_if = "Option::is_none")] - resources: Option, - #[serde(skip_serializing_if = "Option::is_none")] - replicas: Option, - #[serde(skip_serializing_if = "Option::is_none")] - ingress: Option, - #[serde(skip_serializing_if = "Option::is_none")] - ingress_annotations: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - enable_tls: Option, -} + if let Some(repo) = self.annotations().get("gitrepository") { + data_set + .annotations_mut() + .insert("gitrepository".to_string(), repo.to_string()); + } -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] -pub enum ModelType { - Mlflow, -} + data_set.spec.project = Some(self.spec.cfg.name.clone()); -#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, PartialEq, Eq, Default)] -#[serde(rename_all = "camelCase")] -pub struct ModelStatus { - #[serde(skip_serializing_if = "Option::is_none")] - pub latest_model_version: Option