diff --git a/.bazel_downloader_config b/.bazel_downloader_config new file mode 100644 index 000000000..df8204c64 --- /dev/null +++ b/.bazel_downloader_config @@ -0,0 +1,4 @@ +# Prefer the Google Maven Central mirror for repository-rule downloads that +# hardcode repo1.maven.org. Coursier dependencies still use MODULE.bazel's +# maven.install repositories. +rewrite (repo1.maven.org)/(maven2/.*) https://maven-central.storage-download.googleapis.com/$2 diff --git a/.bazelignore b/.bazelignore index ac880334b..f50748fbc 100644 --- a/.bazelignore +++ b/.bazelignore @@ -28,7 +28,12 @@ # with our bzlmod setup. Re-enable per-subtree as Phase B brings each subtree # native. src/compute-plane-services -src/control-plane-services +# src/control-plane-services is carved out per-subtree: cloud-tasks is native +# (Phase B) and must stay loadable, while these siblings still carry nested +# MODULE.bazel / WORKSPACE-style BUILD files that conflict with bzlmod. +src/control-plane-services/function-autoscaler +src/control-plane-services/helm-reval +src/control-plane-services/nats-auth-callout src/invocation-plane-services src/libraries/python # stargate is a nested Bazel module (own MODULE.bazel under @@ -45,7 +50,16 @@ docs fern infra migrations -tools + +# Note: tools/ is not ignored here so //tools/bazel/java (the root-owned Java +# build helpers) is loadable. Gazelle is still kept out of tools/ via the +# gazelle:exclude directive in the root BUILD.bazel. + +# The Java subtrees (src/libraries/java/nv-boot-parent and +# src/control-plane-services/cloud-tasks) are part of the single root module: +# plain source dirs with BUILD.bazel only, no nested MODULE.bazel. They are +# intentionally loadable so root labels traverse them. Gazelle is kept out via +# the root BUILD.bazel gazelle:exclude directives. # Vendored dependencies inside the two Phase 1 subtrees. Bazel resolves these # from MODULE.bazel via Gazelle's go_deps extension, not from on-disk vendor. diff --git a/.bazelrc b/.bazelrc index 863882f9d..9a11339d3 100644 --- a/.bazelrc +++ b/.bazelrc @@ -68,15 +68,30 @@ common --incompatible_enable_proto_toolchain_resolution build --workspace_status_command=./tools/workspace_status.sh build --nostamp -# Java: build and run against a hermetic remotejdk 21 regardless of the host -# JDK, so Java service builds are reproducible on any developer machine and in -# CI. Language version 21 is the current Spring Boot 3.x baseline. +# Java baseline: Java 25 for the whole monorepo (nv-boot-parent and the Java +# control-plane services). local_jdk is the approved runtime. The pinned +# bazel-ci image supplies Temurin 25 through JAVA_HOME; the bare +# Docker-integration lane supplies it through actions/setup-java. The Java +# build and test jobs prove that both environments satisfy this contract. +# Full javac (header compilation off) is required because Lombok-generated APIs +# in the imported projects are not compatible with Bazel's Turbine header +# compiler. build --java_language_version=25 -build --java_runtime_version=remotejdk_25 build --tool_java_language_version=25 -build --tool_java_runtime_version=remotejdk_25 +build --java_runtime_version=local_jdk +build --tool_java_runtime_version=local_jdk build --java_header_compilation=false +# rules_spring 2.6.3 (cloud-tasks Spring Boot packaging) still uses bare Java +# rules/providers in its own BUILD and .bzl files; Bazel 9 requires these +# symbols to be autoloaded for that external repo. +common --incompatible_autoload_externally=+@rules_java + +# Downloader rewrites for repository-rule downloads (e.g. rules that hardcode +# repo1.maven.org). Coursier maven.install still uses the repositories declared +# in MODULE.bazel. Public, credentials-free mappings only. +common --downloader_config=.bazel_downloader_config + test --test_output=errors # Bazel's sandbox strips HOME, but a lot of Go code (cobra/viper config, # user state files, git cache lookups) blows up without it. Point HOME at diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 7daaac5a0..c02503e32 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -12,14 +12,14 @@ # # Matrix entries reference subtrees that have their Bazel scaffold # mirrored to GitHub (each subtree's upstream .oss-allowlist controls -# whether BUILD.bazel files reach this mirror). Add a row to the ROWS -# table in the detect job when a new subtree's OSS-flip MR merges -# upstream. +# whether BUILD.bazel files reach this mirror). Add a static ROWS entry when a +# non-Java subtree's OSS-flip MR merges upstream. Java components are +# discovered from component-local bazel-java-ci.json files. # # Image push targets, internal NGC registry pulls, and the nvcfbarn # remote cache are all internal-only; this workflow runs build + test -# only. It pulls the same bazel-ci toolchain image the GitLab pipeline -# uses, mirrored to ghcr.io by the bazel-ci-image workflow. +# only. The bazel-ci image is built by the internal nvcf/bazel-ci-templates +# project and mirrored to GHCR for this public workflow. name: bazel @@ -64,12 +64,16 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.detect.outputs.matrix }} + matrix_docker: ${{ steps.detect.outputs.matrix_docker }} any: ${{ steps.detect.outputs.any }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Check Java import boundaries + run: bash tools/ci/check-java-import-boundaries + - name: Compute changed subtrees id: detect env: @@ -96,6 +100,9 @@ jobs: # its own private agent image (not publicly pullable), so it is not # externally buildable and is built only by its own internal CI. # Excluded from the public build pending a public base (NVIDIA/nvcf#39). + # Row fields: + # id|path|tests_skip|component_kind|ci_lane + # Static non-Java rows use only the first three fields. ROWS='root|.|false grpc-proxy|src/invocation-plane-services/grpc-proxy|false nats-auth-callout|src/control-plane-services/nats-auth-callout|false @@ -115,9 +122,34 @@ jobs: function-autoscaler|src/control-plane-services/function-autoscaler|false helm-reval|src/control-plane-services/helm-reval|false stargate|src/libraries/rust/stargate|false' - # Paths that affect the root-module workspace build (native - # root subtrees, Go and Java, plus the shared Bazel scaffold). - ROOT_GLOBS=(MODULE.bazel .bazelrc .bazelversion BUILD.bazel WORKSPACE WORKSPACE.bazel go.work go.work.bazel rules/ platforms/ tools/ ci/ src/clis/ src/libraries/ .github/bazel-root-test-quarantine.txt) + + # Java rows are discovered from component-local bazel-java-ci.json + # descriptors. The descriptor's parent directory is the component + # path, so adding a Java service never requires editing this workflow. + # jq validates every field before the row reaches matrix generation. + while IFS= read -r manifest; do + path="${manifest%/bazel-java-ci.json}" + id="$(jq -er '.id | select(test("^[a-z0-9][a-z0-9-]*$"))' "$manifest")" + tests_skip="$(jq -er '.tests_skip | if type == "boolean" then tostring else error("tests_skip must be boolean") end' "$manifest")" + component_kind="$(jq -er '.component_kind | select(. == "java-framework" or . == "java-service")' "$manifest")" + ci_lane="$(jq -er '.ci_lane | select(. == "build-container" or . == "docker-host")' "$manifest")" + ROWS="${ROWS} + ${id}|${path}|${tests_skip}|${component_kind}|${ci_lane}" + done < <(find src -name bazel-java-ci.json -type f -print | LC_ALL=C sort) + + # Every discovered Java component belongs to the root `nvcf` Bazel + # module; it does not own a nested MODULE.bazel. Its lane therefore + # invokes Bazel from the repository root and scopes work to + # ///... . Descriptor component_kind and ci_lane + # fields derive shared triggers, framework-consumer validation, + # execution-environment routing, and artifact upload; do not maintain + # separate component-name lists for those behaviors. + # Paths that affect the root-module workspace build (native root + # subtrees plus the shared Bazel scaffold). Java and stargate no longer + # ride the root row: discovered Java components and stargate have + # their own scoped rows, so src/libraries/ is narrowed to + # src/libraries/go/. + ROOT_GLOBS=(MODULE.bazel .bazelrc .bazelversion BUILD.bazel WORKSPACE WORKSPACE.bazel go.work go.work.bazel rules/ platforms/ tools/ ci/ src/clis/ src/libraries/go/ .github/bazel-root-test-quarantine.txt) run_all=false changed="" @@ -138,14 +170,78 @@ jobs: fi ;; esac + # Policy: run the full test matrix on every PR and push so no subtree's + # tests are skipped because an unrelated path changed (regression safety + # over queue latency; the remote cache keeps unaffected targets to cache + # hits). The change-aware detection above and the reverse-dependency + # edges below are retained but inert while this is set; delete the next + # line to restore change-aware scheduling. + run_all=true # A change to this workflow itself revalidates everything. if printf '%s\n' "$changed" | grep -q '^\.github/workflows/bazel\.yml$'; then run_all=true fi + # Reverse-dependency edge. A discovered Java framework change + # validates every discovered java-service row. + java_framework_changed=false + if [ "$run_all" != "true" ]; then + while IFS='|' read -r _id path _tests_skip component_kind _ci_lane; do + component_kind="$(echo "$component_kind" | xargs)" + if [ "$component_kind" = "java-framework" ] && printf '%s\n' "$changed" | grep -q "^${path}/"; then + java_framework_changed=true + break + fi + done <<< "$ROWS" + fi + + # Root-owned Java dependencies and reusable Java rules/tools affect + # every root-scoped Java component even though the changed path is + # outside that component's subtree. Keep these explicit edges correct + # while run_all=true; they become active automatically if change-aware + # scheduling is restored. + JAVA_SHARED_GLOBS=( + MODULE.bazel + MODULE.bazel.lock + maven_install.json + .bazelrc + .bazelversion + .bazel_downloader_config + rules/java/ + tools/bazel/java/ + tools/ci/stage-bazel-java-artifacts + ) + java_shared_changed=false + if [ "$run_all" != "true" ]; then + for g in "${JAVA_SHARED_GLOBS[@]}"; do + if printf '%s\n' "$changed" | awk -v p="$g" 'index($0, p) == 1 { f = 1 } END { exit !f }'; then + java_shared_changed=true + break + fi + done + fi + + # Rows with ci_lane=docker-host own requires-docker (Testcontainers) + # tests. They run directly on the GitHub host where a Docker daemon is + # available, executing their FULL suite with no tag filter. Such a row + # is not also placed in the build-container matrix. + # NOTE: grpc-proxy also owns a requires-docker test but is a nested Rust + # module whose test has never run in CI; wiring+validating it is tracked + # separately (NVIDIA/nvcf#396) to keep this change scoped to the Java + # subtrees. It stays in the build-container matrix for now (build + # coverage). include="[]" - while IFS='|' read -r id path tests_skip; do + include_docker="[]" + while IFS='|' read -r id path tests_skip component_kind ci_lane; do id="$(echo "$id" | xargs)"; [ -z "$id" ] && continue + component_kind="$(echo "$component_kind" | xargs)" + ci_lane="$(echo "$ci_lane" | xargs)" + # Discovered Java rows belong to the root module. Existing static + # non-Java rows retain their module-local behavior. + case "$component_kind" in + java-*) workdir="."; scope="//${path}/..."; scoped="root" ;; + *) workdir="$path"; scope="//..."; scoped="" ;; + esac hit=false if [ "$run_all" = "true" ]; then hit=true @@ -170,15 +266,31 @@ jobs: else if printf '%s\n' "$changed" | grep -q "^${path}/"; then hit=true; fi fi + # A framework change schedules every registered Java service. + if [ "$hit" != "true" ] && [ "$java_framework_changed" = "true" ] && [ "$component_kind" = "java-service" ]; then + hit=true + fi + # Shared Java configuration, dependency locks, rules, and tools + # schedule every Java row, not merely the root row. + if [ "$hit" != "true" ] && [ "$java_shared_changed" = "true" ]; then + case "$component_kind" in java-*) hit=true ;; esac + fi if [ "$hit" = "true" ]; then - include=$(printf '%s' "$include" | jq -c --arg id "$id" --arg path "$path" --argjson ts "$tests_skip" '. + [{id:$id, path:$path, tests_skip:$ts}]') + entry=$(jq -cn --arg id "$id" --arg path "$path" --argjson ts "$tests_skip" --arg wd "$workdir" --arg sc "$scope" --arg sm "$scoped" --arg ck "$component_kind" --arg cl "$ci_lane" '{id:$id, path:$path, tests_skip:$ts, workdir:$wd, scope:$sc, scoped:$sm, component_kind:$ck, ci_lane:$cl}') + case "$ci_lane" in + docker-host) include_docker=$(printf '%s' "$include_docker" | jq -c --argjson e "$entry" '. + [$e]') ;; + *) include=$(printf '%s' "$include" | jq -c --argjson e "$entry" '. + [$e]') ;; + esac fi done <<< "$ROWS" count=$(printf '%s' "$include" | jq 'length') - echo "selected ${count} subtree(s): $(printf '%s' "$include" | jq -r '[.[].id] | join(", ")')" + count_docker=$(printf '%s' "$include_docker" | jq 'length') + total=$((count + count_docker)) + echo "selected ${total} subtree(s): build-container=[$(printf '%s' "$include" | jq -r '[.[].id] | join(", ")')] docker-host=[$(printf '%s' "$include_docker" | jq -r '[.[].id] | join(", ")')]" echo "matrix=${include}" >> "$GITHUB_OUTPUT" - if [ "$count" -gt 0 ]; then echo "any=true" >> "$GITHUB_OUTPUT"; else echo "any=false" >> "$GITHUB_OUTPUT"; fi + echo "matrix_docker=${include_docker}" >> "$GITHUB_OUTPUT" + if [ "$total" -gt 0 ]; then echo "any=true" >> "$GITHUB_OUTPUT"; else echo "any=false" >> "$GITHUB_OUTPUT"; fi bazel: name: bazel (${{ matrix.subtree.id }}) @@ -192,6 +304,11 @@ jobs: CACHE_TOKEN: ${{ secrets.BAZEL_REMOTE_CACHE_TOKEN }} CACHE_ENDPOINT: ${{ vars.BAZEL_REMOTE_CACHE_ENDPOINT }} container: + # This pinned image is built in the internal nvcf/bazel-ci-templates + # project and mirrored to GHCR. It supplies Temurin 25 at JAVA_HOME; + # Bazelisk then selects the root .bazelversion release. Update this tag + # only after the corresponding internal image has been published and + # mirrored. image: ghcr.io/nvidia/nvcf/bazel-ci:0.12.0 credentials: username: ${{ github.actor }} @@ -213,7 +330,7 @@ jobs: - name: Skip if subtree has no MODULE.bazel yet id: precheck run: | - if [ ! -f "${{ matrix.subtree.path }}/MODULE.bazel" ]; then + if [ "${{ matrix.subtree.scoped }}" != "root" ] && [ ! -f "${{ matrix.subtree.path }}/MODULE.bazel" ]; then echo "no MODULE.bazel at ${{ matrix.subtree.path }} -- skipping" echo "skip=true" >> "$GITHUB_OUTPUT" else @@ -223,7 +340,7 @@ jobs: # bazelisk is preinstalled in the bazel-ci image as `bazel`. - name: bazel version if: steps.precheck.outputs.skip == 'false' - working-directory: ${{ matrix.subtree.path }} + working-directory: ${{ matrix.subtree.workdir }} run: bazel version - name: Cache Bazel repository + disk caches @@ -233,7 +350,7 @@ jobs: path: | ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache - key: bazel-${{ matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.path), format('{0}/.bazelversion', matrix.subtree.path)) }} + key: bazel-${{ matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }} restore-keys: | bazel-${{ matrix.subtree.id }}- @@ -295,15 +412,19 @@ jobs: - name: Determine build targets id: targets if: steps.precheck.outputs.skip == 'false' - working-directory: ${{ matrix.subtree.path }} + working-directory: ${{ matrix.subtree.workdir }} env: EVENT: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} SUBTREE_PATH: ${{ matrix.subtree.path }} + SCOPE: ${{ matrix.subtree.scope }} run: | set -uo pipefail tfile="$RUNNER_TEMP/targets.txt" - full() { printf '//...\n' > "$tfile"; echo "targets: //... (full build)"; } + # SCOPE is //... for standalone-module rows, and ///... + # for a root-scoped lane so it builds only its own targets out of the + # shared root module. + full() { printf '%s\n' "$SCOPE" > "$tfile"; echo "targets: $SCOPE (full build)"; } # Only the root row on a pull_request is a narrowing candidate. Every # other trigger (main push warms + uploads the whole closure; dispatch @@ -365,7 +486,7 @@ jobs: - name: bazel build if: steps.precheck.outputs.skip == 'false' - working-directory: ${{ matrix.subtree.path }} + working-directory: ${{ matrix.subtree.workdir }} run: | if [ ! -s "$RUNNER_TEMP/targets.txt" ]; then echo "no targets to build; skipping"; exit 0 @@ -413,7 +534,7 @@ jobs: # but tests fail with an environment delta from GitLab CI (per the # documented build-only policy). Default is to run tests. if: steps.precheck.outputs.skip == 'false' && !matrix.subtree.tests_skip - working-directory: ${{ matrix.subtree.path }} + working-directory: ${{ matrix.subtree.workdir }} run: | if [ ! -s "$RUNNER_TEMP/targets.txt" ]; then echo "no targets to test; skipping"; exit 0 @@ -428,12 +549,24 @@ jobs: cp "$RUNNER_TEMP/targets.txt" "$ttfile" else patterns=$(tr '\n' ' ' < "$RUNNER_TEMP/targets.txt") - tests=$(bazel query --noshow_progress --output=label "tests(set($patterns))" 2>/dev/null) || tests="__QFAIL__" + # Exclude requires-docker tests from the resolved set: they run in the + # Docker-host lane, not here. Naming them explicitly and then + # running `bazel test --test_tag_filters=-requires-docker` fails with + # "No test targets were found, yet testing was requested" (exit 4) when + # a scope's only tests are requires-docker. Filtering + # them in the query lets such a scope resolve to empty and skip cleanly, + # matching the tag filter applied to the run below. + tests=$(bazel query --noshow_progress --output=label \ + "tests(set($patterns)) except attr(tags, 'requires-docker', set($patterns))" 2>/dev/null) || tests="__QFAIL__" if [ "$tests" = "__QFAIL__" ]; then echo "tests() query failed; falling back to full test set" printf '//...\n' > "$ttfile" elif [ -z "$tests" ]; then - echo "no affected test targets; skipping tests"; exit 0 + # Not a coverage gap: requires-docker tests are excluded here (the + # container has no Docker daemon) and run in the bazel-integration + # lane instead. A scope whose only tests are requires-docker + # resolves empty here and is covered there. + echo "no non-docker test targets in scope; requires-docker tests run in the Docker-host lane"; exit 0 else printf '%s\n' "$tests" > "$ttfile" echo "tests: $(grep -c . "$ttfile") affected test target(s)" @@ -451,7 +584,11 @@ jobs: grep -E '^[[:space:]]*-//' "$qfile" | sed -E 's/^[[:space:]]+//' >> "$ttfile" echo "quarantine: subtracted ${n:-0} pattern(s) from the root test set" fi - COMMON=(--flaky_test_attempts=3) + # requires-docker tests (Testcontainers/DinD) run in the dedicated + # Docker-host lane, not here; this matrix has no Docker daemon. + # They still compile in the build step; only their execution is + # deferred to that lane (see the bazel-integration job). + COMMON=(--flaky_test_attempts=3 --test_tag_filters=-requires-docker) CACHE=(--remote_cache=) if [ "${CACHE_READY:-0}" = "1" ]; then CACHE=(--remote_cache="$CACHE_ENDPOINT" @@ -482,9 +619,101 @@ jobs: bazel test "${COMMON[@]}" --remote_cache= "${TARGETS[@]}" fi + - name: Stage Java verification artifacts + if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} + working-directory: ${{ matrix.subtree.workdir }} + run: | + bash tools/ci/stage-bazel-java-artifacts \ + "${{ matrix.subtree.id }}" \ + "${{ matrix.subtree.path }}" + + - name: Upload Java verification artifacts + if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} + uses: actions/upload-artifact@v4 + with: + name: bazel-${{ matrix.subtree.id }}-verification-${{ github.run_attempt }} + path: ${{ runner.temp }}/bazel-java-verification/${{ matrix.subtree.id }} + if-no-files-found: error + retention-days: 14 + + # Docker-host lane. Each subtree that owns requires-docker + # (Testcontainers) tests runs its FULL suite (unit + Testcontainers) here, in + # its own visibly-named lane, with no tag filter -- so no test a subtree owns + # is filtered out or deferred, and a green lane means that service's tests ran. + # Runs on the bare ubuntu-latest runner (not the bazel-ci container) because + # that runner ships a running Docker daemon, so Testcontainers uses the host + # daemon directly -- no DinD, no host-override networking. These subtrees are + # excluded from the build-container matrix, so they never produce a + # zero-test lane. + # bazelisk reads .bazelversion for the pinned Bazel. + bazel-docker: + name: bazel (${{ matrix.subtree.id }}) + needs: detect + if: needs.detect.outputs.matrix_docker != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + subtree: ${{ fromJson(needs.detect.outputs.matrix_docker) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # The Java tree builds with local_jdk (root .bazelrc). Unlike the fast + # matrix, this lane runs on a bare runner rather than the bazel-ci image, + # so provide JDK 25 explicitly for the Java requires-docker tests. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + - name: Install bazelisk + run: | + sudo curl -fsSLo /usr/local/bin/bazel \ + https://github.com/bazelbuild/bazelisk/releases/download/v1.20.0/bazelisk-linux-amd64 + sudo chmod +x /usr/local/bin/bazel + bazel version + - name: bazel build (${{ matrix.subtree.id }}) + working-directory: ${{ matrix.subtree.workdir }} + run: bazel build ${{ matrix.subtree.scope }} + - name: bazel test (${{ matrix.subtree.id }}) + working-directory: ${{ matrix.subtree.workdir }} + # Full suite, NO tag filter: runs the subtree's unit AND requires-docker + # (Testcontainers) tests against the host Docker daemon. A subtree whose + # only tests are requires-docker still runs them all here. + # bazel exit 4 (no test targets in scope) is treated as success. + run: | + set +e + bazel test ${{ matrix.subtree.scope }} \ + --test_output=errors \ + --flaky_test_attempts=2 + rc=$? + set -e + if [ "$rc" -eq 4 ]; then + echo "::warning::${{ matrix.subtree.id }}: no test targets in scope" + exit 0 + fi + exit "$rc" + + - name: Stage Java verification artifacts + if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} + working-directory: ${{ matrix.subtree.workdir }} + run: | + bash tools/ci/stage-bazel-java-artifacts \ + "${{ matrix.subtree.id }}" \ + "${{ matrix.subtree.path }}" + + - name: Upload Java verification artifacts + if: ${{ always() && startsWith(matrix.subtree.component_kind, 'java-') }} + uses: actions/upload-artifact@v4 + with: + name: bazel-${{ matrix.subtree.id }}-verification-${{ github.run_attempt }} + path: ${{ runner.temp }}/bazel-java-verification/${{ matrix.subtree.id }} + if-no-files-found: error + retention-days: 14 + bazel-verification: name: bazel verification - needs: [detect, bazel] + needs: [detect, bazel, bazel-docker] if: ${{ always() }} runs-on: ubuntu-latest steps: @@ -492,6 +721,7 @@ jobs: env: DETECT_RESULT: ${{ needs.detect.result }} BAZEL_RESULT: ${{ needs.bazel.result }} + DOCKER_RESULT: ${{ needs.bazel-docker.result }} BAZEL_ANY: ${{ needs.detect.outputs.any }} run: | set -euo pipefail @@ -514,3 +744,9 @@ jobs: echo "one or more Bazel matrix rows failed, were cancelled, or were skipped" exit 1 fi + + echo "bazel-docker result: ${DOCKER_RESULT}" + if [ "${DOCKER_RESULT}" != "success" ] && [ "${DOCKER_RESULT}" != "skipped" ]; then + echo "one or more bazel docker (per-service Testcontainers) lanes failed or were cancelled" + exit 1 + fi diff --git a/BAZEL.md b/BAZEL.md index 56b564b55..b8e1cf7c0 100644 --- a/BAZEL.md +++ b/BAZEL.md @@ -11,11 +11,13 @@ Bazel currently builds, tests, and packages: - `src/clis/nvcf-cli` (Go binary + multi-platform release matrix + OCI image) - `src/libraries/go/lib` (Go library, 92 targets) +- `src/libraries/java/nv-boot-parent` (Java framework libraries and tests) +- `src/control-plane-services/cloud-tasks` (Java libraries, tests, and Spring + Boot application) -Subtrees listed in `imports.yaml` with `authoritative_source: upstream` are -intentionally excluded via -`.bazelignore` and `# gazelle:exclude` directives in the root `BUILD.bazel`. -They will be onboarded one at a time as Phase B in separate MRs. +Other upstream-owned subtrees remain excluded until they are onboarded one at +a time. nv-boot-parent and Cloud Tasks are folded into the root Bazel module +and are not nested Bazel workspaces. ## One-time setup @@ -62,10 +64,34 @@ the cross-toolchain on first use. ### Common environment -The repo expects Bazel 8.6.0 (pinned in `.bazelversion`). Bazelisk handles +The repo expects Bazel 9.1.1 (pinned in `.bazelversion`). Bazelisk handles the download automatically; do not install Bazel via apt or brew directly, as that pins a different version. +Java targets use Java 25 with the root `.bazelrc` setting +`--java_runtime_version=local_jdk`. The pinned containerized CI image supplies +Temurin 25 through `JAVA_HOME`. The bare Docker-integration lane downloads and +configures Temurin 25 through the workflow's `actions/setup-java@v4` step. + +`bazel info java-home` reports the Java runtime used to run the Bazel server. +That directory is not guaranteed to contain command-line tools such as +`javac`, so it is not the right way to inspect the Java toolchains selected for +build actions. Query those toolchains directly: + +```bash +bazel cquery @bazel_tools//tools/jdk:current_java_runtime \ + --output=starlark \ + --starlark:expr='str(providers(target)["ToolchainInfo"].java_runtime.version)' + +bazel cquery @bazel_tools//tools/jdk:current_host_java_runtime \ + --output=starlark \ + --starlark:expr='str(providers(target)["ToolchainInfo"].java_runtime.version)' +``` + +Both commands must print `25`. The normal Java build then proves that the +compiler toolchain works; a separate `javac` command under +`bazel info java-home` is neither required nor expected. + ## Day-to-day commands ### Build @@ -282,12 +308,31 @@ build). ## CI -Two Bazel-aware jobs in the root `.gitlab-ci.yml`: +The public GitHub Bazel matrix in `.github/workflows/bazel.yml` consumes +`ghcr.io/nvidia/nvcf/bazel-ci:0.12.0`. That image is built in the internal +[`nvcf/bazel-ci-templates`](https://gitlab-master.nvidia.com/nvcf/bazel-ci-templates) +project, stamped with a version, and mirrored to GHCR. The mirror is currently +manual; automation is planned. To change the image's Bazel, Java, or operating +system tooling, update the internal template first, publish and mirror a new +tag, and only then update the pinned `container.image` here. + +The root `ci/Dockerfile.bazel` and +`.github/workflows/bazel-ci-image.yml` are legacy files and are not the +authoritative producer for the image used by the matrix. Do not use them to +reason about the contents of `bazel-ci:0.12.0`. + +The detect job also enforces the single-module import boundary: + +```bash +bash tools/ci/check-java-import-boundaries +``` + +Run this after refreshing nv-boot-parent or Cloud Tasks source. It fails when +a standalone Bazel root file, lockfile, workspace file, or migration directory +is reintroduced under either subtree. + +The existing internal Bazel jobs include: -- `bazel-ci-image`: rebuilds `ci/Dockerfile.bazel` via buildah and pushes - to `${CI_REGISTRY_IMAGE}/bazel-ci:`. Triggers only when - `ci/Dockerfile.bazel` or `.bazelversion` changes (or when a web pipeline - is run with `$REBUILD_BAZEL_IMAGE` set). - `bazel-smoke`: pulls the image, runs `bazel info release`, then `bazel build --config=remote //src/libraries/go/lib/... //src/clis/nvcf-cli:image_index` and `bazel mod graph`. It does not @@ -339,9 +384,13 @@ For native subtrees outside Phase 1 scope today: 5. Run `bazel run //:gazelle` then `bazel mod tidy`. 6. `bazel build //path/to/subtree/...` to validate. -For upstream-owned subtrees (`authoritative_source: upstream` in -`imports.yaml`), Bazel files belong upstream so they survive the next commit-pin -refresh. +The public checkout does not contain the internal source-mirroring +configuration. For an upstream-owned subtree, distinguish between source files +that continue to mirror from the standalone repository and monorepo-native +Bazel overlays. The import process must exclude standalone `MODULE.bazel`, +lockfiles, `.bazelrc`, `.bazelversion`, downloader config, dependency hub, +and `bazel-enablement` content. Root-module BUILD adaptations and monorepo +agent/documentation overlays must be preserved during refreshes. ## Per-service publish cadence diff --git a/BUILD.bazel b/BUILD.bazel index ce3c98a73..1cd9bf97b 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -21,25 +21,28 @@ exports_files( ) # Root module identity + third-party lock + NOTICE index, consumed by the -# nv-boot-parent NOTICE generation/verification tooling now that its Maven -# closure is owned by the root module. +# Java subtrees' NOTICE generation/verification tooling now that their Maven +# closure is owned by the single root module. exports_files( [ "MODULE.bazel", "NOTICE", "maven_install.json", ], - visibility = ["//src/libraries/java/nv-boot-parent:__subpackages__"], + visibility = [ + "//src/control-plane-services/cloud-tasks:__subpackages__", + "//src/libraries/java/nv-boot-parent:__subpackages__", + ], ) -# Phase 1 scope: only the native Go subtrees listed in go.work.bazel are -# managed by Bazel. -# Everything else is excluded so Gazelle does not descend into vendored -# directories of upstream-owned subtrees. Their source is owned upstream, and -# local-only changes would be clobbered by the next commit-pin refresh. +# Gazelle manages only the native Go subtrees listed in go.work.bazel. +# nv-boot-parent and Cloud Tasks are also owned by the root Bazel module, but +# their checked-in Java BUILD files are maintained manually and remain outside +# Gazelle's scope. Other upstream-owned subtrees stay excluded so Gazelle does +# not descend into vendored directories. # -# When promoting an upstream to native (Phase B), remove the corresponding -# exclude line below and add a per-subtree BUILD.bazel with the right prefix. +# When onboarding another subtree, remove only the exclusions needed by its +# language-specific build integration. # gazelle:exclude .bazel-cache # gazelle:exclude .worktrees # gazelle:exclude .cursor diff --git a/MODULE.bazel b/MODULE.bazel index 31322b8bc..61deaccb4 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -148,21 +148,97 @@ use_repo( # Java / JVM # # Foundation for Spring Boot (and plain Java) services: Maven dependency -# resolution via rules_jvm_external, JUnit 5 test support via contrib_rules_jvm, -# and the nvcf_spring_boot_image macro in //rules/java. A new Java service wires -# in by adding its Maven coordinates to maven.install below (re-pin with -# `bazel run @maven//:pin`) and calling the macro from its BUILD.bazel. +# resolution via rules_jvm_external and JUnit 5 test support via +# contrib_rules_jvm. A new root-workspace Java target wires in by adding its +# Maven coordinates to maven.install below and re-pinning with +# `bazel run @nv_third_party_deps//:pin`. # # The pinned lock file (//:maven_install.json) makes resolution reproducible and # lets `bazel mod` run offline. Repositories intentionally lists only Maven # Central so the foundation builds anywhere, including the public GitHub mirror. -# Internal services that depend on nv-boot add the internal Artifactory virtual -# repo here; see rules/java/README.md. +# Standalone Java modules (src/libraries/java/nv-boot-parent, +# src/control-plane-services/cloud-tasks) instead own their own MODULE.bazel and +# nv_third_party_deps hub and are ignored by the root build (see .bazelignore). # ============================================================================ bazel_dep(name = "rules_java", version = "9.3.0") bazel_dep(name = "rules_jvm_external", version = "7.0") bazel_dep(name = "contrib_rules_jvm", version = "0.33.0") +# Spring Boot executable-jar packaging for the Java control-plane services +# (consumed by //rules/java:spring.bzl). Folded in from the cloud-tasks import. +bazel_dep(name = "rules_spring", version = "2.6.3") + +# gRPC-Java codegen. rules_proto_grpc_java drives the protoc-gen-grpc-java +# plugin (fetched as native binaries below) to generate Java gRPC stubs for +# Java services that expose or consume gRPC. protobuf/com_google_protobuf and +# rules_java are already declared above. +bazel_dep(name = "rules_proto_grpc", version = "5.8.0") +bazel_dep(name = "rules_proto_grpc_java", version = "5.8.0") + +# The gRPC generator is a native executable, not a Java dependency. Keep it +# outside the shared Maven hub so fetch_sources applies only to Java artifacts. +GRPC_VERSION = "1.63.0" + +http_file = use_repo_rule( + "@bazel_tools//tools/build_defs/repo:http.bzl", + "http_file", +) + +http_file( + name = "grpc_java_plugin_linux_aarch_64", + downloaded_file_path = "protoc-gen-grpc-java.exe", + executable = True, + sha256 = "471427565ad82b3caac5e19dba2d15fb75b81042503ea32357630312d1f074b4", + urls = [ + "https://maven-central.storage-download.googleapis.com/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-linux-aarch_64.exe" % (GRPC_VERSION, GRPC_VERSION), + "https://repo.maven.apache.org/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-linux-aarch_64.exe" % (GRPC_VERSION, GRPC_VERSION), + ], +) + +http_file( + name = "grpc_java_plugin_linux_x86_64", + downloaded_file_path = "protoc-gen-grpc-java.exe", + executable = True, + sha256 = "0e3e8db80ba1fbddeed97ea3220b52cfaa95764ff8bf00716df7322883ce47e8", + urls = [ + "https://maven-central.storage-download.googleapis.com/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-linux-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + "https://repo.maven.apache.org/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-linux-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + ], +) + +http_file( + name = "grpc_java_plugin_osx_aarch_64", + downloaded_file_path = "protoc-gen-grpc-java.exe", + executable = True, + sha256 = "28290117a2ee9ea60f50f94273ab139dc2b3be4b8f2a557bef7e6efefee5b363", + urls = [ + "https://maven-central.storage-download.googleapis.com/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-osx-aarch_64.exe" % (GRPC_VERSION, GRPC_VERSION), + "https://repo.maven.apache.org/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-osx-aarch_64.exe" % (GRPC_VERSION, GRPC_VERSION), + ], +) + +http_file( + name = "grpc_java_plugin_osx_x86_64", + downloaded_file_path = "protoc-gen-grpc-java.exe", + executable = True, + sha256 = "28290117a2ee9ea60f50f94273ab139dc2b3be4b8f2a557bef7e6efefee5b363", + urls = [ + "https://maven-central.storage-download.googleapis.com/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-osx-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + "https://repo.maven.apache.org/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-osx-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + ], +) + +http_file( + name = "grpc_java_plugin_windows_x86_64", + downloaded_file_path = "protoc-gen-grpc-java.exe", + executable = True, + sha256 = "c3e9aaefd825a6ea9a252e153e0998d7ef36a7b27c2156867a98c71edf9c18a1", + urls = [ + "https://maven-central.storage-download.googleapis.com/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-windows-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + "https://repo.maven.apache.org/maven2/io/grpc/protoc-gen-grpc-java/%s/protoc-gen-grpc-java-%s-windows-x86_64.exe" % (GRPC_VERSION, GRPC_VERSION), + ], +) + maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "nv_third_party_deps", @@ -218,12 +294,52 @@ maven.install( "org.wiremock:wiremock-standalone:3.13.2", "software.amazon.awssdk:regions:2.40.1", "tools.jackson.module:jackson-module-blackbird", - # examples/java-spring-boot-service (versions from the Spring Boot BOM). + # General Java test/web roots (versions from the Spring Boot BOM). "org.springframework.boot:spring-boot-starter-web", "org.junit.jupiter:junit-jupiter-api", "org.junit.jupiter:junit-jupiter-engine", "org.junit.platform:junit-platform-launcher", "org.junit.platform:junit-platform-reporting", + # Cloud Tasks service closure. Version-less coordinates are managed by + # the BOMs above (spring-boot 4.0.7 / spring-cloud 2025.1.2 / + # testcontainers 2.0.5 / shedlock 7.7.0). Explicit versions match the + # validated Cloud Tasks baseline; io.grpc pins track GRPC_VERSION. + "com.bucket4j:bucket4j_jdk17-core:8.19.0", + "com.github.ben-manes.caffeine:caffeine", + "com.google.protobuf:protobuf-java:4.33.4", + "com.google.protobuf:protobuf-java-util:4.33.4", + "io.grpc:grpc-api:%s" % GRPC_VERSION, + "io.grpc:grpc-protobuf:%s" % GRPC_VERSION, + "io.grpc:grpc-stub:%s" % GRPC_VERSION, + "io.micrometer:micrometer-registry-prometheus", + "io.projectreactor.netty:reactor-netty-core", + "io.projectreactor.netty:reactor-netty-http", + "javax.annotation:javax.annotation-api:1.3.2", + "net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE", + "net.javacrumbs.shedlock:shedlock-provider-cassandra", + "net.javacrumbs.shedlock:shedlock-spring", + "org.assertj:assertj-core", + "org.awaitility:awaitility", + "org.junit.jupiter:junit-jupiter-params", + "org.mockito:mockito-core", + "org.mockito:mockito-junit-jupiter", + "org.ow2.asm:asm:9.9", + "org.ow2.asm:asm-commons:9.9", + "org.ow2.asm:asm-tree:9.9", + "org.springframework:spring-context-support", + "org.springframework:spring-webflux", + "org.springframework.boot:spring-boot-actuator", + "org.springframework.boot:spring-boot-loader", + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-micrometer-tracing", + "org.springframework.boot:spring-boot-opentelemetry", + "org.springframework.boot:spring-boot-starter-aspectj", + "org.springframework.boot:spring-boot-starter-security", + "org.springframework.boot:spring-boot-starter-webmvc", + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config", + "org.springframework.retry:spring-retry", + "org.testcontainers:testcontainers", + "org.testcontainers:testcontainers-localstack", ], boms = [ "net.javacrumbs.shedlock:shedlock-bom:7.7.0", @@ -233,7 +349,10 @@ maven.install( ], fail_on_missing_checksum = True, fetch_sources = True, - known_contributing_modules = ["protobuf"], + known_contributing_modules = [ + "protobuf", + "rules_proto_grpc_java", + ], lock_file = "//:maven_install.json", repositories = [ # Public Central mirrors only. This is the public GitHub mirror; its diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d2ebf15e3..dc12c256c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -23,11 +23,13 @@ "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", + "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", - "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.21.2/MODULE.bazel": "276347663a25b0d5bd6cad869252bea3e160c4d980e764b15f3bae7f80b30624", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.21.2/source.json": "f42051fa42629f0e59b7ac2adf0a55749144b11f1efcd8c697f0ee247181e526", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838", "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", @@ -46,6 +48,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", @@ -80,6 +83,8 @@ "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", "https://bcr.bazel.build/modules/contrib_rules_jvm/0.33.0/MODULE.bazel": "1aba514585748372ee392484b2645f0bbcb13ad4271d5b7cace018280f15bae1", "https://bcr.bazel.build/modules/contrib_rules_jvm/0.33.0/source.json": "e52d62ce2a2b8a513d6afe9178acc06a4349c9b0b4c0934d59085ed9df4b4de0", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2", "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", @@ -158,6 +163,7 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -173,6 +179,7 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", + "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", @@ -188,12 +195,15 @@ "https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", + "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.4.0/MODULE.bazel": "a592852f8a3dd539e82ee6542013bf2cadfc4c6946be8941e189d224500a8934", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", "https://bcr.bazel.build/modules/rules_java/8.13.0/MODULE.bazel": "0444ebf737d144cf2bb2ccb368e7f1cce735264285f2a3711785827c1686625e", @@ -210,14 +220,18 @@ "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", + "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.10/MODULE.bazel": "33e636ca6bc9ee0fa090a38aa33c631ded2d8cf6fead4124181d1b35dc474f7c", "https://bcr.bazel.build/modules/rules_jvm_external/6.2/MODULE.bazel": "36a6e52487a855f33cb960724eb56547fa87e2c98a0474c3acad94339d7f8e99", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", "https://bcr.bazel.build/modules/rules_jvm_external/6.6/MODULE.bazel": "153042249c7060536dc95b6bb9f9bb8063b8a0b0cb7acdb381bddbc2374aed55", "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.8/MODULE.bazel": "b5afe861e867e4c8e5b88e401cb7955bd35924258f97b1862cc966cbcf4f1a62", "https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274", "https://bcr.bazel.build/modules/rules_jvm_external/7.0/MODULE.bazel": "421482bdbcf05709f933c96b867a599deb517f2804ceb3e74511880610cfbf71", "https://bcr.bazel.build/modules/rules_jvm_external/7.0/source.json": "714cd003eadf5be5c83268311fe8e951db39f802babeaddc536b3560dc8f6faf", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/2.1.3/MODULE.bazel": "ce7def6d576aa8d3a9c6d10e13b4d157296229674371f67dbf788dae0afae3d5", @@ -241,6 +255,10 @@ "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", + "https://bcr.bazel.build/modules/rules_proto_grpc/5.8.0/MODULE.bazel": "38e2acb9aa480a04c780fa4b11bfaae0fa16e05f85f6d8fc32e044bad683ed86", + "https://bcr.bazel.build/modules/rules_proto_grpc/5.8.0/source.json": "142d5c5dd650d0f817936835738daa7df2256dfb33c247163b600ab28cba31d1", + "https://bcr.bazel.build/modules/rules_proto_grpc_java/5.8.0/MODULE.bazel": "0e24d1d4716b017afa6c85649bee47144066310057fbce7bdb5661aecd525571", + "https://bcr.bazel.build/modules/rules_proto_grpc_java/5.8.0/source.json": "965a546920ecf67e8cbb9e060f35d7f87a6fcd4319db0cf47cbc66c30327b1ee", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", @@ -250,6 +268,7 @@ "https://bcr.bazel.build/modules/rules_python/0.37.1/MODULE.bazel": "3faeb2d9fa0a81f8980643ee33f212308f4d93eea4b9ce6f36d0b742e71e9500", "https://bcr.bazel.build/modules/rules_python/0.37.2/MODULE.bazel": "b5ffde91410745750b6c13be1c5dc4555ef5bc50562af4a89fd77807fdde626a", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", "https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43", "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", @@ -258,13 +277,18 @@ "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/MODULE.bazel": "d44fec647d0aeb67b9f3b980cf68ba634976f3ae7ccd6c07d790b59b87a4f251", "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/source.json": "37c10335f2361c337c5c1f34ed36d2da70534c23088062b33a8bdaab68aa9dea", + "https://bcr.bazel.build/modules/rules_rust/0.66.0/MODULE.bazel": "86ef763a582f4739a27029bdcc6c562258ed0ea6f8d58294b049e215ceb251b3", + "https://bcr.bazel.build/modules/rules_rust/0.66.0/source.json": "5c2252a61ccc19b4e420c7c06429c8f51d8edd7b743dcb4b60571e7d40b5aa57", "https://bcr.bazel.build/modules/rules_shell/0.1.2/MODULE.bazel": "66e4ca3ce084b04af0b9ff05ff14cab4e5df7503973818bb91cbc6cda08d32fc", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.4.0/MODULE.bazel": "0f8f11bb3cd11755f0b48c1de0bbcf62b4b34421023aa41a2fc74ef68d9584f0", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.8.0/MODULE.bazel": "f6a89f1d6a669a26f28fe814503857055d76306b79cfc11d12399af08d0b80ae", "https://bcr.bazel.build/modules/rules_shell/0.8.0/source.json": "eb53cc815bc503c6683c5fe12d943f98883f81fc22f51403ec8a95610cba4195", + "https://bcr.bazel.build/modules/rules_spring/2.6.3/MODULE.bazel": "c2f719ea89af7bd3957a322c32430ee59f03fa467a4b1e208eba95a61ddcaa40", + "https://bcr.bazel.build/modules/rules_spring/2.6.3/source.json": "ac68bba4571d93871193ac0e85e3a20e6149221f12dc7f04fa8b042485cd2042", "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", @@ -273,17 +297,21 @@ "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", "https://bcr.bazel.build/modules/stardoc/0.5.4/MODULE.bazel": "6569966df04610b8520957cb8e97cf2e9faac2c0309657c537ab51c16c18a2a4", + "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", - "https://bcr.bazel.build/modules/tar.bzl/0.2.1/source.json": "600ac6ff61744667a439e7b814ae59c1f29632c3984fccf8000c64c9db8d7bb6", + "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351", + "https://bcr.bazel.build/modules/tar.bzl/0.5.1/source.json": "deed3094f7cc779ed1d37a68403847b0e38d9dd9d931e03cb90825f3368b515f", "https://bcr.bazel.build/modules/toml.bzl/0.3.0/MODULE.bazel": "5016e5dd1ad2200e119a4b28b2b3935e276c4b480f2fe3e952bea7eeba88f578", "https://bcr.bazel.build/modules/toml.bzl/0.3.0/source.json": "0cf7c878c419b37ddb55f3dd93dd7c0c409bd7c4efacb3da504e0748780b2fa9", + "https://bcr.bazel.build/modules/toolchains_protoc/0.5.0/MODULE.bazel": "e649dcd74790d8b186517588c827a777dfa67acfc4cbd733721c4be143ea107f", "https://bcr.bazel.build/modules/toolchains_protoc/0.6.1/MODULE.bazel": "377cbb438118f413c3361a1dd363da8a42077018473fcdc71a19c203aaf94b17", "https://bcr.bazel.build/modules/toolchains_protoc/0.6.1/source.json": "b14b0b38c8309691bee7a0ab46113678b8675e04e8999294c58e68b036b8dbff", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", @@ -350,7 +378,7 @@ }, "@@rules_oci+//oci:extensions.bzl%oci": { "general": { - "bzlTransitiveDigest": "qGzrjxshR+J4OqW/IYdHPjWbclbeUFswL6msJ9ALGOg=", + "bzlTransitiveDigest": "IPpbSHX7+8yLBfvZyia9qXU/WRxkP+dWAO8m5+BsBY8=", "usagesDigest": "OlwIdJhTnYJJAA+ezJIG6rT/iRycvHf0Yuf0EPuhO7c=", "recordedInputs": [ "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", @@ -772,6 +800,57 @@ } } } + }, + "@@tar.bzl+//tar:extensions.bzl%toolchains": { + "general": { + "bzlTransitiveDigest": "/2afh6fPjq/rcyE/jztQDK3ierehmFFngfvmqyRv72M=", + "usagesDigest": "maF8qsAIqeH1ey8pxP0gNZbvJt34kLZvTFeQ0ntrJVA=", + "recordedInputs": [], + "generatedRepoSpecs": { + "bsd_tar_toolchains": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:toolchain.bzl%tar_toolchains_repo", + "attributes": { + "user_repository_name": "bsd_tar_toolchains" + } + }, + "bsd_tar_toolchains_darwin_amd64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "darwin_amd64" + } + }, + "bsd_tar_toolchains_darwin_arm64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "darwin_arm64" + } + }, + "bsd_tar_toolchains_linux_amd64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "linux_amd64" + } + }, + "bsd_tar_toolchains_linux_arm64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "linux_arm64" + } + }, + "bsd_tar_toolchains_windows_amd64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "windows_amd64" + } + }, + "bsd_tar_toolchains_windows_arm64": { + "repoRuleId": "@@tar.bzl+//tar/toolchain:platforms.bzl%bsdtar_binary_repo", + "attributes": { + "platform": "windows_arm64" + } + } + } + } } }, "facts": { diff --git a/NOTICE b/NOTICE index d332b19a7..d48fa2ce4 100644 --- a/NOTICE +++ b/NOTICE @@ -306,6 +306,7 @@ The following third-party licenses are included in this repository: src/compute-plane-services/nvca/vendor/sigs.k8s.io/structured-merge-diff/v6/LICENSE src/compute-plane-services/nvca/vendor/sigs.k8s.io/yaml/LICENSE src/compute-plane-services/nvsnap/NOTICE + src/control-plane-services/cloud-tasks/NOTICE src/invocation-plane-services/llm-api-gateway/NOTICE src/invocation-plane-services/ratelimiter/NOTICE src/libraries/go/lib/vendor/cloud.google.com/go/compute/metadata/LICENSE diff --git a/examples/java-spring-boot-service/BUILD.bazel b/examples/java-spring-boot-service/BUILD.bazel deleted file mode 100644 index e82da8bac..000000000 --- a/examples/java-spring-boot-service/BUILD.bazel +++ /dev/null @@ -1,89 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Reference Spring Boot service. Doubles as the template a new Java service -# copies: a java_library for the app, nvcf_spring_boot_image for the multi-arch -# container, and a JUnit 5 test. To publish images, add a registry = ... to the -# image target and a release: block in tools/ci/subproject-validations.yaml -# (see the grpc-proxy entry). See README.md for the nv-boot / internal Maven -# wiring a real service adds. - -load("@contrib_rules_jvm//java:defs.bzl", "java_junit5_test") -load("//rules/java:defs.bzl", "nvcf_java_library", "nvcf_spring_boot_image") - -nvcf_java_library( - name = "example_lib", - srcs = glob(["src/main/java/**/*.java"]), - visibility = ["//visibility:public"], - # The starter carries the full runtime (embedded Tomcat, Jackson, ...); - # the concrete artifacts below are the ones the code imports directly, so - # the strict-deps header compiler sees them on the direct classpath. - deps = [ - "@nv_third_party_deps//:org_springframework_boot_spring_boot", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_web", - "@nv_third_party_deps//:org_springframework_spring_web", - ], -) - -# Multi-arch OCI image: :image (host arch), :image_index (amd64 + arm64), -# :image_load (docker load), :image.tar. A registry = ... would also emit -# :image_push. Tagged "manual", so bazel build //... skips it; the CI entry -# builds :image_index explicitly. -nvcf_spring_boot_image( - name = "image", - main_class = "com.nvidia.nvcf.example.ExampleApplication", - visibility = ["//visibility:public"], - deps = [":example_lib"], -) - -java_junit5_test( - name = "hello_controller_test", - srcs = ["src/test/java/com/nvidia/nvcf/example/HelloControllerTest.java"], - test_class = "com.nvidia.nvcf.example.HelloControllerTest", - runtime_deps = [ - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_engine", - "@nv_third_party_deps//:org_junit_platform_junit_platform_launcher", - "@nv_third_party_deps//:org_junit_platform_junit_platform_reporting", - ], - deps = [ - ":example_lib", - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", - ], -) - -# Boots the full Spring context and hits /hello through MockMvc. This is the -# test that actually exercises the exploded-classpath design: starting the -# context reads each dependency jar's auto-configuration metadata, so a broken -# classpath assembly fails here where the plain unit test above cannot. -java_junit5_test( - name = "hello_controller_web_test", - srcs = ["src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java"], - test_class = "com.nvidia.nvcf.example.HelloControllerWebTest", - runtime_deps = [ - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_engine", - "@nv_third_party_deps//:org_junit_platform_junit_platform_launcher", - "@nv_third_party_deps//:org_junit_platform_junit_platform_reporting", - ], - deps = [ - ":example_lib", - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_webmvc_test", - "@nv_third_party_deps//:org_springframework_spring_beans", - "@nv_third_party_deps//:org_springframework_spring_test", - ], -) diff --git a/examples/java-spring-boot-service/README.md b/examples/java-spring-boot-service/README.md deleted file mode 100644 index ad16be1eb..000000000 --- a/examples/java-spring-boot-service/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# java-spring-boot-service - -Reference Spring Boot service built with Bazel. It exists to exercise and -demonstrate the Java-on-Bazel foundation in `//rules/java` and the Maven wiring -in the root `MODULE.bazel`. Copy it as the starting point for a new Java service. - -## Layout - -``` -BUILD.bazel nvcf_java_library + nvcf_spring_boot_image + JUnit 5 test -src/main/java/com/nvidia/nvcf/example/ - ExampleApplication.java @SpringBootApplication entry point - HelloController.java trivial @RestController -src/test/java/.../HelloControllerTest.java -``` - -## Build and test - -``` -bazel test //examples/java-spring-boot-service:hello_controller_test -bazel build //examples/java-spring-boot-service:image_index -``` - -`nvcf_spring_boot_image` generates the same target set as the Go image macro: - -- `:image` host-arch image -- `:image_index` multi-arch index (amd64 + arm64) -- `:image_load` load into a local Docker daemon -- `:image.tar` tarball -- `:image_push` when the macro is given `registry = ...` - -Run it locally: - -``` -bazel run //examples/java-spring-boot-service:image_load -docker run --rm -p 8080:8080 examples/java-spring-boot-service:latest -curl localhost:8080/hello -``` - -## Creating a new Java service from this template - -1. Copy this directory to `src//` (or another loadable - path; anything under a directory listed in `.bazelignore` is skipped). -2. Rename the package and `main_class`. -3. Add the Maven coordinates your code imports to `maven.install` in the root - `MODULE.bazel`, then re-pin: `bazel run @maven//:pin`. List the artifacts you - import directly in `deps` so the strict-deps header compiler resolves them. -4. Add a subproject entry in `tools/ci/subproject-validations.yaml` (model it on - `java-example-service`) so the build and test run in CI. -5. To publish images, pass `registry = ...` to `nvcf_spring_boot_image` and add a - `release:` block to the subproject entry (model it on `grpc-proxy`). The - release machinery is image-format agnostic: it stamps and pushes the - `:image_index` the same way it does for Go and Rust services. - -## Using nv-boot or other internal dependencies - -This example depends only on public Maven Central artifacts so it builds -anywhere, including the public GitHub mirror. A real internal service that -depends on nv-boot adds the internal Artifactory Maven virtual repository to the -`repositories` list in `maven.install` and lists the nv-boot coordinates in -`artifacts`. See `//rules/java/README.md` for that wiring. diff --git a/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/ExampleApplication.java b/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/ExampleApplication.java deleted file mode 100644 index 2baf0ec7f..000000000 --- a/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/ExampleApplication.java +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.nvidia.nvcf.example; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** Minimal Spring Boot service used to exercise the Java-on-Bazel foundation. */ -@SpringBootApplication -public class ExampleApplication { - public static void main(String[] args) { - SpringApplication.run(ExampleApplication.class, args); - } -} diff --git a/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/HelloController.java b/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/HelloController.java deleted file mode 100644 index cca333ac5..000000000 --- a/examples/java-spring-boot-service/src/main/java/com/nvidia/nvcf/example/HelloController.java +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.nvidia.nvcf.example; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -/** Trivial REST endpoint so the service exercises Spring Boot auto-configuration. */ -@RestController -public class HelloController { - - @GetMapping("/hello") - public String hello() { - return greeting("world"); - } - - static String greeting(String name) { - return "Hello, " + name + "!"; - } -} diff --git a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerTest.java b/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerTest.java deleted file mode 100644 index ad3aad325..000000000 --- a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.nvidia.nvcf.example; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Test; - -class HelloControllerTest { - - @Test - void greetingFormatsName() { - assertEquals("Hello, world!", HelloController.greeting("world")); - } -} diff --git a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java b/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java deleted file mode 100644 index fbad34578..000000000 --- a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.nvidia.nvcf.example; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; - -/** - * Boots the full Spring application context and exercises the /hello endpoint through MockMvc. - * - *

This is the test that validates the exploded-classpath packaging design: starting the context - * forces Spring to read each dependency jar's META-INF/spring.factories and - * META-INF/spring/*.AutoConfiguration.imports. If the classpath were assembled so that those - * same-path resources collided (as a fat/singlejar would), auto-configuration would be dropped and - * this test would fail. The plain JUnit unit test in HelloControllerTest cannot catch that. - */ -@SpringBootTest -@AutoConfigureMockMvc -class HelloControllerWebTest { - - @Autowired private MockMvc mockMvc; - - @Test - void helloEndpointServesGreeting() throws Exception { - mockMvc - .perform(get("/hello")) - .andExpect(status().isOk()) - .andExpect(content().string("Hello, world!")); - } -} diff --git a/maven_install.json b/maven_install.json index 01d08969d..ebfae1f7c 100755 --- a/maven_install.json +++ b/maven_install.json @@ -2,41 +2,70 @@ "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", "__INPUT_ARTIFACTS_HASH": { "at.yawk.lz4:lz4-java": 1725015399, + "com.bucket4j:bucket4j_jdk17-core": -1409600715, + "com.github.ben-manes.caffeine:caffeine": 1100418982, "com.github.ben-manes.caffeine:guava": 1054685015, "com.github.java-json-tools:json-patch": -1031005245, + "com.google.protobuf:protobuf-java": 1906581597, + "com.google.protobuf:protobuf-java-util": -1626074784, "commons-codec:commons-codec": -1269462382, "io.cloudevents:cloudevents-core": -2103567774, "io.cloudevents:cloudevents-json-jackson": -1197487309, + "io.grpc:grpc-api": 811923177, + "io.grpc:grpc-protobuf": 1083049616, + "io.grpc:grpc-stub": 1416868173, + "io.micrometer:micrometer-registry-prometheus": 1865600799, "io.micrometer:micrometer-tracing-bridge-otel": -754588172, "io.nats:jnats": 572014237, "io.opentelemetry:opentelemetry-exporter-otlp": -12806658, "io.opentelemetry:opentelemetry-sdk-testing": -32635354, + "io.projectreactor.netty:reactor-netty-core": -2096527644, + "io.projectreactor.netty:reactor-netty-http": 131695323, "jakarta.servlet:jakarta.servlet-api": 2120044853, + "javax.annotation:javax.annotation-api": 1286517389, + "net.devh:grpc-server-spring-boot-starter": -1304967214, "net.javacrumbs.shedlock:shedlock-bom": -1406345450, + "net.javacrumbs.shedlock:shedlock-provider-cassandra": -1200872, + "net.javacrumbs.shedlock:shedlock-spring": -326290089, "org.apache.cassandra:java-driver-metrics-micrometer": -465267397, "org.apache.commons:commons-lang3": -278168457, + "org.assertj:assertj-core": 1863574844, + "org.awaitility:awaitility": -1630939750, "org.bouncycastle:bcprov-jdk18on": -1405390253, "org.jacoco:org.jacoco.agent": -2069397525, "org.jacoco:org.jacoco.cli": -1856875155, "org.junit.jupiter:junit-jupiter-api": 194920406, "org.junit.jupiter:junit-jupiter-engine": -1886863302, + "org.junit.jupiter:junit-jupiter-params": 1082310006, "org.junit.platform:junit-platform-console-standalone": -1481831078, "org.junit.platform:junit-platform-launcher": -354104948, "org.junit.platform:junit-platform-reporting": 1896713402, + "org.mockito:mockito-core": -554630660, + "org.mockito:mockito-junit-jupiter": -1104541639, + "org.ow2.asm:asm": -221905796, "org.ow2.asm:asm-analysis": -1027574299, + "org.ow2.asm:asm-commons": 1525995351, + "org.ow2.asm:asm-tree": -242459737, "org.ow2.asm:asm-util": -307204853, "org.projectlombok:lombok": -2073039513, "org.springdoc:springdoc-openapi-starter-webflux-api": 1102747448, "org.springdoc:springdoc-openapi-starter-webmvc-api": 655186513, + "org.springframework.boot:spring-boot-actuator": 763411999, "org.springframework.boot:spring-boot-dependencies": 70164638, + "org.springframework.boot:spring-boot-loader": 1745496505, + "org.springframework.boot:spring-boot-micrometer-metrics": -782059807, + "org.springframework.boot:spring-boot-micrometer-tracing": -2099172128, "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": 238152322, + "org.springframework.boot:spring-boot-opentelemetry": -396622647, "org.springframework.boot:spring-boot-restclient": 525086533, "org.springframework.boot:spring-boot-starter": 1358725161, "org.springframework.boot:spring-boot-starter-actuator": -1082017891, "org.springframework.boot:spring-boot-starter-actuator-test": 536870722, + "org.springframework.boot:spring-boot-starter-aspectj": -1960483986, "org.springframework.boot:spring-boot-starter-data-cassandra": 699136521, "org.springframework.boot:spring-boot-starter-data-cassandra-test": -667262890, "org.springframework.boot:spring-boot-starter-jackson": -1899095121, + "org.springframework.boot:spring-boot-starter-security": 1168390884, "org.springframework.boot:spring-boot-starter-security-oauth2-client": -1248590284, "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server": 185843737, "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test": -1173625786, @@ -45,16 +74,23 @@ "org.springframework.boot:spring-boot-starter-web": -1905796688, "org.springframework.boot:spring-boot-starter-webflux": 1788530073, "org.springframework.boot:spring-boot-starter-webflux-test": 5326534, + "org.springframework.boot:spring-boot-starter-webmvc": 338400426, "org.springframework.boot:spring-boot-starter-webmvc-test": -2111356331, "org.springframework.boot:spring-boot-webclient": -1835278023, "org.springframework.cloud:spring-cloud-commons": -673836000, "org.springframework.cloud:spring-cloud-context": -1724959431, "org.springframework.cloud:spring-cloud-dependencies": 46341733, "org.springframework.cloud:spring-cloud-starter-bootstrap": -213001704, + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config": -2108580045, + "org.springframework.retry:spring-retry": -815156811, "org.springframework.security:spring-security-test": 1317137564, + "org.springframework:spring-context-support": 43813702, + "org.springframework:spring-webflux": 11421498, + "org.testcontainers:testcontainers": -258185670, "org.testcontainers:testcontainers-bom": 483223872, "org.testcontainers:testcontainers-cassandra": -1027104267, "org.testcontainers:testcontainers-junit-jupiter": -918230293, + "org.testcontainers:testcontainers-localstack": 1146768038, "org.wiremock:wiremock-standalone": -1242936487, "repositories": -1624298853, "software.amazon.awssdk:regions": -1274787064, @@ -71,6 +107,10 @@ "ch.qos.logback:logback-classic:jar:sources": -390128445, "ch.qos.logback:logback-core": -1554021729, "ch.qos.logback:logback-core:jar:sources": 77538609, + "com.bucket4j:bucket4j-core": -332288989, + "com.bucket4j:bucket4j-core:jar:sources": -559201191, + "com.bucket4j:bucket4j_jdk17-core": -1244833112, + "com.bucket4j:bucket4j_jdk17-core:jar:sources": -2075602285, "com.datastax.cassandra:cassandra-driver-core": -681774303, "com.datastax.cassandra:cassandra-driver-core:jar:sources": -2045236197, "com.datastax.oss:native-protocol": 447263174, @@ -97,13 +137,13 @@ "com.github.docker-java:docker-java-transport-zerodep": -1848983763, "com.github.docker-java:docker-java-transport-zerodep:jar:sources": 1882881505, "com.github.docker-java:docker-java-transport:jar:sources": 864522470, - "com.github.java-json-tools:btf": -1747700664, + "com.github.java-json-tools:btf": 204024567, "com.github.java-json-tools:btf:jar:sources": 1539011915, - "com.github.java-json-tools:jackson-coreutils": 2047023685, + "com.github.java-json-tools:jackson-coreutils": 1585779168, "com.github.java-json-tools:jackson-coreutils:jar:sources": 323093438, - "com.github.java-json-tools:json-patch": -399792119, + "com.github.java-json-tools:json-patch": 388171991, "com.github.java-json-tools:json-patch:jar:sources": -1320375757, - "com.github.java-json-tools:msg-simple": 986302814, + "com.github.java-json-tools:msg-simple": -285204120, "com.github.java-json-tools:msg-simple:jar:sources": 1013495835, "com.github.jnr:jffi": 1952487985, "com.github.jnr:jffi:jar:native": 1426144838, @@ -118,7 +158,14 @@ "com.github.jnr:jnr-x86asm:jar:sources": -1784079536, "com.github.stephenc.jcip:jcip-annotations": -121928928, "com.github.stephenc.jcip:jcip-annotations:jar:sources": -2051941468, - "com.google.code.findbugs:jsr305": 1838181273, + "com.google.android:annotations": 769933135, + "com.google.android:annotations:jar:sources": 215169100, + "com.google.api.grpc:proto-google-common-protos": -1208329413, + "com.google.api.grpc:proto-google-common-protos:jar:sources": 944916609, + "com.google.code.findbugs:jsr305": -1038659426, + "com.google.code.findbugs:jsr305:jar:sources": -1300148931, + "com.google.code.gson:gson": 328405842, + "com.google.code.gson:gson:jar:sources": -329485452, "com.google.errorprone:error_prone_annotations": -1266099896, "com.google.errorprone:error_prone_annotations:jar:sources": 936701586, "com.google.guava:failureaccess": -56291903, @@ -128,6 +175,10 @@ "com.google.guava:listenablefuture": 1588902908, "com.google.j2objc:j2objc-annotations": -2074422376, "com.google.j2objc:j2objc-annotations:jar:sources": -597974451, + "com.google.protobuf:protobuf-java": 1139989641, + "com.google.protobuf:protobuf-java-util": 404866093, + "com.google.protobuf:protobuf-java-util:jar:sources": 1216176261, + "com.google.protobuf:protobuf-java:jar:sources": -718827633, "com.jayway.jsonpath:json-path": 626712679, "com.jayway.jsonpath:json-path:jar:sources": -343427302, "com.nimbusds:content-type": -444977683, @@ -138,10 +189,16 @@ "com.nimbusds:nimbus-jose-jwt:jar:sources": 24935039, "com.nimbusds:oauth2-oidc-sdk": -498816278, "com.nimbusds:oauth2-oidc-sdk:jar:sources": 1276926642, + "com.squareup.okhttp3:logging-interceptor": -2002563184, + "com.squareup.okhttp3:logging-interceptor:jar:sources": 524942179, + "com.squareup.okhttp3:okhttp": -2047906849, "com.squareup.okhttp3:okhttp-jvm": -314031254, "com.squareup.okhttp3:okhttp-jvm:jar:sources": 447067750, + "com.squareup.okhttp3:okhttp:jar:sources": -967779406, + "com.squareup.okio:okio": -11775483, "com.squareup.okio:okio-jvm": -391120506, "com.squareup.okio:okio-jvm:jar:sources": 1375453359, + "com.squareup.okio:okio:jar:sources": -1339925226, "com.typesafe:config": 96906638, "com.typesafe:config:jar:sources": -892423283, "com.vaadin.external.google:android-json": -1531950165, @@ -160,6 +217,38 @@ "io.cloudevents:cloudevents-json-jackson:jar:sources": -166751855, "io.dropwizard.metrics:metrics-core": 1029463962, "io.dropwizard.metrics:metrics-core:jar:sources": 1607397396, + "io.grpc:grpc-api": 716151037, + "io.grpc:grpc-api:jar:sources": 234601049, + "io.grpc:grpc-context": 721938541, + "io.grpc:grpc-context:jar:sources": 1478851124, + "io.grpc:grpc-core": -1513972768, + "io.grpc:grpc-core:jar:sources": -232667389, + "io.grpc:grpc-inprocess": 353730710, + "io.grpc:grpc-inprocess:jar:sources": -676994554, + "io.grpc:grpc-netty-shaded": 1759851352, + "io.grpc:grpc-netty-shaded:jar:sources": 1478851124, + "io.grpc:grpc-protobuf": -549393834, + "io.grpc:grpc-protobuf-lite": -1967256086, + "io.grpc:grpc-protobuf-lite:jar:sources": -1659538303, + "io.grpc:grpc-protobuf:jar:sources": -1849103116, + "io.grpc:grpc-services": 1483710115, + "io.grpc:grpc-services:jar:sources": 2062893709, + "io.grpc:grpc-stub": -1391817309, + "io.grpc:grpc-stub:jar:sources": -1596678046, + "io.grpc:grpc-util": 188637273, + "io.grpc:grpc-util:jar:sources": -2003766127, + "io.gsonfire:gson-fire": -1955577022, + "io.gsonfire:gson-fire:jar:sources": -237239617, + "io.kubernetes:client-java": -1721476940, + "io.kubernetes:client-java-api": 1944060710, + "io.kubernetes:client-java-api-fluent": 889139695, + "io.kubernetes:client-java-api-fluent:jar:sources": -1413457658, + "io.kubernetes:client-java-api:jar:sources": -1450077171, + "io.kubernetes:client-java-extended": 693015729, + "io.kubernetes:client-java-extended:jar:sources": -483537505, + "io.kubernetes:client-java-proto": -533144525, + "io.kubernetes:client-java-proto:jar:sources": 396874650, + "io.kubernetes:client-java:jar:sources": -1422160339, "io.micrometer:context-propagation": -1130727419, "io.micrometer:context-propagation:jar:sources": -1135393170, "io.micrometer:micrometer-commons": 326693391, @@ -172,6 +261,8 @@ "io.micrometer:micrometer-observation-test": -1818068529, "io.micrometer:micrometer-observation-test:jar:sources": 117373145, "io.micrometer:micrometer-observation:jar:sources": 1279306785, + "io.micrometer:micrometer-registry-prometheus": -104964751, + "io.micrometer:micrometer-registry-prometheus:jar:sources": 769452823, "io.micrometer:micrometer-tracing": -109714346, "io.micrometer:micrometer-tracing-bridge-otel": 975863312, "io.micrometer:micrometer-tracing-bridge-otel:jar:sources": 1300289348, @@ -256,6 +347,8 @@ "io.opentelemetry:opentelemetry-sdk-trace": 2083919234, "io.opentelemetry:opentelemetry-sdk-trace:jar:sources": 818630467, "io.opentelemetry:opentelemetry-sdk:jar:sources": -1478160490, + "io.perfmark:perfmark-api": 2140534246, + "io.perfmark:perfmark-api:jar:sources": 1660808382, "io.projectreactor.netty:reactor-netty-core": -1310988391, "io.projectreactor.netty:reactor-netty-core:jar:sources": 160926198, "io.projectreactor.netty:reactor-netty-http": -1289714469, @@ -264,12 +357,26 @@ "io.projectreactor:reactor-core:jar:sources": -717353230, "io.projectreactor:reactor-test": 947112823, "io.projectreactor:reactor-test:jar:sources": 383025302, + "io.prometheus:prometheus-metrics-config": 2090275681, + "io.prometheus:prometheus-metrics-config:jar:sources": -594166944, + "io.prometheus:prometheus-metrics-core": 786050810, + "io.prometheus:prometheus-metrics-core:jar:sources": 832360457, + "io.prometheus:prometheus-metrics-exposition-formats": 968068623, + "io.prometheus:prometheus-metrics-exposition-formats:jar:sources": -438412972, + "io.prometheus:prometheus-metrics-exposition-textformats": 620169553, + "io.prometheus:prometheus-metrics-exposition-textformats:jar:sources": 1866131456, + "io.prometheus:prometheus-metrics-model": -1472396605, + "io.prometheus:prometheus-metrics-model:jar:sources": 2095315251, + "io.prometheus:prometheus-metrics-tracer-common": -1148816462, + "io.prometheus:prometheus-metrics-tracer-common:jar:sources": 2121978274, "io.swagger.core.v3:swagger-annotations-jakarta": -842428034, "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources": 133934665, "io.swagger.core.v3:swagger-core-jakarta": -439612282, "io.swagger.core.v3:swagger-core-jakarta:jar:sources": 449241668, "io.swagger.core.v3:swagger-models-jakarta": -1439553498, "io.swagger.core.v3:swagger-models-jakarta:jar:sources": 2092063988, + "io.swagger:swagger-annotations": 1839493157, + "io.swagger:swagger-annotations:jar:sources": 686356574, "jakarta.activation:jakarta.activation-api": -1560267684, "jakarta.activation:jakarta.activation-api:jar:sources": 759160467, "jakarta.annotation:jakarta.annotation-api": -1904975463, @@ -280,12 +387,24 @@ "jakarta.validation:jakarta.validation-api:jar:sources": -131796339, "jakarta.xml.bind:jakarta.xml.bind-api": 1157993223, "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources": -945798747, + "javax.annotation:javax.annotation-api": 193132517, + "javax.annotation:javax.annotation-api:jar:sources": -1766532873, "net.bytebuddy:byte-buddy": 383637760, "net.bytebuddy:byte-buddy-agent": -1380713096, "net.bytebuddy:byte-buddy-agent:jar:sources": 564051985, "net.bytebuddy:byte-buddy:jar:sources": -1360611642, + "net.devh:grpc-common-spring-boot": 868090547, + "net.devh:grpc-common-spring-boot:jar:sources": 1655513026, + "net.devh:grpc-server-spring-boot-starter": 1078456931, + "net.devh:grpc-server-spring-boot-starter:jar:sources": 1954333860, "net.java.dev.jna:jna": -1951542637, "net.java.dev.jna:jna:jar:sources": -545183654, + "net.javacrumbs.shedlock:shedlock-core": 672475327, + "net.javacrumbs.shedlock:shedlock-core:jar:sources": 69227555, + "net.javacrumbs.shedlock:shedlock-provider-cassandra": -460637299, + "net.javacrumbs.shedlock:shedlock-provider-cassandra:jar:sources": 1860167, + "net.javacrumbs.shedlock:shedlock-spring": 2008414309, + "net.javacrumbs.shedlock:shedlock-spring:jar:sources": 875349683, "net.minidev:accessors-smart": -325667575, "net.minidev:accessors-smart:jar:sources": -124254155, "net.minidev:json-smart": 1673421716, @@ -298,6 +417,8 @@ "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources": -2019023443, "org.apache.cassandra:java-driver-query-builder": 303143232, "org.apache.cassandra:java-driver-query-builder:jar:sources": -1665027563, + "org.apache.commons:commons-collections4": -321403372, + "org.apache.commons:commons-collections4:jar:sources": -620214302, "org.apache.commons:commons-compress": -134181577, "org.apache.commons:commons-compress:jar:sources": -1845261624, "org.apache.commons:commons-lang3": 759645435, @@ -314,14 +435,24 @@ "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources": -1809736441, "org.apiguardian:apiguardian-api": -1579303244, "org.apiguardian:apiguardian-api:jar:sources": 768152577, + "org.aspectj:aspectjweaver": -278059941, + "org.aspectj:aspectjweaver:jar:sources": 2235676, "org.assertj:assertj-core": -536770136, "org.assertj:assertj-core:jar:sources": -1826278818, "org.awaitility:awaitility": 755971515, "org.awaitility:awaitility:jar:sources": -1322650242, + "org.bitbucket.b_c:jose4j": 1319068658, + "org.bitbucket.b_c:jose4j:jar:sources": 969109778, + "org.bouncycastle:bcpkix-jdk18on": 755215182, + "org.bouncycastle:bcpkix-jdk18on:jar:sources": -167831728, "org.bouncycastle:bcprov-jdk18on": -709136978, "org.bouncycastle:bcprov-jdk18on:jar:sources": 106463766, "org.bouncycastle:bcprov-lts8on": 973431866, "org.bouncycastle:bcprov-lts8on:jar:sources": -196135755, + "org.bouncycastle:bcutil-jdk18on": 686883151, + "org.bouncycastle:bcutil-jdk18on:jar:sources": 1665148152, + "org.codehaus.mojo:animal-sniffer-annotations": -1054702037, + "org.codehaus.mojo:animal-sniffer-annotations:jar:sources": -248345982, "org.hamcrest:hamcrest": 1116842741, "org.hamcrest:hamcrest:jar:sources": -996443755, "org.hdrhistogram:HdrHistogram": 1379183334, @@ -339,6 +470,10 @@ "org.jboss.logging:jboss-logging": -2136063667, "org.jboss.logging:jboss-logging:jar:sources": 2066441551, "org.jetbrains.kotlin:kotlin-stdlib": -570435334, + "org.jetbrains.kotlin:kotlin-stdlib-jdk7": -1527302391, + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:sources": 197696244, + "org.jetbrains.kotlin:kotlin-stdlib-jdk8": 1920837701, + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:sources": -680289057, "org.jetbrains.kotlin:kotlin-stdlib:jar:sources": 1404576391, "org.jetbrains:annotations": 643765179, "org.jetbrains:annotations:jar:sources": 1009912224, @@ -427,6 +562,8 @@ "org.springframework.boot:spring-boot-http-converter:jar:sources": 678862764, "org.springframework.boot:spring-boot-jackson": -886310726, "org.springframework.boot:spring-boot-jackson:jar:sources": -928970153, + "org.springframework.boot:spring-boot-loader": 1516185416, + "org.springframework.boot:spring-boot-loader:jar:sources": 499181734, "org.springframework.boot:spring-boot-micrometer-metrics": -971815116, "org.springframework.boot:spring-boot-micrometer-metrics-test": 1059861465, "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources": 539702709, @@ -466,6 +603,8 @@ "org.springframework.boot:spring-boot-starter-actuator-test": 910069034, "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources": 314973026, "org.springframework.boot:spring-boot-starter-actuator:jar:sources": -2079433447, + "org.springframework.boot:spring-boot-starter-aspectj": 740892542, + "org.springframework.boot:spring-boot-starter-aspectj:jar:sources": 1957689077, "org.springframework.boot:spring-boot-starter-data-cassandra": -1305411192, "org.springframework.boot:spring-boot-starter-data-cassandra-test": 1049224325, "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources": 464889083, @@ -538,13 +677,22 @@ "org.springframework.cloud:spring-cloud-commons:jar:sources": 1320616004, "org.springframework.cloud:spring-cloud-context": 1118197933, "org.springframework.cloud:spring-cloud-context:jar:sources": -1413797918, + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig": -256822388, + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig:jar:sources": 758022911, + "org.springframework.cloud:spring-cloud-kubernetes-client-config": 954066893, + "org.springframework.cloud:spring-cloud-kubernetes-client-config:jar:sources": -251471561, + "org.springframework.cloud:spring-cloud-kubernetes-commons": -500725348, + "org.springframework.cloud:spring-cloud-kubernetes-commons:jar:sources": -325491963, "org.springframework.cloud:spring-cloud-starter": -1812063683, "org.springframework.cloud:spring-cloud-starter-bootstrap": -1263482746, "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources": 1965832056, + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config": -251169345, "org.springframework.data:spring-data-cassandra": -1548120966, "org.springframework.data:spring-data-cassandra:jar:sources": -1628107854, "org.springframework.data:spring-data-commons": 1312199320, "org.springframework.data:spring-data-commons:jar:sources": 544548950, + "org.springframework.retry:spring-retry": -2038056656, + "org.springframework.retry:spring-retry:jar:sources": -870799349, "org.springframework.security:spring-security-config": 2001744589, "org.springframework.security:spring-security-config:jar:sources": -756968726, "org.springframework.security:spring-security-core": -1251894794, @@ -568,6 +716,8 @@ "org.springframework:spring-beans": -698130853, "org.springframework:spring-beans:jar:sources": -2147408778, "org.springframework:spring-context": -846077202, + "org.springframework:spring-context-support": 427709038, + "org.springframework:spring-context-support:jar:sources": -410245914, "org.springframework:spring-context:jar:sources": -1263444176, "org.springframework:spring-core": -253727183, "org.springframework:spring-core:jar:sources": -173347576, @@ -590,6 +740,8 @@ "org.testcontainers:testcontainers-database-commons:jar:sources": 315333060, "org.testcontainers:testcontainers-junit-jupiter": -1827576744, "org.testcontainers:testcontainers-junit-jupiter:jar:sources": 975697823, + "org.testcontainers:testcontainers-localstack": 744979553, + "org.testcontainers:testcontainers-localstack:jar:sources": 311852137, "org.testcontainers:testcontainers:jar:sources": 76092129, "org.wiremock:wiremock-standalone": -1817681233, "org.wiremock:wiremock-standalone:jar:sources": 695361099, @@ -674,6 +826,20 @@ }, "version": "1.5.34" }, + "com.bucket4j:bucket4j-core": { + "shasums": { + "jar": "c274208e4961855e8e341bcb74434af8771ce98a494aec48997028efeb4909f2", + "sources": "fb9270f4aef9d0688e12f9b83553fe2bc1fa0c15bd98894579c3f19b489ef4dc" + }, + "version": "8.10.1" + }, + "com.bucket4j:bucket4j_jdk17-core": { + "shasums": { + "jar": "603885663799e203f7e17394315fdbdc584fe021643614d8a34b49b53e618c74", + "sources": "e68a40186a9ef6f80e05db48ac76d7beca94b8d5bd635fb22aeab784ad16a520" + }, + "version": "8.19.0" + }, "com.datastax.cassandra:cassandra-driver-core": { "shasums": { "jar": "09c4d54009e942e3afb8755c5fd51abac8ba721e407b61dcb22f435d1a33c1a1", @@ -836,11 +1002,33 @@ }, "version": "1.0-1" }, + "com.google.android:annotations": { + "shasums": { + "jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15", + "sources": "e9b667aa958df78ea1ad115f7bbac18a5869c3128b1d5043feb360b0cfce9d40" + }, + "version": "4.1.1.4" + }, + "com.google.api.grpc:proto-google-common-protos": { + "shasums": { + "jar": "ee9c751f06b112e92b37f75e4f73a17d03ef2c3302c6e8d986adbcc721b63cb0", + "sources": "fe7831089c20c097ef540b61ff90d12cfe0fbc57c2bbe21a3e8fa96bb0085d99" + }, + "version": "2.29.0" + }, "com.google.code.findbugs:jsr305": { "shasums": { - "jar": "1e7f53fa5b8b5c807e986ba335665da03f18d660802d8bf061823089d1bee468" + "jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7", + "sources": "1c9e85e272d0708c6a591dc74828c71603053b48cc75ae83cce56912a2aa063b" }, - "version": "2.0.1" + "version": "3.0.2" + }, + "com.google.code.gson:gson": { + "shasums": { + "jar": "dd0ce1b55a3ed2080cb70f9c655850cda86c206862310009dcb5e5c95265a5e0", + "sources": "058974b69cb7b0a04712278e11870e84ee8cd8fb5f551bd8401e72ba6638bfef" + }, + "version": "2.13.2" }, "com.google.errorprone:error_prone_annotations": { "shasums": { @@ -876,6 +1064,20 @@ }, "version": "3.1" }, + "com.google.protobuf:protobuf-java": { + "shasums": { + "jar": "3ca892fd6ea8b37d01bb6917dbc0bf2637548b756753f65a28d4f1d4d982347f", + "sources": "ed30fe6a51c7c15a6f123448304c97185f2039f2aeca9d5e3b4f53de3a4c813c" + }, + "version": "4.33.4" + }, + "com.google.protobuf:protobuf-java-util": { + "shasums": { + "jar": "6f02f04c5ca088e74b68dbbaf118f1ffa9d587958e637f893e0f8a1899a61342", + "sources": "4bf8ae758fdaae56220796e651cf5fbc3f7ce99bdc42b7048c2604f757592f8a" + }, + "version": "4.33.4" + }, "com.jayway.jsonpath:json-path": { "shasums": { "jar": "890daa95dd3892d34d9fabc27cd5153656e6f369358625c88f4dc7b79cbd6c5a", @@ -911,6 +1113,20 @@ }, "version": "11.26.1" }, + "com.squareup.okhttp3:logging-interceptor": { + "shasums": { + "jar": "f3e8d5f0903c250c2b55d2f47fcfe008e80634385da8385161c7a63aaed0c74c", + "sources": "967335783f8af3fca7819f9f343f753243f2877c5480099e2084fe493af7da82" + }, + "version": "4.12.0" + }, + "com.squareup.okhttp3:okhttp": { + "shasums": { + "jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0", + "sources": "d91a769a4140e542cddbac4e67fcf279299614e8bfd53bd23b85e60c2861341c" + }, + "version": "4.12.0" + }, "com.squareup.okhttp3:okhttp-jvm": { "shasums": { "jar": "9632c08567dbb21c569b13d793107834c8580d44e4eea74b2eae0722f0506179", @@ -918,6 +1134,13 @@ }, "version": "5.2.1" }, + "com.squareup.okio:okio": { + "shasums": { + "jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5", + "sources": "64d5b6667f064511dd93100173f735b2d5052a1c926858f4b6a05b84e825ef94" + }, + "version": "3.6.0" + }, "com.squareup.okio:okio-jvm": { "shasums": { "jar": "31f48e6463ec587d6d262d042c91da00659c983b6ad20d5982bf31e85222693c", @@ -988,6 +1211,118 @@ }, "version": "3.2.2" }, + "io.grpc:grpc-api": { + "shasums": { + "jar": "21d747911e1e5931004f1b058417f3c3f72f1fbf8aea16f5fc6af7a3f0caf35a", + "sources": "7ff367383a7e67241d72404f584a73d47d8f0a6b4c0bdf8aca4170f6475e58a3" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-context": { + "shasums": { + "jar": "d7f50185bb858131d02314de23ea3cc797131ed98b215e845429f45a81dd5fed", + "sources": "a9032f8d2f795247bef60b45ee5fc3eb4693c705c05818cc12e9ed1deba440d4" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-core": { + "shasums": { + "jar": "246e4e583cc11c70ed15cc8f988eff8e76fed5c06426e8310d51c4da6f5cc81b", + "sources": "6d2f37cebce94495593d736e118d6df8ff05b877e2ebc2b5e472c140a5fa5559" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-inprocess": { + "shasums": { + "jar": "5386e23de85651bf868c3565bfa4f1af1bb9c888d8de5ae4350e010064041336", + "sources": "55a86f1a031e1c7dfe4bae6b6350ff561003e0395ca2c3af5114a3aab8afaa22" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-netty-shaded": { + "shasums": { + "jar": "4d170c599e47214f35c8955cda3edd7cdb8171229b45795d4d61eb43dfa76402", + "sources": "a9032f8d2f795247bef60b45ee5fc3eb4693c705c05818cc12e9ed1deba440d4" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-protobuf": { + "shasums": { + "jar": "260e0abaf8ac72fc71deec9f88d2beeb163e6d19494bbabe45676a4b4ce87087", + "sources": "e44785571580c4bfc25582a739f66acacf4af030bb6d58d8459532d4570225a6" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-protobuf-lite": { + "shasums": { + "jar": "5e7f36c03600c7cfa8e10d2d0321f0ba8c32d74cd044873f44b026704f355fb7", + "sources": "0464b0b77b4360c0f9916477859abbbf22457a9d18c343743635e7247c2af684" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-services": { + "shasums": { + "jar": "5258358c47d5afb1dc8fd6bc869c93b02bce686c156f5e0a1aab5d39b1d8e0a5", + "sources": "d265c23748ecd5c15142978d38798e8db4a2cd146cf7fe6f8cb535f202219561" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-stub": { + "shasums": { + "jar": "a92fc7c7f1ac9d580c5e3df5825b977c842f442874794663b78db22b27d649e0", + "sources": "77cf1024d4612bf7ec085ff330456eb49cdbaa93e21559396f07b063ea289238" + }, + "version": "1.63.0" + }, + "io.grpc:grpc-util": { + "shasums": { + "jar": "b15006f22e76a6631dfb137a6930aaf9e0cb3a797499ec42394d79641fb770ee", + "sources": "969b6a21c1689ad8d71e4f557ccd76c9c3f422c037fe0e5c1e9c9b3f6eeb45d6" + }, + "version": "1.63.0" + }, + "io.gsonfire:gson-fire": { + "shasums": { + "jar": "73f56642ef43381efda085befb1e6cb51ce906af426d8db19632d396a5bb7a91", + "sources": "40d8500f33c7309515782d5381593a9d6c42699fc3fe38df0055ec4e08055f59" + }, + "version": "1.9.0" + }, + "io.kubernetes:client-java": { + "shasums": { + "jar": "8facf414f052bd39176703f0a5e923e3ed945fa736eb4a00cc685e633eaeb286", + "sources": "f4982edc9570241d940971b6f9a93a60976596584feba462e94c58263b234abe" + }, + "version": "24.0.0" + }, + "io.kubernetes:client-java-api": { + "shasums": { + "jar": "580bf0c6c7ab498cb53d16d975ee6fab8183e90c8993f39a9e739e100bb432d3", + "sources": "0248c50909280447729c9ab996abf41b96bcf54e1d7c9567b49dcddfae78fa67" + }, + "version": "24.0.0" + }, + "io.kubernetes:client-java-api-fluent": { + "shasums": { + "jar": "abd85f46b6a8d81032e25129dcc04a04e3efe414057255b91593e1a08aff0be5", + "sources": "febf43923730c43335aecb9bf8e16618cf9d6d35fef6acbd3b11579cf4e6cf75" + }, + "version": "24.0.0" + }, + "io.kubernetes:client-java-extended": { + "shasums": { + "jar": "5c7089ef93a08714f6b63142579cf7a9f7c2bde62bdf530174351ba390002281", + "sources": "127e271c85091a8a4f563cea1a7a4e16051b3db77b0b824ae1e51a2aada57787" + }, + "version": "24.0.0" + }, + "io.kubernetes:client-java-proto": { + "shasums": { + "jar": "ffe88472b1bac61e08fa19e8a3af53f5a4ee314913f77313f0a1beff7ca228b1", + "sources": "b954e41c5b45105699d1ee179f4e6fb6b0c707a9f3fe52a418989070194232d2" + }, + "version": "24.0.0" + }, "io.micrometer:context-propagation": { "shasums": { "jar": "5b69e2100640879ccf4b20673ced86de560b8e217b55542f85a53d3563b72aaa", @@ -1030,6 +1365,13 @@ }, "version": "1.16.6" }, + "io.micrometer:micrometer-registry-prometheus": { + "shasums": { + "jar": "0e9c7a32dffea3745f2ebff41a4cfeddc5269ca0fd6d98d6e3bafc7b5e35375d", + "sources": "76668b6182ad39e92ddc48ee7815d32cd84ceea4c6751ee13f076a86a37c8136" + }, + "version": "1.16.6" + }, "io.micrometer:micrometer-tracing": { "shasums": { "jar": "b335096393e4d070dfda4fb30808f8488d31d43811b93b2d7496703c5f1315ef", @@ -1314,6 +1656,13 @@ }, "version": "1.55.0" }, + "io.perfmark:perfmark-api": { + "shasums": { + "jar": "b7d23e93a34537ce332708269a0d1404788a5b5e1949e82f5535fce51b3ea95b", + "sources": "7379e0fef0c32d69f3ebae8f271f426fc808613f1cfbc29e680757f348ba8aa4" + }, + "version": "0.26.0" + }, "io.projectreactor.netty:reactor-netty-core": { "shasums": { "jar": "b8aaddd6025af42c2d810a76dc5b9a7717f0fde84beb93d6d4a778dac25fc287", @@ -1342,6 +1691,48 @@ }, "version": "3.8.6" }, + "io.prometheus:prometheus-metrics-config": { + "shasums": { + "jar": "3da2d5f0123af9e313bcf26f04c1248afeacd1b0d5c3a35f220034604ac0ec47", + "sources": "b926a8a6802d8f1ea9800f9e88cb4b2ff8511b6fb63786ad51d778a5f22bd059" + }, + "version": "1.4.3" + }, + "io.prometheus:prometheus-metrics-core": { + "shasums": { + "jar": "7ecd546d5238e3d3f5f312f580eaaab10cb05d147597978ab90b78080cc58255", + "sources": "48721aa5dd183e912fe82f2c19ebd980a11ed56c9a8296d23fd4011051ec8d96" + }, + "version": "1.4.3" + }, + "io.prometheus:prometheus-metrics-exposition-formats": { + "shasums": { + "jar": "502ad6b37c44b1a6311f6070a18fc921ffa2e26abb43eaaaf7626aa1b97984b6", + "sources": "4bc087c844d7dd22799e5466bcd47c7e0feda0a6fbc084c26aaa843af5cee6e9" + }, + "version": "1.4.3" + }, + "io.prometheus:prometheus-metrics-exposition-textformats": { + "shasums": { + "jar": "4aeee30d937ae5baf34ca9daced0f84400ec0f034e7beaa8331d9c519d84e3d7", + "sources": "a4b5c7b615f221b4fd6dc46a877c9c5b5151a86f03789c21f2d708407d1a9bc3" + }, + "version": "1.4.3" + }, + "io.prometheus:prometheus-metrics-model": { + "shasums": { + "jar": "bc3f1825014a14006626086ea779b62893e647e71d1eab075c0bb65057c245f8", + "sources": "61abe1dd37b1f8caea95ba193e5d85dc4b37861017731524ca155bfb57936cfe" + }, + "version": "1.4.3" + }, + "io.prometheus:prometheus-metrics-tracer-common": { + "shasums": { + "jar": "b816aaf84e45d591e5f66c5b94c7a789dedf2d705e5bf3eec2ae8730f76c68cd", + "sources": "797e6e276ccf7d874248b4d779b705ce0a400fe02b54443a04758b393de3f66e" + }, + "version": "1.4.3" + }, "io.swagger.core.v3:swagger-annotations-jakarta": { "shasums": { "jar": "9b30b319f1c31993e6128d22e12652be9a688589e30c67819ed84e95b96e7f88", @@ -1363,6 +1754,13 @@ }, "version": "2.2.47" }, + "io.swagger:swagger-annotations": { + "shasums": { + "jar": "c832295d639aa54139404b7406fb2f8fbf1da8c57219df3395de475503464297", + "sources": "7b2de9dc92520bd12e30987ee491191131e719d30e3bbe8a536f18e80ca14df3" + }, + "version": "1.6.16" + }, "jakarta.activation:jakarta.activation-api": { "shasums": { "jar": "c9db52100ce6c8aac95cc39075f95720d2e561b11f8051b81c121ad4effd7004", @@ -1398,6 +1796,13 @@ }, "version": "4.0.5" }, + "javax.annotation:javax.annotation-api": { + "shasums": { + "jar": "e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b", + "sources": "128971e52e0d84a66e3b6e049dab8ad7b2c58b7e1ad37fa2debd3d40c2947b95" + }, + "version": "1.3.2" + }, "net.bytebuddy:byte-buddy": { "shasums": { "jar": "2b5ddc8c1f4234bdb7cb45338a8e10a13e0e3ca473e91d5d821d681127ea8ba1", @@ -1412,6 +1817,20 @@ }, "version": "1.17.8" }, + "net.devh:grpc-common-spring-boot": { + "shasums": { + "jar": "951fd28aa9dce0cfc5be8ff9eba6d7dc616b2998e920a7b6acc05e11f4064f94", + "sources": "157991de7c495e40c1b18f8ef274e5ff1e2c0393b75642e7308457b866b3096a" + }, + "version": "3.1.0.RELEASE" + }, + "net.devh:grpc-server-spring-boot-starter": { + "shasums": { + "jar": "289b7b45fe511d14f54801745ac3de7602bf7d7eef0fb75890bef7fbedb94ec4", + "sources": "e0ddd0872ffbbc48c322ba617035bafd0f5bc656e91eec0bda876d3f07d9f3f1" + }, + "version": "3.1.0.RELEASE" + }, "net.java.dev.jna:jna": { "shasums": { "jar": "260c4b1e22b1db9e110ee441c4f13ce115f841fa48c41d78750986214b395557", @@ -1419,6 +1838,27 @@ }, "version": "5.18.1" }, + "net.javacrumbs.shedlock:shedlock-core": { + "shasums": { + "jar": "b2ca6f358b5dcbadc641a7c5ef6217b8c5f91bc2b49e89d715fdd5443cc48166", + "sources": "81a2914ff05c94d86b54d78fa59f79216df1db8e980d5d5a910559f6da3bb9e1" + }, + "version": "7.7.0" + }, + "net.javacrumbs.shedlock:shedlock-provider-cassandra": { + "shasums": { + "jar": "e8a5db022350461384618f4ebaf978dbc2894becb208d7c053ae0125dd31a7eb", + "sources": "2e6e6fdcbc77faffaa22b16850ae4b50f7693439b59fc146c0ec9f510c012eb5" + }, + "version": "7.7.0" + }, + "net.javacrumbs.shedlock:shedlock-spring": { + "shasums": { + "jar": "a71fa9d539b10140b5aaea5a4a0e58f219b396195b6bc7148c8adf65425cbce1", + "sources": "bb61469b2f50396b4ab6ccb2a30ecb3d394ca0d0c986f43086de2beaafeac67e" + }, + "version": "7.7.0" + }, "net.minidev:accessors-smart": { "shasums": { "jar": "222c9f547bb20a99fc486403a398352d1306fb671b38abd7ecab6401df170e61", @@ -1461,6 +1901,13 @@ }, "version": "4.19.3" }, + "org.apache.commons:commons-collections4": { + "shasums": { + "jar": "00f93263c267be201b8ae521b44a7137271b16688435340bf629db1bac0a5845", + "sources": "75f1bef9447cce189743f7d52f63a669bd796ae19ca863e1f22db1d5b6b504a6" + }, + "version": "4.5.0" + }, "org.apache.commons:commons-compress": { "shasums": { "jar": "e1522945218456f3649a39bc4afd70ce4bd466221519dba7d378f2141a4642ca", @@ -1517,6 +1964,13 @@ }, "version": "1.1.2" }, + "org.aspectj:aspectjweaver": { + "shasums": { + "jar": "4fe86fdc18faea571f29129c70eaad5d121363504a06d7907be88f6c60ba3116", + "sources": "06fbde6ef3a83791e70965432b5f1891e493e21cdbc37307c54575ee86752595" + }, + "version": "1.9.25.1" + }, "org.assertj:assertj-core": { "shasums": { "jar": "c4a445426c3c2861666863b842cc4ec7bbb1c4226fefd370b6d2fe83d6c4ff0f", @@ -1531,6 +1985,20 @@ }, "version": "4.3.0" }, + "org.bitbucket.b_c:jose4j": { + "shasums": { + "jar": "7314af50cde9c99e8eaf43eee617a23edcc6bb43036221064355094999d837ef", + "sources": "958be1837b507d3a1f1187257072b4c1e1d031c3a0c610d3c02ac69aabfac6a5" + }, + "version": "0.9.6" + }, + "org.bouncycastle:bcpkix-jdk18on": { + "shasums": { + "jar": "4f4ba6a92617ea19dc183f0fa5db492eee426fdde2a0a2d6c94777ffd1af6413", + "sources": "601ec2beb4749f0be65e296811b6e63de567f90d124f26887875bb722fd87e71" + }, + "version": "1.80" + }, "org.bouncycastle:bcprov-jdk18on": { "shasums": { "jar": "64d6c5a6121fcd927152dd182cbed39afe0fda641a970d9bcc0c9cb1858b2731", @@ -1545,6 +2013,20 @@ }, "version": "2.73.8" }, + "org.bouncycastle:bcutil-jdk18on": { + "shasums": { + "jar": "bc78d32d7ffb141ee27e4fb77df04259d842c899e7e8eaf912f990d7253bd3b4", + "sources": "f1e43055e8287cde556a7741bf16b7fd7896b8f572ab91e0cadb337e710b015f" + }, + "version": "1.80.2" + }, + "org.codehaus.mojo:animal-sniffer-annotations": { + "shasums": { + "jar": "9ffe526bf43a6348e9d8b33b9cd6f580a7f5eed0cf055913007eda263de974d0", + "sources": "4878fcc6808dbc88085a4622db670e703867754bc4bc40312c52bf3a3510d019" + }, + "version": "1.23" + }, "org.hamcrest:hamcrest": { "shasums": { "jar": "5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc", @@ -1608,6 +2090,20 @@ }, "version": "2.2.21" }, + "org.jetbrains.kotlin:kotlin-stdlib-jdk7": { + "shasums": { + "jar": "b785922f11e6d91a6dd1d75cb0aef1ce37b83f8de0e3a2139139dfb823bb8a2c", + "sources": "2534c8908432e06de73177509903d405b55f423dd4c2f747e16b92a2162611e6" + }, + "version": "2.2.21" + }, + "org.jetbrains.kotlin:kotlin-stdlib-jdk8": { + "shasums": { + "jar": "c62275c50ee591ca2f82c7ba42696b791600c25844f47e84bd9460302a0d5238", + "sources": "3cb6895054a0985bba591c165503fe4dd63a215af53263b67a071ccdc242bf6e" + }, + "version": "2.2.21" + }, "org.jetbrains:annotations": { "shasums": { "jar": "195fb0da046d55bb042e91543484cf1da68b02bb7afbfe031f229e45ac84b3f2", @@ -1916,6 +2412,13 @@ }, "version": "4.0.7" }, + "org.springframework.boot:spring-boot-loader": { + "shasums": { + "jar": "bb1cd5fee23e03eec3fb5fdaff59c56d1db3829afe5f59381904764df76fb1a8", + "sources": "539b6b3e0fde31a126ee1bc885840874aae9275f28c8c7d4c30e1c15ed85b221" + }, + "version": "4.0.7" + }, "org.springframework.boot:spring-boot-micrometer-metrics": { "shasums": { "jar": "1cd20b112104d244fdc96b44b3a8193e0916686a819a6be93127c1c7eefc1c9d", @@ -2056,6 +2559,13 @@ }, "version": "4.0.7" }, + "org.springframework.boot:spring-boot-starter-aspectj": { + "shasums": { + "jar": "5521390213e6f2a0a3ef0d78eed35094868f840888823f9508e175dab10e7306", + "sources": "5521390213e6f2a0a3ef0d78eed35094868f840888823f9508e175dab10e7306" + }, + "version": "4.0.7" + }, "org.springframework.boot:spring-boot-starter-data-cassandra": { "shasums": { "jar": "32abc561b414e781bc1998e1a6cd1167f3eff2247e6bb2b1b08a347bb3054acb", @@ -2301,6 +2811,27 @@ }, "version": "5.0.2" }, + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig": { + "shasums": { + "jar": "3ccfe0d3f130c7e2ddede783c416a331e935c5818df7ad6a0369c646641cd596", + "sources": "15ba3dfaf88146e82232510b8a02bdb1c0e6982efe5a0e00a5bbaacd40f7321f" + }, + "version": "5.0.2" + }, + "org.springframework.cloud:spring-cloud-kubernetes-client-config": { + "shasums": { + "jar": "3891c4930a4c214e316a6f1624a596c4007f759b3465cc1fa3cb1fc7edba2614", + "sources": "abf13b94b56188d1df40c9f080c3771c77c1252350a4d6b674887fa1466076e5" + }, + "version": "5.0.2" + }, + "org.springframework.cloud:spring-cloud-kubernetes-commons": { + "shasums": { + "jar": "34a0c7e9a1036e03faa0d75655f7e82eade86f6fc3b4ae2273e434b5798bd8f5", + "sources": "bec63622fa749d090a711f9a24b0c0561f70a8ee5853db7c1bf727990c373817" + }, + "version": "5.0.2" + }, "org.springframework.cloud:spring-cloud-starter": { "shasums": { "jar": "7568b8ced4069bc055af52b5455b206092401a44dcbd4f89d6fa00f89df1cc18" @@ -2314,6 +2845,12 @@ }, "version": "5.0.2" }, + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config": { + "shasums": { + "jar": "f0b4f8adf2dbac198147d18e1890e5ebd5f41d8de4db68ae5eca930e946c1efe" + }, + "version": "5.0.2" + }, "org.springframework.data:spring-data-cassandra": { "shasums": { "jar": "d9e4f2656df238d5561d02d0521036f52518b91d0ef840fd8f25d113b4325610", @@ -2328,6 +2865,13 @@ }, "version": "4.0.6" }, + "org.springframework.retry:spring-retry": { + "shasums": { + "jar": "213785750007f90b067ba43036cbffdad2890f6bb98917e199f6b049cf810040", + "sources": "672447ec8df39cf31fcd8b9fdd209ae65776fb06c17e1e6fd13e30943963947b" + }, + "version": "2.0.13" + }, "org.springframework.security:spring-security-config": { "shasums": { "jar": "7aeafd876d48d8b5660356953ed7a607d2d09e8f2b9844acba3fcbc32beb63cc", @@ -2412,6 +2956,13 @@ }, "version": "7.0.8" }, + "org.springframework:spring-context-support": { + "shasums": { + "jar": "9ab80715682c47ad66a1a2c0e9ab2be9ec3d828276f281597f12c5208147b92e", + "sources": "22d3e1ee4d52ede6b31cd6fd27d65d4bdd0b2709fcc14af92ec287286ae8abf3" + }, + "version": "7.0.8" + }, "org.springframework:spring-core": { "shasums": { "jar": "726ba2a5130833644bdf267a55ff26e1f52e8dcc9aa1ffa06904ca9c14619f25", @@ -2489,6 +3040,13 @@ }, "version": "2.0.5" }, + "org.testcontainers:testcontainers-localstack": { + "shasums": { + "jar": "2fa0f4271a69112ece3841bb26b251962280e39f2e6b2daa7a4aa9128a54485d", + "sources": "b37477b207c29a395c8d0428a6131954aa3495ba2cf5a8db40c9f57c1e8cfa8d" + }, + "version": "2.0.5" + }, "org.wiremock:wiremock-standalone": { "shasums": { "jar": "d097b19bd483c5038479b13a5c71e9faf8f2f5106584f0c120a7770ab0bdb367", @@ -2654,14 +3212,19 @@ "conflict_resolution": { "com.github.jnr:jnr-ffi:2.2.11": "com.github.jnr:jnr-ffi:2.1.7", "com.github.jnr:jnr-posix:3.0.44": "com.github.jnr:jnr-posix:3.1.15", - "com.google.code.findbugs:jsr305:3.0.2": "com.google.code.findbugs:jsr305:2.0.1", + "com.google.code.findbugs:jsr305:2.0.1": "com.google.code.findbugs:jsr305:3.0.2", + "com.google.errorprone:error_prone_annotations:2.18.0": "com.google.errorprone:error_prone_annotations:2.49.0", + "com.google.errorprone:error_prone_annotations:2.23.0": "com.google.errorprone:error_prone_annotations:2.49.0", + "com.google.errorprone:error_prone_annotations:2.41.0": "com.google.errorprone:error_prone_annotations:2.49.0", "com.google.errorprone:error_prone_annotations:2.47.0": "com.google.errorprone:error_prone_annotations:2.49.0", "com.google.guava:guava:19.0": "com.google.guava:guava:33.6.0-jre", - "org.jetbrains:annotations:13.0": "org.jetbrains:annotations:17.0.0", - "org.ow2.asm:asm-commons:5.0.3": "org.ow2.asm:asm-commons:9.9", - "org.ow2.asm:asm-tree:5.0.3": "org.ow2.asm:asm-tree:9.9", - "org.ow2.asm:asm:5.0.3": "org.ow2.asm:asm:9.9", - "org.ow2.asm:asm:9.7.1": "org.ow2.asm:asm:9.9" + "com.google.guava:guava:32.1.3-android": "com.google.guava:guava:33.6.0-jre", + "com.google.guava:guava:32.1.3-jre": "com.google.guava:guava:33.6.0-jre", + "com.google.j2objc:j2objc-annotations:2.8": "com.google.j2objc:j2objc-annotations:3.1", + "com.squareup.okio:okio-jvm:3.6.0": "com.squareup.okio:okio-jvm:3.16.1", + "commons-io:commons-io:2.19.0": "commons-io:commons-io:2.20.0", + "org.apache.commons:commons-compress:1.27.1": "org.apache.commons:commons-compress:1.28.0", + "org.jetbrains:annotations:13.0": "org.jetbrains:annotations:17.0.0" }, "dependencies": { "ch.qos.logback:logback-classic": [ @@ -2739,6 +3302,12 @@ "com.github.jnr:jnr-constants", "com.github.jnr:jnr-ffi" ], + "com.google.api.grpc:proto-google-common-protos": [ + "com.google.protobuf:protobuf-java" + ], + "com.google.code.gson:gson": [ + "com.google.errorprone:error_prone_annotations" + ], "com.google.guava:guava": [ "com.google.errorprone:error_prone_annotations", "com.google.guava:failureaccess", @@ -2746,6 +3315,12 @@ "com.google.j2objc:j2objc-annotations", "org.jspecify:jspecify" ], + "com.google.protobuf:protobuf-java-util": [ + "com.google.code.findbugs:jsr305", + "com.google.code.gson:gson", + "com.google.errorprone:error_prone_annotations", + "com.google.protobuf:protobuf-java" + ], "com.jayway.jsonpath:json-path": [ "net.minidev:json-smart", "org.slf4j:slf4j-api" @@ -2757,10 +3332,21 @@ "com.nimbusds:nimbus-jose-jwt", "net.minidev:json-smart" ], + "com.squareup.okhttp3:logging-interceptor": [ + "com.squareup.okhttp3:okhttp", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + ], + "com.squareup.okhttp3:okhttp": [ + "com.squareup.okio:okio", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + ], "com.squareup.okhttp3:okhttp-jvm": [ "com.squareup.okio:okio-jvm", "org.jetbrains.kotlin:kotlin-stdlib" ], + "com.squareup.okio:okio": [ + "com.squareup.okio:okio-jvm" + ], "com.squareup.okio:okio-jvm": [ "org.jetbrains.kotlin:kotlin-stdlib" ], @@ -2775,6 +3361,116 @@ "io.dropwizard.metrics:metrics-core": [ "org.slf4j:slf4j-api" ], + "io.grpc:grpc-api": [ + "com.google.code.findbugs:jsr305", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava" + ], + "io.grpc:grpc-context": [ + "io.grpc:grpc-api" + ], + "io.grpc:grpc-core": [ + "com.google.android:annotations", + "com.google.code.gson:gson", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "io.grpc:grpc-api", + "io.grpc:grpc-context", + "io.perfmark:perfmark-api", + "org.codehaus.mojo:animal-sniffer-annotations" + ], + "io.grpc:grpc-inprocess": [ + "com.google.guava:guava", + "io.grpc:grpc-api", + "io.grpc:grpc-core" + ], + "io.grpc:grpc-netty-shaded": [ + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "io.grpc:grpc-api", + "io.grpc:grpc-core", + "io.grpc:grpc-util", + "io.perfmark:perfmark-api" + ], + "io.grpc:grpc-protobuf": [ + "com.google.api.grpc:proto-google-common-protos", + "com.google.code.findbugs:jsr305", + "com.google.guava:guava", + "com.google.protobuf:protobuf-java", + "io.grpc:grpc-api", + "io.grpc:grpc-protobuf-lite" + ], + "io.grpc:grpc-protobuf-lite": [ + "com.google.code.findbugs:jsr305", + "com.google.guava:guava", + "io.grpc:grpc-api" + ], + "io.grpc:grpc-services": [ + "com.google.code.gson:gson", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "com.google.j2objc:j2objc-annotations", + "com.google.protobuf:protobuf-java-util", + "io.grpc:grpc-core", + "io.grpc:grpc-protobuf", + "io.grpc:grpc-stub", + "io.grpc:grpc-util" + ], + "io.grpc:grpc-stub": [ + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "io.grpc:grpc-api" + ], + "io.grpc:grpc-util": [ + "com.google.guava:guava", + "io.grpc:grpc-api", + "io.grpc:grpc-core", + "org.codehaus.mojo:animal-sniffer-annotations" + ], + "io.gsonfire:gson-fire": [ + "com.google.code.gson:gson" + ], + "io.kubernetes:client-java": [ + "com.google.protobuf:protobuf-java", + "commons-codec:commons-codec", + "commons-io:commons-io", + "io.kubernetes:client-java-api", + "io.kubernetes:client-java-proto", + "org.apache.commons:commons-collections4", + "org.apache.commons:commons-compress", + "org.apache.commons:commons-lang3", + "org.bitbucket.b_c:jose4j", + "org.bouncycastle:bcpkix-jdk18on", + "org.slf4j:slf4j-api", + "org.yaml:snakeyaml" + ], + "io.kubernetes:client-java-api": [ + "com.fasterxml.jackson.core:jackson-databind", + "com.google.code.findbugs:jsr305", + "com.google.code.gson:gson", + "com.squareup.okhttp3:logging-interceptor", + "com.squareup.okhttp3:okhttp", + "io.gsonfire:gson-fire", + "io.swagger:swagger-annotations", + "jakarta.annotation:jakarta.annotation-api", + "javax.annotation:javax.annotation-api", + "org.apache.commons:commons-lang3" + ], + "io.kubernetes:client-java-api-fluent": [ + "io.kubernetes:client-java-api" + ], + "io.kubernetes:client-java-extended": [ + "com.bucket4j:bucket4j-core", + "com.github.ben-manes.caffeine:caffeine", + "io.kubernetes:client-java", + "io.kubernetes:client-java-api", + "io.kubernetes:client-java-api-fluent", + "io.kubernetes:client-java-proto", + "org.apache.commons:commons-lang3" + ], + "io.kubernetes:client-java-proto": [ + "com.google.protobuf:protobuf-java" + ], "io.micrometer:context-propagation": [ "org.jspecify:jspecify" ], @@ -2805,6 +3501,13 @@ "org.junit.jupiter:junit-jupiter", "org.mockito:mockito-core" ], + "io.micrometer:micrometer-registry-prometheus": [ + "io.micrometer:micrometer-core", + "io.prometheus:prometheus-metrics-core", + "io.prometheus:prometheus-metrics-exposition-formats", + "io.prometheus:prometheus-metrics-tracer-common", + "org.jspecify:jspecify" + ], "io.micrometer:micrometer-tracing": [ "aopalliance:aopalliance", "io.micrometer:context-propagation", @@ -3047,6 +3750,20 @@ "io.projectreactor:reactor-core", "org.jspecify:jspecify" ], + "io.prometheus:prometheus-metrics-core": [ + "io.prometheus:prometheus-metrics-config", + "io.prometheus:prometheus-metrics-model" + ], + "io.prometheus:prometheus-metrics-exposition-formats": [ + "io.prometheus:prometheus-metrics-exposition-textformats" + ], + "io.prometheus:prometheus-metrics-exposition-textformats": [ + "io.prometheus:prometheus-metrics-config", + "io.prometheus:prometheus-metrics-model" + ], + "io.prometheus:prometheus-metrics-model": [ + "io.prometheus:prometheus-metrics-config" + ], "io.swagger.core.v3:swagger-core-jakarta": [ "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-databind", @@ -3066,6 +3783,32 @@ "jakarta.xml.bind:jakarta.xml.bind-api": [ "jakarta.activation:jakarta.activation-api" ], + "net.devh:grpc-common-spring-boot": [ + "io.grpc:grpc-core", + "org.springframework.boot:spring-boot-starter" + ], + "net.devh:grpc-server-spring-boot-starter": [ + "io.grpc:grpc-api", + "io.grpc:grpc-inprocess", + "io.grpc:grpc-netty-shaded", + "io.grpc:grpc-protobuf", + "io.grpc:grpc-services", + "io.grpc:grpc-stub", + "net.devh:grpc-common-spring-boot", + "org.springframework.boot:spring-boot-starter" + ], + "net.javacrumbs.shedlock:shedlock-core": [ + "org.slf4j:slf4j-api" + ], + "net.javacrumbs.shedlock:shedlock-provider-cassandra": [ + "net.javacrumbs.shedlock:shedlock-core", + "org.apache.cassandra:java-driver-core", + "org.apache.cassandra:java-driver-query-builder" + ], + "net.javacrumbs.shedlock:shedlock-spring": [ + "net.javacrumbs.shedlock:shedlock-core", + "org.springframework:spring-context" + ], "net.minidev:accessors-smart": [ "org.ow2.asm:asm" ], @@ -3109,6 +3852,15 @@ "org.awaitility:awaitility": [ "org.hamcrest:hamcrest" ], + "org.bitbucket.b_c:jose4j": [ + "org.slf4j:slf4j-api" + ], + "org.bouncycastle:bcpkix-jdk18on": [ + "org.bouncycastle:bcutil-jdk18on" + ], + "org.bouncycastle:bcutil-jdk18on": [ + "org.bouncycastle:bcprov-jdk18on" + ], "org.hibernate.validator:hibernate-validator": [ "com.fasterxml:classmate", "jakarta.validation:jakarta.validation-api", @@ -3130,6 +3882,13 @@ "org.jetbrains.kotlin:kotlin-stdlib": [ "org.jetbrains:annotations" ], + "org.jetbrains.kotlin:kotlin-stdlib-jdk7": [ + "org.jetbrains.kotlin:kotlin-stdlib" + ], + "org.jetbrains.kotlin:kotlin-stdlib-jdk8": [ + "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7" + ], "org.junit.jupiter:junit-jupiter": [ "org.junit.jupiter:junit-jupiter-api", "org.junit.jupiter:junit-jupiter-engine", @@ -3381,6 +4140,11 @@ "org.springframework.boot:spring-boot-starter-micrometer-metrics-test", "org.springframework.boot:spring-boot-starter-test" ], + "org.springframework.boot:spring-boot-starter-aspectj": [ + "org.aspectj:aspectjweaver", + "org.springframework.boot:spring-boot-starter", + "org.springframework:spring-aop" + ], "org.springframework.boot:spring-boot-starter-data-cassandra": [ "org.springframework.boot:spring-boot-cassandra", "org.springframework.boot:spring-boot-data-cassandra", @@ -3575,6 +4339,26 @@ "org.springframework.cloud:spring-cloud-context": [ "org.springframework.security:spring-security-crypto" ], + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig": [ + "io.kubernetes:client-java", + "io.kubernetes:client-java-extended", + "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.cloud:spring-cloud-kubernetes-commons" + ], + "org.springframework.cloud:spring-cloud-kubernetes-client-config": [ + "io.kubernetes:client-java", + "io.kubernetes:client-java-extended", + "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig", + "org.springframework.cloud:spring-cloud-kubernetes-commons", + "org.springframework.cloud:spring-cloud-starter" + ], + "org.springframework.cloud:spring-cloud-kubernetes-commons": [ + "jakarta.annotation:jakarta.annotation-api", + "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-context" + ], "org.springframework.cloud:spring-cloud-starter": [ "org.bouncycastle:bcprov-jdk18on", "org.springframework.boot:spring-boot-starter", @@ -3584,6 +4368,9 @@ "org.springframework.cloud:spring-cloud-starter-bootstrap": [ "org.springframework.cloud:spring-cloud-starter" ], + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config": [ + "org.springframework.cloud:spring-cloud-kubernetes-client-config" + ], "org.springframework.data:spring-data-cassandra": [ "org.apache.cassandra:java-driver-core", "org.apache.cassandra:java-driver-query-builder", @@ -3669,6 +4456,11 @@ "org.springframework:spring-core", "org.springframework:spring-expression" ], + "org.springframework:spring-context-support": [ + "org.springframework:spring-beans", + "org.springframework:spring-context", + "org.springframework:spring-core" + ], "org.springframework:spring-core": [ "commons-logging:commons-logging", "org.jspecify:jspecify" @@ -3719,6 +4511,9 @@ "org.testcontainers:testcontainers-junit-jupiter": [ "org.testcontainers:testcontainers" ], + "org.testcontainers:testcontainers-localstack": [ + "org.testcontainers:testcontainers" + ], "org.xmlunit:xmlunit-core": [ "jakarta.xml.bind:jakarta.xml.bind-api" ], @@ -3905,6 +4700,52 @@ "ch.qos.logback.core.testUtil", "ch.qos.logback.core.util" ], + "com.bucket4j:bucket4j-core": [ + "io.github.bucket4j", + "io.github.bucket4j.distributed", + "io.github.bucket4j.distributed.expiration", + "io.github.bucket4j.distributed.jdbc", + "io.github.bucket4j.distributed.proxy", + "io.github.bucket4j.distributed.proxy.generic.compare_and_swap", + "io.github.bucket4j.distributed.proxy.generic.pessimistic_locking", + "io.github.bucket4j.distributed.proxy.generic.select_for_update", + "io.github.bucket4j.distributed.proxy.optimization", + "io.github.bucket4j.distributed.proxy.optimization.batch", + "io.github.bucket4j.distributed.proxy.optimization.delay", + "io.github.bucket4j.distributed.proxy.optimization.manual", + "io.github.bucket4j.distributed.proxy.optimization.predictive", + "io.github.bucket4j.distributed.proxy.optimization.skiponzero", + "io.github.bucket4j.distributed.remote", + "io.github.bucket4j.distributed.remote.commands", + "io.github.bucket4j.distributed.serialization", + "io.github.bucket4j.distributed.versioning", + "io.github.bucket4j.local", + "io.github.bucket4j.util", + "io.github.bucket4j.util.concurrent.batch" + ], + "com.bucket4j:bucket4j_jdk17-core": [ + "io.github.bucket4j", + "io.github.bucket4j.distributed", + "io.github.bucket4j.distributed.expiration", + "io.github.bucket4j.distributed.jdbc", + "io.github.bucket4j.distributed.proxy", + "io.github.bucket4j.distributed.proxy.generic.compare_and_swap", + "io.github.bucket4j.distributed.proxy.generic.pessimistic_locking", + "io.github.bucket4j.distributed.proxy.generic.select_for_update", + "io.github.bucket4j.distributed.proxy.optimization", + "io.github.bucket4j.distributed.proxy.optimization.batch", + "io.github.bucket4j.distributed.proxy.optimization.delay", + "io.github.bucket4j.distributed.proxy.optimization.manual", + "io.github.bucket4j.distributed.proxy.optimization.predictive", + "io.github.bucket4j.distributed.proxy.optimization.skiponzero", + "io.github.bucket4j.distributed.remote", + "io.github.bucket4j.distributed.remote.commands", + "io.github.bucket4j.distributed.serialization", + "io.github.bucket4j.distributed.versioning", + "io.github.bucket4j.local", + "io.github.bucket4j.util", + "io.github.bucket4j.util.concurrent.batch" + ], "com.datastax.cassandra:cassandra-driver-core": [ "com.datastax.driver.core", "com.datastax.driver.core.exceptions", @@ -4180,11 +5021,37 @@ "com.github.stephenc.jcip:jcip-annotations": [ "net.jcip.annotations" ], + "com.google.android:annotations": [ + "android.annotation" + ], + "com.google.api.grpc:proto-google-common-protos": [ + "com.google.api", + "com.google.cloud", + "com.google.cloud.audit", + "com.google.cloud.location", + "com.google.geo.type", + "com.google.logging.type", + "com.google.longrunning", + "com.google.rpc", + "com.google.rpc.context", + "com.google.type" + ], "com.google.code.findbugs:jsr305": [ "javax.annotation", "javax.annotation.concurrent", "javax.annotation.meta" ], + "com.google.code.gson:gson": [ + "com.google.gson", + "com.google.gson.annotations", + "com.google.gson.internal", + "com.google.gson.internal.bind", + "com.google.gson.internal.bind.util", + "com.google.gson.internal.reflect", + "com.google.gson.internal.sql", + "com.google.gson.reflect", + "com.google.gson.stream" + ], "com.google.errorprone:error_prone_annotations": [ "com.google.errorprone.annotations", "com.google.errorprone.annotations.concurrent" @@ -4215,6 +5082,13 @@ "com.google.j2objc:j2objc-annotations": [ "com.google.j2objc.annotations" ], + "com.google.protobuf:protobuf-java": [ + "com.google.protobuf", + "com.google.protobuf.compiler" + ], + "com.google.protobuf:protobuf-java-util": [ + "com.google.protobuf.util" + ], "com.jayway.jsonpath:json-path": [ "com.jayway.jsonpath", "com.jayway.jsonpath.internal", @@ -4323,6 +5197,28 @@ "com.nimbusds.openid.connect.sdk.validators", "com.nimbusds.secevent.sdk.claims" ], + "com.squareup.okhttp3:logging-interceptor": [ + "okhttp3.logging" + ], + "com.squareup.okhttp3:okhttp": [ + "okhttp3", + "okhttp3.internal", + "okhttp3.internal.authenticator", + "okhttp3.internal.cache", + "okhttp3.internal.cache2", + "okhttp3.internal.concurrent", + "okhttp3.internal.connection", + "okhttp3.internal.http", + "okhttp3.internal.http1", + "okhttp3.internal.http2", + "okhttp3.internal.io", + "okhttp3.internal.platform", + "okhttp3.internal.platform.android", + "okhttp3.internal.proxy", + "okhttp3.internal.publicsuffix", + "okhttp3.internal.tls", + "okhttp3.internal.ws" + ], "com.squareup.okhttp3:okhttp-jvm": [ "okhttp3", "okhttp3.internal", @@ -4413,6 +5309,174 @@ "io.dropwizard.metrics:metrics-core": [ "com.codahale.metrics" ], + "io.grpc:grpc-api": [ + "io.grpc" + ], + "io.grpc:grpc-core": [ + "io.grpc.internal" + ], + "io.grpc:grpc-inprocess": [ + "io.grpc.inprocess" + ], + "io.grpc:grpc-netty-shaded": [ + "io.grpc.netty.shaded.io.grpc.netty", + "io.grpc.netty.shaded.io.netty.bootstrap", + "io.grpc.netty.shaded.io.netty.buffer", + "io.grpc.netty.shaded.io.netty.buffer.search", + "io.grpc.netty.shaded.io.netty.channel", + "io.grpc.netty.shaded.io.netty.channel.embedded", + "io.grpc.netty.shaded.io.netty.channel.epoll", + "io.grpc.netty.shaded.io.netty.channel.group", + "io.grpc.netty.shaded.io.netty.channel.internal", + "io.grpc.netty.shaded.io.netty.channel.local", + "io.grpc.netty.shaded.io.netty.channel.nio", + "io.grpc.netty.shaded.io.netty.channel.oio", + "io.grpc.netty.shaded.io.netty.channel.pool", + "io.grpc.netty.shaded.io.netty.channel.socket", + "io.grpc.netty.shaded.io.netty.channel.socket.nio", + "io.grpc.netty.shaded.io.netty.channel.socket.oio", + "io.grpc.netty.shaded.io.netty.channel.unix", + "io.grpc.netty.shaded.io.netty.handler.address", + "io.grpc.netty.shaded.io.netty.handler.codec", + "io.grpc.netty.shaded.io.netty.handler.codec.base64", + "io.grpc.netty.shaded.io.netty.handler.codec.bytes", + "io.grpc.netty.shaded.io.netty.handler.codec.compression", + "io.grpc.netty.shaded.io.netty.handler.codec.http", + "io.grpc.netty.shaded.io.netty.handler.codec.http.cookie", + "io.grpc.netty.shaded.io.netty.handler.codec.http.cors", + "io.grpc.netty.shaded.io.netty.handler.codec.http.multipart", + "io.grpc.netty.shaded.io.netty.handler.codec.http.websocketx", + "io.grpc.netty.shaded.io.netty.handler.codec.http.websocketx.extensions", + "io.grpc.netty.shaded.io.netty.handler.codec.http.websocketx.extensions.compression", + "io.grpc.netty.shaded.io.netty.handler.codec.http2", + "io.grpc.netty.shaded.io.netty.handler.codec.json", + "io.grpc.netty.shaded.io.netty.handler.codec.marshalling", + "io.grpc.netty.shaded.io.netty.handler.codec.protobuf", + "io.grpc.netty.shaded.io.netty.handler.codec.rtsp", + "io.grpc.netty.shaded.io.netty.handler.codec.serialization", + "io.grpc.netty.shaded.io.netty.handler.codec.socks", + "io.grpc.netty.shaded.io.netty.handler.codec.socksx", + "io.grpc.netty.shaded.io.netty.handler.codec.socksx.v4", + "io.grpc.netty.shaded.io.netty.handler.codec.socksx.v5", + "io.grpc.netty.shaded.io.netty.handler.codec.spdy", + "io.grpc.netty.shaded.io.netty.handler.codec.string", + "io.grpc.netty.shaded.io.netty.handler.codec.xml", + "io.grpc.netty.shaded.io.netty.handler.flow", + "io.grpc.netty.shaded.io.netty.handler.flush", + "io.grpc.netty.shaded.io.netty.handler.ipfilter", + "io.grpc.netty.shaded.io.netty.handler.logging", + "io.grpc.netty.shaded.io.netty.handler.pcap", + "io.grpc.netty.shaded.io.netty.handler.proxy", + "io.grpc.netty.shaded.io.netty.handler.ssl", + "io.grpc.netty.shaded.io.netty.handler.ssl.ocsp", + "io.grpc.netty.shaded.io.netty.handler.ssl.util", + "io.grpc.netty.shaded.io.netty.handler.stream", + "io.grpc.netty.shaded.io.netty.handler.timeout", + "io.grpc.netty.shaded.io.netty.handler.traffic", + "io.grpc.netty.shaded.io.netty.internal.tcnative", + "io.grpc.netty.shaded.io.netty.resolver", + "io.grpc.netty.shaded.io.netty.util", + "io.grpc.netty.shaded.io.netty.util.collection", + "io.grpc.netty.shaded.io.netty.util.concurrent", + "io.grpc.netty.shaded.io.netty.util.internal", + "io.grpc.netty.shaded.io.netty.util.internal.logging", + "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.queues", + "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.queues.atomic", + "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.util", + "io.grpc.netty.shaded.io.netty.util.internal.svm" + ], + "io.grpc:grpc-protobuf": [ + "io.grpc.protobuf" + ], + "io.grpc:grpc-protobuf-lite": [ + "io.grpc.protobuf.lite" + ], + "io.grpc:grpc-services": [ + "io.grpc.binarylog.v1", + "io.grpc.channelz.v1", + "io.grpc.health.v1", + "io.grpc.protobuf.services", + "io.grpc.protobuf.services.internal", + "io.grpc.reflection.v1alpha", + "io.grpc.services" + ], + "io.grpc:grpc-stub": [ + "io.grpc.stub", + "io.grpc.stub.annotations" + ], + "io.grpc:grpc-util": [ + "io.grpc.util" + ], + "io.gsonfire:gson-fire": [ + "io.gsonfire", + "io.gsonfire.annotations", + "io.gsonfire.builders", + "io.gsonfire.gson", + "io.gsonfire.postprocessors", + "io.gsonfire.postprocessors.methodinvoker", + "io.gsonfire.util", + "io.gsonfire.util.reflection" + ], + "io.kubernetes:client-java": [ + "io.kubernetes.client", + "io.kubernetes.client.apimachinery", + "io.kubernetes.client.informer", + "io.kubernetes.client.informer.cache", + "io.kubernetes.client.informer.exception", + "io.kubernetes.client.informer.impl", + "io.kubernetes.client.monitoring", + "io.kubernetes.client.persister", + "io.kubernetes.client.simplified", + "io.kubernetes.client.util", + "io.kubernetes.client.util.annotations", + "io.kubernetes.client.util.authenticators", + "io.kubernetes.client.util.conversion", + "io.kubernetes.client.util.credentials", + "io.kubernetes.client.util.exception", + "io.kubernetes.client.util.generic", + "io.kubernetes.client.util.generic.dynamic", + "io.kubernetes.client.util.generic.options", + "io.kubernetes.client.util.labels", + "io.kubernetes.client.util.okhttp", + "io.kubernetes.client.util.taints", + "io.kubernetes.client.util.version", + "io.kubernetes.client.util.wait" + ], + "io.kubernetes:client-java-api": [ + "io.kubernetes.client.common", + "io.kubernetes.client.custom", + "io.kubernetes.client.gson", + "io.kubernetes.client.openapi", + "io.kubernetes.client.openapi.apis", + "io.kubernetes.client.openapi.auth", + "io.kubernetes.client.openapi.models" + ], + "io.kubernetes:client-java-api-fluent": [ + "io.kubernetes.client.fluent", + "io.kubernetes.client.openapi.models" + ], + "io.kubernetes:client-java-extended": [ + "io.kubernetes.client.extended.controller", + "io.kubernetes.client.extended.controller.builder", + "io.kubernetes.client.extended.controller.reconciler", + "io.kubernetes.client.extended.event", + "io.kubernetes.client.extended.event.legacy", + "io.kubernetes.client.extended.event.v1", + "io.kubernetes.client.extended.kubectl", + "io.kubernetes.client.extended.kubectl.exception", + "io.kubernetes.client.extended.kubectl.util.deployment", + "io.kubernetes.client.extended.leaderelection", + "io.kubernetes.client.extended.leaderelection.resourcelock", + "io.kubernetes.client.extended.network", + "io.kubernetes.client.extended.network.exception", + "io.kubernetes.client.extended.pager", + "io.kubernetes.client.extended.wait", + "io.kubernetes.client.extended.workqueue", + "io.kubernetes.client.extended.workqueue.ratelimiter" + ], + "io.kubernetes:client-java-proto": [ + "io.kubernetes.client.proto" + ], "io.micrometer:context-propagation": [ "io.micrometer.context", "io.micrometer.context.integration" @@ -4489,6 +5553,9 @@ "io.micrometer:micrometer-observation-test": [ "io.micrometer.observation.tck" ], + "io.micrometer:micrometer-registry-prometheus": [ + "io.micrometer.prometheusmetrics" + ], "io.micrometer:micrometer-tracing": [ "io.micrometer.tracing", "io.micrometer.tracing.annotation", @@ -4746,6 +5813,9 @@ "io.opentelemetry.sdk.trace.internal", "io.opentelemetry.sdk.trace.samplers" ], + "io.perfmark:perfmark-api": [ + "io.perfmark" + ], "io.projectreactor.netty:reactor-netty-core": [ "reactor.netty", "reactor.netty.channel", @@ -4794,6 +5864,31 @@ "reactor.test.subscriber", "reactor.test.util" ], + "io.prometheus:prometheus-metrics-config": [ + "io.prometheus.metrics.config" + ], + "io.prometheus:prometheus-metrics-core": [ + "io.prometheus.metrics.core.datapoints", + "io.prometheus.metrics.core.exemplars", + "io.prometheus.metrics.core.metrics", + "io.prometheus.metrics.core.util" + ], + "io.prometheus:prometheus-metrics-exposition-formats": [ + "io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_0", + "io.prometheus.metrics.expositionformats.internal", + "io.prometheus.metrics.shaded.com_google_protobuf_4_33_0", + "io.prometheus.metrics.shaded.com_google_protobuf_4_33_0.compiler" + ], + "io.prometheus:prometheus-metrics-exposition-textformats": [ + "io.prometheus.metrics.expositionformats" + ], + "io.prometheus:prometheus-metrics-model": [ + "io.prometheus.metrics.model.registry", + "io.prometheus.metrics.model.snapshots" + ], + "io.prometheus:prometheus-metrics-tracer-common": [ + "io.prometheus.metrics.tracer.common" + ], "io.swagger.core.v3:swagger-annotations-jakarta": [ "io.swagger.v3.oas.annotations", "io.swagger.v3.oas.annotations.callbacks", @@ -4832,6 +5927,9 @@ "io.swagger.v3.oas.models.servers", "io.swagger.v3.oas.models.tags" ], + "io.swagger:swagger-annotations": [ + "io.swagger.annotations" + ], "jakarta.activation:jakarta.activation-api": [ "jakarta.activation", "jakarta.activation.spi" @@ -4866,6 +5964,11 @@ "jakarta.xml.bind.helpers", "jakarta.xml.bind.util" ], + "javax.annotation:javax.annotation-api": [ + "javax.annotation", + "javax.annotation.security", + "javax.annotation.sql" + ], "net.bytebuddy:byte-buddy": [ "net.bytebuddy", "net.bytebuddy.agent.builder", @@ -4911,12 +6014,48 @@ "net.bytebuddy.agent", "net.bytebuddy.agent.utility.nullability" ], + "net.devh:grpc-common-spring-boot": [ + "net.devh.boot.grpc.common.autoconfigure", + "net.devh.boot.grpc.common.codec", + "net.devh.boot.grpc.common.security", + "net.devh.boot.grpc.common.util" + ], + "net.devh:grpc-server-spring-boot-starter": [ + "net.devh.boot.grpc.server.advice", + "net.devh.boot.grpc.server.autoconfigure", + "net.devh.boot.grpc.server.condition", + "net.devh.boot.grpc.server.config", + "net.devh.boot.grpc.server.error", + "net.devh.boot.grpc.server.event", + "net.devh.boot.grpc.server.interceptor", + "net.devh.boot.grpc.server.metrics", + "net.devh.boot.grpc.server.nameresolver", + "net.devh.boot.grpc.server.scope", + "net.devh.boot.grpc.server.security.authentication", + "net.devh.boot.grpc.server.security.check", + "net.devh.boot.grpc.server.security.interceptors", + "net.devh.boot.grpc.server.serverfactory", + "net.devh.boot.grpc.server.service" + ], "net.java.dev.jna:jna": [ "com.sun.jna", "com.sun.jna.internal", "com.sun.jna.ptr", "com.sun.jna.win32" ], + "net.javacrumbs.shedlock:shedlock-core": [ + "net.javacrumbs.shedlock.core", + "net.javacrumbs.shedlock.support", + "net.javacrumbs.shedlock.util" + ], + "net.javacrumbs.shedlock:shedlock-provider-cassandra": [ + "net.javacrumbs.shedlock.provider.cassandra" + ], + "net.javacrumbs.shedlock:shedlock-spring": [ + "net.javacrumbs.shedlock.spring", + "net.javacrumbs.shedlock.spring.annotation", + "net.javacrumbs.shedlock.spring.aop" + ], "net.minidev:accessors-smart": [ "net.minidev.asm", "net.minidev.asm.ex" @@ -5103,6 +6242,28 @@ "com.datastax.oss.driver.internal.querybuilder.truncate", "com.datastax.oss.driver.internal.querybuilder.update" ], + "org.apache.commons:commons-collections4": [ + "org.apache.commons.collections4", + "org.apache.commons.collections4.bag", + "org.apache.commons.collections4.bidimap", + "org.apache.commons.collections4.bloomfilter", + "org.apache.commons.collections4.collection", + "org.apache.commons.collections4.comparators", + "org.apache.commons.collections4.functors", + "org.apache.commons.collections4.iterators", + "org.apache.commons.collections4.keyvalue", + "org.apache.commons.collections4.list", + "org.apache.commons.collections4.map", + "org.apache.commons.collections4.multimap", + "org.apache.commons.collections4.multiset", + "org.apache.commons.collections4.properties", + "org.apache.commons.collections4.queue", + "org.apache.commons.collections4.sequence", + "org.apache.commons.collections4.set", + "org.apache.commons.collections4.splitmap", + "org.apache.commons.collections4.trie", + "org.apache.commons.collections4.trie.analyzer" + ], "org.apache.commons:commons-compress": [ "org.apache.commons.compress", "org.apache.commons.compress.archivers", @@ -5274,6 +6435,45 @@ "org.apiguardian:apiguardian-api": [ "org.apiguardian.api" ], + "org.aspectj:aspectjweaver": [ + "aj.org.objectweb.asm", + "aj.org.objectweb.asm.commons", + "aj.org.objectweb.asm.signature", + "org.aspectj.apache.bcel", + "org.aspectj.apache.bcel.classfile", + "org.aspectj.apache.bcel.classfile.annotation", + "org.aspectj.apache.bcel.generic", + "org.aspectj.apache.bcel.util", + "org.aspectj.asm", + "org.aspectj.asm.internal", + "org.aspectj.bridge", + "org.aspectj.bridge.context", + "org.aspectj.internal.lang.annotation", + "org.aspectj.internal.lang.reflect", + "org.aspectj.lang", + "org.aspectj.lang.annotation", + "org.aspectj.lang.annotation.control", + "org.aspectj.lang.internal.lang", + "org.aspectj.lang.reflect", + "org.aspectj.runtime", + "org.aspectj.runtime.internal", + "org.aspectj.runtime.internal.cflowstack", + "org.aspectj.runtime.reflect", + "org.aspectj.util", + "org.aspectj.weaver", + "org.aspectj.weaver.ast", + "org.aspectj.weaver.bcel", + "org.aspectj.weaver.bcel.asm", + "org.aspectj.weaver.internal.tools", + "org.aspectj.weaver.loadtime", + "org.aspectj.weaver.loadtime.definition", + "org.aspectj.weaver.ltw", + "org.aspectj.weaver.model", + "org.aspectj.weaver.patterns", + "org.aspectj.weaver.reflect", + "org.aspectj.weaver.tools", + "org.aspectj.weaver.tools.cache" + ], "org.assertj:assertj-core": [ "org.assertj.core.annotation", "org.assertj.core.annotations", @@ -5315,6 +6515,82 @@ "org.awaitility.reflect.exception", "org.awaitility.spi" ], + "org.bitbucket.b_c:jose4j": [ + "org.jose4j.base64url", + "org.jose4j.base64url.internal.apache.commons.codec.binary", + "org.jose4j.http", + "org.jose4j.jca", + "org.jose4j.json", + "org.jose4j.json.internal.json_simple", + "org.jose4j.json.internal.json_simple.parser", + "org.jose4j.jwa", + "org.jose4j.jwe", + "org.jose4j.jwe.kdf", + "org.jose4j.jwk", + "org.jose4j.jws", + "org.jose4j.jwt", + "org.jose4j.jwt.consumer", + "org.jose4j.jwx", + "org.jose4j.keys", + "org.jose4j.keys.resolvers", + "org.jose4j.lang", + "org.jose4j.mac", + "org.jose4j.zip" + ], + "org.bouncycastle:bcpkix-jdk18on": [ + "org.bouncycastle.cert", + "org.bouncycastle.cert.bc", + "org.bouncycastle.cert.cmp", + "org.bouncycastle.cert.crmf", + "org.bouncycastle.cert.crmf.bc", + "org.bouncycastle.cert.crmf.jcajce", + "org.bouncycastle.cert.dane", + "org.bouncycastle.cert.dane.fetcher", + "org.bouncycastle.cert.jcajce", + "org.bouncycastle.cert.ocsp", + "org.bouncycastle.cert.ocsp.jcajce", + "org.bouncycastle.cert.path", + "org.bouncycastle.cert.path.validations", + "org.bouncycastle.cert.selector", + "org.bouncycastle.cert.selector.jcajce", + "org.bouncycastle.cmc", + "org.bouncycastle.cms", + "org.bouncycastle.cms.bc", + "org.bouncycastle.cms.jcajce", + "org.bouncycastle.dvcs", + "org.bouncycastle.eac", + "org.bouncycastle.eac.jcajce", + "org.bouncycastle.eac.operator", + "org.bouncycastle.eac.operator.jcajce", + "org.bouncycastle.est", + "org.bouncycastle.est.jcajce", + "org.bouncycastle.its", + "org.bouncycastle.its.bc", + "org.bouncycastle.its.jcajce", + "org.bouncycastle.its.operator", + "org.bouncycastle.mime", + "org.bouncycastle.mime.encoding", + "org.bouncycastle.mime.smime", + "org.bouncycastle.mozilla", + "org.bouncycastle.mozilla.jcajce", + "org.bouncycastle.openssl", + "org.bouncycastle.openssl.bc", + "org.bouncycastle.openssl.jcajce", + "org.bouncycastle.operator", + "org.bouncycastle.operator.bc", + "org.bouncycastle.operator.jcajce", + "org.bouncycastle.pkcs", + "org.bouncycastle.pkcs.bc", + "org.bouncycastle.pkcs.jcajce", + "org.bouncycastle.pkix", + "org.bouncycastle.pkix.jcajce", + "org.bouncycastle.pkix.util", + "org.bouncycastle.pkix.util.filter", + "org.bouncycastle.tsp", + "org.bouncycastle.tsp.cms", + "org.bouncycastle.tsp.ers", + "org.bouncycastle.voms" + ], "org.bouncycastle:bcprov-jdk18on": [ "org.bouncycastle", "org.bouncycastle.asn1", @@ -5657,6 +6933,58 @@ "org.bouncycastle.util.io.pem", "org.bouncycastle.util.test" ], + "org.bouncycastle:bcutil-jdk18on": [ + "org.bouncycastle.asn1.bsi", + "org.bouncycastle.asn1.cmc", + "org.bouncycastle.asn1.cmp", + "org.bouncycastle.asn1.cms", + "org.bouncycastle.asn1.cms.ecc", + "org.bouncycastle.asn1.crmf", + "org.bouncycastle.asn1.cryptlib", + "org.bouncycastle.asn1.dvcs", + "org.bouncycastle.asn1.eac", + "org.bouncycastle.asn1.edec", + "org.bouncycastle.asn1.esf", + "org.bouncycastle.asn1.ess", + "org.bouncycastle.asn1.est", + "org.bouncycastle.asn1.gnu", + "org.bouncycastle.asn1.iana", + "org.bouncycastle.asn1.icao", + "org.bouncycastle.asn1.isara", + "org.bouncycastle.asn1.isismtt", + "org.bouncycastle.asn1.isismtt.ocsp", + "org.bouncycastle.asn1.isismtt.x509", + "org.bouncycastle.asn1.iso", + "org.bouncycastle.asn1.kisa", + "org.bouncycastle.asn1.microsoft", + "org.bouncycastle.asn1.misc", + "org.bouncycastle.asn1.mozilla", + "org.bouncycastle.asn1.nsri", + "org.bouncycastle.asn1.ntt", + "org.bouncycastle.asn1.oiw", + "org.bouncycastle.asn1.rosstandart", + "org.bouncycastle.asn1.smime", + "org.bouncycastle.asn1.tsp", + "org.bouncycastle.oer", + "org.bouncycastle.oer.its", + "org.bouncycastle.oer.its.etsi102941", + "org.bouncycastle.oer.its.etsi102941.basetypes", + "org.bouncycastle.oer.its.etsi103097", + "org.bouncycastle.oer.its.etsi103097.extension", + "org.bouncycastle.oer.its.ieee1609dot2", + "org.bouncycastle.oer.its.ieee1609dot2.basetypes", + "org.bouncycastle.oer.its.ieee1609dot2dot1", + "org.bouncycastle.oer.its.template.etsi102941", + "org.bouncycastle.oer.its.template.etsi102941.basetypes", + "org.bouncycastle.oer.its.template.etsi103097", + "org.bouncycastle.oer.its.template.etsi103097.extension", + "org.bouncycastle.oer.its.template.ieee1609dot2", + "org.bouncycastle.oer.its.template.ieee1609dot2.basetypes", + "org.bouncycastle.oer.its.template.ieee1609dot2dot1" + ], + "org.codehaus.mojo:animal-sniffer-annotations": [ + "org.codehaus.mojo.animal_sniffer" + ], "org.hamcrest:hamcrest": [ "org.hamcrest", "org.hamcrest.beans", @@ -6440,6 +7768,19 @@ "org.springframework.boot.jackson", "org.springframework.boot.jackson.autoconfigure" ], + "org.springframework.boot:spring-boot-loader": [ + "org.springframework.boot.loader.jar", + "org.springframework.boot.loader.jarmode", + "org.springframework.boot.loader.launch", + "org.springframework.boot.loader.log", + "org.springframework.boot.loader.net.protocol", + "org.springframework.boot.loader.net.protocol.jar", + "org.springframework.boot.loader.net.protocol.nested", + "org.springframework.boot.loader.net.util", + "org.springframework.boot.loader.nio.file", + "org.springframework.boot.loader.ref", + "org.springframework.boot.loader.zip" + ], "org.springframework.boot:spring-boot-micrometer-metrics": [ "org.springframework.boot.micrometer.metrics", "org.springframework.boot.micrometer.metrics.actuate.endpoint", @@ -6698,6 +8039,28 @@ "org.springframework.cloud.util", "org.springframework.cloud.util.random" ], + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig": [ + "org.springframework.cloud.kubernetes.client" + ], + "org.springframework.cloud:spring-cloud-kubernetes-client-config": [ + "org.springframework.cloud.kubernetes.client.config", + "org.springframework.cloud.kubernetes.client.config.reload" + ], + "org.springframework.cloud:spring-cloud-kubernetes-commons": [ + "org.springframework.cloud.kubernetes.commons", + "org.springframework.cloud.kubernetes.commons.autoconfig", + "org.springframework.cloud.kubernetes.commons.config", + "org.springframework.cloud.kubernetes.commons.config.reload", + "org.springframework.cloud.kubernetes.commons.config.reload.condition", + "org.springframework.cloud.kubernetes.commons.configdata", + "org.springframework.cloud.kubernetes.commons.discovery", + "org.springframework.cloud.kubernetes.commons.discovery.conditionals", + "org.springframework.cloud.kubernetes.commons.leader", + "org.springframework.cloud.kubernetes.commons.leader.election", + "org.springframework.cloud.kubernetes.commons.leader.election.events", + "org.springframework.cloud.kubernetes.commons.loadbalancer", + "org.springframework.cloud.kubernetes.commons.profile" + ], "org.springframework.cloud:spring-cloud-starter-bootstrap": [ "org.springframework.cloud.bootstrap.marker" ], @@ -6777,6 +8140,20 @@ "org.springframework.data.web.config", "org.springframework.data.web.querydsl" ], + "org.springframework.retry:spring-retry": [ + "org.springframework.classify", + "org.springframework.classify.annotation", + "org.springframework.classify.util", + "org.springframework.retry", + "org.springframework.retry.annotation", + "org.springframework.retry.backoff", + "org.springframework.retry.context", + "org.springframework.retry.interceptor", + "org.springframework.retry.listener", + "org.springframework.retry.policy", + "org.springframework.retry.stats", + "org.springframework.retry.support" + ], "org.springframework.security:spring-security-config": [ "org.springframework.security.config", "org.springframework.security.config.annotation", @@ -7108,6 +8485,17 @@ "org.springframework.validation.method", "org.springframework.validation.support" ], + "org.springframework:spring-context-support": [ + "org.springframework.cache.caffeine", + "org.springframework.cache.jcache", + "org.springframework.cache.jcache.config", + "org.springframework.cache.jcache.interceptor", + "org.springframework.cache.transaction", + "org.springframework.mail", + "org.springframework.mail.javamail", + "org.springframework.scheduling.quartz", + "org.springframework.ui.freemarker" + ], "org.springframework:spring-core": [ "org.springframework.aot", "org.springframework.aot.generate", @@ -7830,6 +9218,10 @@ "org.testcontainers:testcontainers-junit-jupiter": [ "org.testcontainers.junit.jupiter" ], + "org.testcontainers:testcontainers-localstack": [ + "org.testcontainers.containers.localstack", + "org.testcontainers.localstack" + ], "org.wiremock:wiremock-standalone": [ "com.github.tomakehurst.wiremock", "com.github.tomakehurst.wiremock.admin", @@ -8507,6 +9899,10 @@ "ch.qos.logback:logback-classic:jar:sources", "ch.qos.logback:logback-core", "ch.qos.logback:logback-core:jar:sources", + "com.bucket4j:bucket4j-core", + "com.bucket4j:bucket4j-core:jar:sources", + "com.bucket4j:bucket4j_jdk17-core", + "com.bucket4j:bucket4j_jdk17-core:jar:sources", "com.datastax.cassandra:cassandra-driver-core", "com.datastax.cassandra:cassandra-driver-core:jar:sources", "com.datastax.oss:native-protocol", @@ -8554,7 +9950,14 @@ "com.github.jnr:jnr-x86asm:jar:sources", "com.github.stephenc.jcip:jcip-annotations", "com.github.stephenc.jcip:jcip-annotations:jar:sources", + "com.google.android:annotations", + "com.google.android:annotations:jar:sources", + "com.google.api.grpc:proto-google-common-protos", + "com.google.api.grpc:proto-google-common-protos:jar:sources", "com.google.code.findbugs:jsr305", + "com.google.code.findbugs:jsr305:jar:sources", + "com.google.code.gson:gson", + "com.google.code.gson:gson:jar:sources", "com.google.errorprone:error_prone_annotations", "com.google.errorprone:error_prone_annotations:jar:sources", "com.google.guava:failureaccess", @@ -8564,6 +9967,10 @@ "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", "com.google.j2objc:j2objc-annotations:jar:sources", + "com.google.protobuf:protobuf-java", + "com.google.protobuf:protobuf-java-util", + "com.google.protobuf:protobuf-java-util:jar:sources", + "com.google.protobuf:protobuf-java:jar:sources", "com.jayway.jsonpath:json-path", "com.jayway.jsonpath:json-path:jar:sources", "com.nimbusds:content-type", @@ -8574,10 +9981,16 @@ "com.nimbusds:nimbus-jose-jwt:jar:sources", "com.nimbusds:oauth2-oidc-sdk", "com.nimbusds:oauth2-oidc-sdk:jar:sources", + "com.squareup.okhttp3:logging-interceptor", + "com.squareup.okhttp3:logging-interceptor:jar:sources", + "com.squareup.okhttp3:okhttp", "com.squareup.okhttp3:okhttp-jvm", "com.squareup.okhttp3:okhttp-jvm:jar:sources", + "com.squareup.okhttp3:okhttp:jar:sources", + "com.squareup.okio:okio", "com.squareup.okio:okio-jvm", "com.squareup.okio:okio-jvm:jar:sources", + "com.squareup.okio:okio:jar:sources", "com.typesafe:config", "com.typesafe:config:jar:sources", "com.vaadin.external.google:android-json", @@ -8596,6 +10009,38 @@ "io.cloudevents:cloudevents-json-jackson:jar:sources", "io.dropwizard.metrics:metrics-core", "io.dropwizard.metrics:metrics-core:jar:sources", + "io.grpc:grpc-api", + "io.grpc:grpc-api:jar:sources", + "io.grpc:grpc-context", + "io.grpc:grpc-context:jar:sources", + "io.grpc:grpc-core", + "io.grpc:grpc-core:jar:sources", + "io.grpc:grpc-inprocess", + "io.grpc:grpc-inprocess:jar:sources", + "io.grpc:grpc-netty-shaded", + "io.grpc:grpc-netty-shaded:jar:sources", + "io.grpc:grpc-protobuf", + "io.grpc:grpc-protobuf-lite", + "io.grpc:grpc-protobuf-lite:jar:sources", + "io.grpc:grpc-protobuf:jar:sources", + "io.grpc:grpc-services", + "io.grpc:grpc-services:jar:sources", + "io.grpc:grpc-stub", + "io.grpc:grpc-stub:jar:sources", + "io.grpc:grpc-util", + "io.grpc:grpc-util:jar:sources", + "io.gsonfire:gson-fire", + "io.gsonfire:gson-fire:jar:sources", + "io.kubernetes:client-java", + "io.kubernetes:client-java-api", + "io.kubernetes:client-java-api-fluent", + "io.kubernetes:client-java-api-fluent:jar:sources", + "io.kubernetes:client-java-api:jar:sources", + "io.kubernetes:client-java-extended", + "io.kubernetes:client-java-extended:jar:sources", + "io.kubernetes:client-java-proto", + "io.kubernetes:client-java-proto:jar:sources", + "io.kubernetes:client-java:jar:sources", "io.micrometer:context-propagation", "io.micrometer:context-propagation:jar:sources", "io.micrometer:micrometer-commons", @@ -8608,6 +10053,8 @@ "io.micrometer:micrometer-observation-test", "io.micrometer:micrometer-observation-test:jar:sources", "io.micrometer:micrometer-observation:jar:sources", + "io.micrometer:micrometer-registry-prometheus", + "io.micrometer:micrometer-registry-prometheus:jar:sources", "io.micrometer:micrometer-tracing", "io.micrometer:micrometer-tracing-bridge-otel", "io.micrometer:micrometer-tracing-bridge-otel:jar:sources", @@ -8692,6 +10139,8 @@ "io.opentelemetry:opentelemetry-sdk-trace", "io.opentelemetry:opentelemetry-sdk-trace:jar:sources", "io.opentelemetry:opentelemetry-sdk:jar:sources", + "io.perfmark:perfmark-api", + "io.perfmark:perfmark-api:jar:sources", "io.projectreactor.netty:reactor-netty-core", "io.projectreactor.netty:reactor-netty-core:jar:sources", "io.projectreactor.netty:reactor-netty-http", @@ -8700,12 +10149,26 @@ "io.projectreactor:reactor-core:jar:sources", "io.projectreactor:reactor-test", "io.projectreactor:reactor-test:jar:sources", + "io.prometheus:prometheus-metrics-config", + "io.prometheus:prometheus-metrics-config:jar:sources", + "io.prometheus:prometheus-metrics-core", + "io.prometheus:prometheus-metrics-core:jar:sources", + "io.prometheus:prometheus-metrics-exposition-formats", + "io.prometheus:prometheus-metrics-exposition-formats:jar:sources", + "io.prometheus:prometheus-metrics-exposition-textformats", + "io.prometheus:prometheus-metrics-exposition-textformats:jar:sources", + "io.prometheus:prometheus-metrics-model", + "io.prometheus:prometheus-metrics-model:jar:sources", + "io.prometheus:prometheus-metrics-tracer-common", + "io.prometheus:prometheus-metrics-tracer-common:jar:sources", "io.swagger.core.v3:swagger-annotations-jakarta", "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources", "io.swagger.core.v3:swagger-core-jakarta", "io.swagger.core.v3:swagger-core-jakarta:jar:sources", "io.swagger.core.v3:swagger-models-jakarta", "io.swagger.core.v3:swagger-models-jakarta:jar:sources", + "io.swagger:swagger-annotations", + "io.swagger:swagger-annotations:jar:sources", "jakarta.activation:jakarta.activation-api", "jakarta.activation:jakarta.activation-api:jar:sources", "jakarta.annotation:jakarta.annotation-api", @@ -8716,12 +10179,24 @@ "jakarta.validation:jakarta.validation-api:jar:sources", "jakarta.xml.bind:jakarta.xml.bind-api", "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources", + "javax.annotation:javax.annotation-api", + "javax.annotation:javax.annotation-api:jar:sources", "net.bytebuddy:byte-buddy", "net.bytebuddy:byte-buddy-agent", "net.bytebuddy:byte-buddy-agent:jar:sources", "net.bytebuddy:byte-buddy:jar:sources", + "net.devh:grpc-common-spring-boot", + "net.devh:grpc-common-spring-boot:jar:sources", + "net.devh:grpc-server-spring-boot-starter", + "net.devh:grpc-server-spring-boot-starter:jar:sources", "net.java.dev.jna:jna", "net.java.dev.jna:jna:jar:sources", + "net.javacrumbs.shedlock:shedlock-core", + "net.javacrumbs.shedlock:shedlock-core:jar:sources", + "net.javacrumbs.shedlock:shedlock-provider-cassandra", + "net.javacrumbs.shedlock:shedlock-provider-cassandra:jar:sources", + "net.javacrumbs.shedlock:shedlock-spring", + "net.javacrumbs.shedlock:shedlock-spring:jar:sources", "net.minidev:accessors-smart", "net.minidev:accessors-smart:jar:sources", "net.minidev:json-smart", @@ -8734,6 +10209,8 @@ "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources", "org.apache.cassandra:java-driver-query-builder", "org.apache.cassandra:java-driver-query-builder:jar:sources", + "org.apache.commons:commons-collections4", + "org.apache.commons:commons-collections4:jar:sources", "org.apache.commons:commons-compress", "org.apache.commons:commons-compress:jar:sources", "org.apache.commons:commons-lang3", @@ -8750,14 +10227,24 @@ "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources", "org.apiguardian:apiguardian-api", "org.apiguardian:apiguardian-api:jar:sources", + "org.aspectj:aspectjweaver", + "org.aspectj:aspectjweaver:jar:sources", "org.assertj:assertj-core", "org.assertj:assertj-core:jar:sources", "org.awaitility:awaitility", "org.awaitility:awaitility:jar:sources", + "org.bitbucket.b_c:jose4j", + "org.bitbucket.b_c:jose4j:jar:sources", + "org.bouncycastle:bcpkix-jdk18on", + "org.bouncycastle:bcpkix-jdk18on:jar:sources", "org.bouncycastle:bcprov-jdk18on", "org.bouncycastle:bcprov-jdk18on:jar:sources", "org.bouncycastle:bcprov-lts8on", "org.bouncycastle:bcprov-lts8on:jar:sources", + "org.bouncycastle:bcutil-jdk18on", + "org.bouncycastle:bcutil-jdk18on:jar:sources", + "org.codehaus.mojo:animal-sniffer-annotations", + "org.codehaus.mojo:animal-sniffer-annotations:jar:sources", "org.hamcrest:hamcrest", "org.hamcrest:hamcrest:jar:sources", "org.hdrhistogram:HdrHistogram", @@ -8775,6 +10262,10 @@ "org.jboss.logging:jboss-logging", "org.jboss.logging:jboss-logging:jar:sources", "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:sources", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:sources", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources", "org.jetbrains:annotations", "org.jetbrains:annotations:jar:sources", @@ -8863,6 +10354,8 @@ "org.springframework.boot:spring-boot-http-converter:jar:sources", "org.springframework.boot:spring-boot-jackson", "org.springframework.boot:spring-boot-jackson:jar:sources", + "org.springframework.boot:spring-boot-loader", + "org.springframework.boot:spring-boot-loader:jar:sources", "org.springframework.boot:spring-boot-micrometer-metrics", "org.springframework.boot:spring-boot-micrometer-metrics-test", "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources", @@ -8902,6 +10395,8 @@ "org.springframework.boot:spring-boot-starter-actuator-test", "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources", "org.springframework.boot:spring-boot-starter-actuator:jar:sources", + "org.springframework.boot:spring-boot-starter-aspectj", + "org.springframework.boot:spring-boot-starter-aspectj:jar:sources", "org.springframework.boot:spring-boot-starter-data-cassandra", "org.springframework.boot:spring-boot-starter-data-cassandra-test", "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources", @@ -8974,13 +10469,22 @@ "org.springframework.cloud:spring-cloud-commons:jar:sources", "org.springframework.cloud:spring-cloud-context", "org.springframework.cloud:spring-cloud-context:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig", + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-client-config", + "org.springframework.cloud:spring-cloud-kubernetes-client-config:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-commons", + "org.springframework.cloud:spring-cloud-kubernetes-commons:jar:sources", "org.springframework.cloud:spring-cloud-starter", "org.springframework.cloud:spring-cloud-starter-bootstrap", "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources", + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config", "org.springframework.data:spring-data-cassandra", "org.springframework.data:spring-data-cassandra:jar:sources", "org.springframework.data:spring-data-commons", "org.springframework.data:spring-data-commons:jar:sources", + "org.springframework.retry:spring-retry", + "org.springframework.retry:spring-retry:jar:sources", "org.springframework.security:spring-security-config", "org.springframework.security:spring-security-config:jar:sources", "org.springframework.security:spring-security-core", @@ -9004,6 +10508,8 @@ "org.springframework:spring-beans", "org.springframework:spring-beans:jar:sources", "org.springframework:spring-context", + "org.springframework:spring-context-support", + "org.springframework:spring-context-support:jar:sources", "org.springframework:spring-context:jar:sources", "org.springframework:spring-core", "org.springframework:spring-core:jar:sources", @@ -9026,6 +10532,8 @@ "org.testcontainers:testcontainers-database-commons:jar:sources", "org.testcontainers:testcontainers-junit-jupiter", "org.testcontainers:testcontainers-junit-jupiter:jar:sources", + "org.testcontainers:testcontainers-localstack", + "org.testcontainers:testcontainers-localstack:jar:sources", "org.testcontainers:testcontainers:jar:sources", "org.wiremock:wiremock-standalone", "org.wiremock:wiremock-standalone:jar:sources", @@ -9085,6 +10593,10 @@ "ch.qos.logback:logback-classic:jar:sources", "ch.qos.logback:logback-core", "ch.qos.logback:logback-core:jar:sources", + "com.bucket4j:bucket4j-core", + "com.bucket4j:bucket4j-core:jar:sources", + "com.bucket4j:bucket4j_jdk17-core", + "com.bucket4j:bucket4j_jdk17-core:jar:sources", "com.datastax.cassandra:cassandra-driver-core", "com.datastax.cassandra:cassandra-driver-core:jar:sources", "com.datastax.oss:native-protocol", @@ -9132,7 +10644,14 @@ "com.github.jnr:jnr-x86asm:jar:sources", "com.github.stephenc.jcip:jcip-annotations", "com.github.stephenc.jcip:jcip-annotations:jar:sources", + "com.google.android:annotations", + "com.google.android:annotations:jar:sources", + "com.google.api.grpc:proto-google-common-protos", + "com.google.api.grpc:proto-google-common-protos:jar:sources", "com.google.code.findbugs:jsr305", + "com.google.code.findbugs:jsr305:jar:sources", + "com.google.code.gson:gson", + "com.google.code.gson:gson:jar:sources", "com.google.errorprone:error_prone_annotations", "com.google.errorprone:error_prone_annotations:jar:sources", "com.google.guava:failureaccess", @@ -9142,6 +10661,10 @@ "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", "com.google.j2objc:j2objc-annotations:jar:sources", + "com.google.protobuf:protobuf-java", + "com.google.protobuf:protobuf-java-util", + "com.google.protobuf:protobuf-java-util:jar:sources", + "com.google.protobuf:protobuf-java:jar:sources", "com.jayway.jsonpath:json-path", "com.jayway.jsonpath:json-path:jar:sources", "com.nimbusds:content-type", @@ -9152,10 +10675,16 @@ "com.nimbusds:nimbus-jose-jwt:jar:sources", "com.nimbusds:oauth2-oidc-sdk", "com.nimbusds:oauth2-oidc-sdk:jar:sources", + "com.squareup.okhttp3:logging-interceptor", + "com.squareup.okhttp3:logging-interceptor:jar:sources", + "com.squareup.okhttp3:okhttp", "com.squareup.okhttp3:okhttp-jvm", "com.squareup.okhttp3:okhttp-jvm:jar:sources", + "com.squareup.okhttp3:okhttp:jar:sources", + "com.squareup.okio:okio", "com.squareup.okio:okio-jvm", "com.squareup.okio:okio-jvm:jar:sources", + "com.squareup.okio:okio:jar:sources", "com.typesafe:config", "com.typesafe:config:jar:sources", "com.vaadin.external.google:android-json", @@ -9174,6 +10703,38 @@ "io.cloudevents:cloudevents-json-jackson:jar:sources", "io.dropwizard.metrics:metrics-core", "io.dropwizard.metrics:metrics-core:jar:sources", + "io.grpc:grpc-api", + "io.grpc:grpc-api:jar:sources", + "io.grpc:grpc-context", + "io.grpc:grpc-context:jar:sources", + "io.grpc:grpc-core", + "io.grpc:grpc-core:jar:sources", + "io.grpc:grpc-inprocess", + "io.grpc:grpc-inprocess:jar:sources", + "io.grpc:grpc-netty-shaded", + "io.grpc:grpc-netty-shaded:jar:sources", + "io.grpc:grpc-protobuf", + "io.grpc:grpc-protobuf-lite", + "io.grpc:grpc-protobuf-lite:jar:sources", + "io.grpc:grpc-protobuf:jar:sources", + "io.grpc:grpc-services", + "io.grpc:grpc-services:jar:sources", + "io.grpc:grpc-stub", + "io.grpc:grpc-stub:jar:sources", + "io.grpc:grpc-util", + "io.grpc:grpc-util:jar:sources", + "io.gsonfire:gson-fire", + "io.gsonfire:gson-fire:jar:sources", + "io.kubernetes:client-java", + "io.kubernetes:client-java-api", + "io.kubernetes:client-java-api-fluent", + "io.kubernetes:client-java-api-fluent:jar:sources", + "io.kubernetes:client-java-api:jar:sources", + "io.kubernetes:client-java-extended", + "io.kubernetes:client-java-extended:jar:sources", + "io.kubernetes:client-java-proto", + "io.kubernetes:client-java-proto:jar:sources", + "io.kubernetes:client-java:jar:sources", "io.micrometer:context-propagation", "io.micrometer:context-propagation:jar:sources", "io.micrometer:micrometer-commons", @@ -9186,6 +10747,8 @@ "io.micrometer:micrometer-observation-test", "io.micrometer:micrometer-observation-test:jar:sources", "io.micrometer:micrometer-observation:jar:sources", + "io.micrometer:micrometer-registry-prometheus", + "io.micrometer:micrometer-registry-prometheus:jar:sources", "io.micrometer:micrometer-tracing", "io.micrometer:micrometer-tracing-bridge-otel", "io.micrometer:micrometer-tracing-bridge-otel:jar:sources", @@ -9270,6 +10833,8 @@ "io.opentelemetry:opentelemetry-sdk-trace", "io.opentelemetry:opentelemetry-sdk-trace:jar:sources", "io.opentelemetry:opentelemetry-sdk:jar:sources", + "io.perfmark:perfmark-api", + "io.perfmark:perfmark-api:jar:sources", "io.projectreactor.netty:reactor-netty-core", "io.projectreactor.netty:reactor-netty-core:jar:sources", "io.projectreactor.netty:reactor-netty-http", @@ -9278,12 +10843,26 @@ "io.projectreactor:reactor-core:jar:sources", "io.projectreactor:reactor-test", "io.projectreactor:reactor-test:jar:sources", + "io.prometheus:prometheus-metrics-config", + "io.prometheus:prometheus-metrics-config:jar:sources", + "io.prometheus:prometheus-metrics-core", + "io.prometheus:prometheus-metrics-core:jar:sources", + "io.prometheus:prometheus-metrics-exposition-formats", + "io.prometheus:prometheus-metrics-exposition-formats:jar:sources", + "io.prometheus:prometheus-metrics-exposition-textformats", + "io.prometheus:prometheus-metrics-exposition-textformats:jar:sources", + "io.prometheus:prometheus-metrics-model", + "io.prometheus:prometheus-metrics-model:jar:sources", + "io.prometheus:prometheus-metrics-tracer-common", + "io.prometheus:prometheus-metrics-tracer-common:jar:sources", "io.swagger.core.v3:swagger-annotations-jakarta", "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources", "io.swagger.core.v3:swagger-core-jakarta", "io.swagger.core.v3:swagger-core-jakarta:jar:sources", "io.swagger.core.v3:swagger-models-jakarta", "io.swagger.core.v3:swagger-models-jakarta:jar:sources", + "io.swagger:swagger-annotations", + "io.swagger:swagger-annotations:jar:sources", "jakarta.activation:jakarta.activation-api", "jakarta.activation:jakarta.activation-api:jar:sources", "jakarta.annotation:jakarta.annotation-api", @@ -9294,12 +10873,24 @@ "jakarta.validation:jakarta.validation-api:jar:sources", "jakarta.xml.bind:jakarta.xml.bind-api", "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources", + "javax.annotation:javax.annotation-api", + "javax.annotation:javax.annotation-api:jar:sources", "net.bytebuddy:byte-buddy", "net.bytebuddy:byte-buddy-agent", "net.bytebuddy:byte-buddy-agent:jar:sources", "net.bytebuddy:byte-buddy:jar:sources", + "net.devh:grpc-common-spring-boot", + "net.devh:grpc-common-spring-boot:jar:sources", + "net.devh:grpc-server-spring-boot-starter", + "net.devh:grpc-server-spring-boot-starter:jar:sources", "net.java.dev.jna:jna", "net.java.dev.jna:jna:jar:sources", + "net.javacrumbs.shedlock:shedlock-core", + "net.javacrumbs.shedlock:shedlock-core:jar:sources", + "net.javacrumbs.shedlock:shedlock-provider-cassandra", + "net.javacrumbs.shedlock:shedlock-provider-cassandra:jar:sources", + "net.javacrumbs.shedlock:shedlock-spring", + "net.javacrumbs.shedlock:shedlock-spring:jar:sources", "net.minidev:accessors-smart", "net.minidev:accessors-smart:jar:sources", "net.minidev:json-smart", @@ -9312,6 +10903,8 @@ "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources", "org.apache.cassandra:java-driver-query-builder", "org.apache.cassandra:java-driver-query-builder:jar:sources", + "org.apache.commons:commons-collections4", + "org.apache.commons:commons-collections4:jar:sources", "org.apache.commons:commons-compress", "org.apache.commons:commons-compress:jar:sources", "org.apache.commons:commons-lang3", @@ -9328,14 +10921,24 @@ "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources", "org.apiguardian:apiguardian-api", "org.apiguardian:apiguardian-api:jar:sources", + "org.aspectj:aspectjweaver", + "org.aspectj:aspectjweaver:jar:sources", "org.assertj:assertj-core", "org.assertj:assertj-core:jar:sources", "org.awaitility:awaitility", "org.awaitility:awaitility:jar:sources", + "org.bitbucket.b_c:jose4j", + "org.bitbucket.b_c:jose4j:jar:sources", + "org.bouncycastle:bcpkix-jdk18on", + "org.bouncycastle:bcpkix-jdk18on:jar:sources", "org.bouncycastle:bcprov-jdk18on", "org.bouncycastle:bcprov-jdk18on:jar:sources", "org.bouncycastle:bcprov-lts8on", "org.bouncycastle:bcprov-lts8on:jar:sources", + "org.bouncycastle:bcutil-jdk18on", + "org.bouncycastle:bcutil-jdk18on:jar:sources", + "org.codehaus.mojo:animal-sniffer-annotations", + "org.codehaus.mojo:animal-sniffer-annotations:jar:sources", "org.hamcrest:hamcrest", "org.hamcrest:hamcrest:jar:sources", "org.hdrhistogram:HdrHistogram", @@ -9353,6 +10956,10 @@ "org.jboss.logging:jboss-logging", "org.jboss.logging:jboss-logging:jar:sources", "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:sources", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:sources", "org.jetbrains.kotlin:kotlin-stdlib:jar:sources", "org.jetbrains:annotations", "org.jetbrains:annotations:jar:sources", @@ -9441,6 +11048,8 @@ "org.springframework.boot:spring-boot-http-converter:jar:sources", "org.springframework.boot:spring-boot-jackson", "org.springframework.boot:spring-boot-jackson:jar:sources", + "org.springframework.boot:spring-boot-loader", + "org.springframework.boot:spring-boot-loader:jar:sources", "org.springframework.boot:spring-boot-micrometer-metrics", "org.springframework.boot:spring-boot-micrometer-metrics-test", "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources", @@ -9480,6 +11089,8 @@ "org.springframework.boot:spring-boot-starter-actuator-test", "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources", "org.springframework.boot:spring-boot-starter-actuator:jar:sources", + "org.springframework.boot:spring-boot-starter-aspectj", + "org.springframework.boot:spring-boot-starter-aspectj:jar:sources", "org.springframework.boot:spring-boot-starter-data-cassandra", "org.springframework.boot:spring-boot-starter-data-cassandra-test", "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources", @@ -9552,13 +11163,22 @@ "org.springframework.cloud:spring-cloud-commons:jar:sources", "org.springframework.cloud:spring-cloud-context", "org.springframework.cloud:spring-cloud-context:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig", + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-client-config", + "org.springframework.cloud:spring-cloud-kubernetes-client-config:jar:sources", + "org.springframework.cloud:spring-cloud-kubernetes-commons", + "org.springframework.cloud:spring-cloud-kubernetes-commons:jar:sources", "org.springframework.cloud:spring-cloud-starter", "org.springframework.cloud:spring-cloud-starter-bootstrap", "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources", + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config", "org.springframework.data:spring-data-cassandra", "org.springframework.data:spring-data-cassandra:jar:sources", "org.springframework.data:spring-data-commons", "org.springframework.data:spring-data-commons:jar:sources", + "org.springframework.retry:spring-retry", + "org.springframework.retry:spring-retry:jar:sources", "org.springframework.security:spring-security-config", "org.springframework.security:spring-security-config:jar:sources", "org.springframework.security:spring-security-core", @@ -9582,6 +11202,8 @@ "org.springframework:spring-beans", "org.springframework:spring-beans:jar:sources", "org.springframework:spring-context", + "org.springframework:spring-context-support", + "org.springframework:spring-context-support:jar:sources", "org.springframework:spring-context:jar:sources", "org.springframework:spring-core", "org.springframework:spring-core:jar:sources", @@ -9604,6 +11226,8 @@ "org.testcontainers:testcontainers-database-commons:jar:sources", "org.testcontainers:testcontainers-junit-jupiter", "org.testcontainers:testcontainers-junit-jupiter:jar:sources", + "org.testcontainers:testcontainers-localstack", + "org.testcontainers:testcontainers-localstack:jar:sources", "org.testcontainers:testcontainers:jar:sources", "org.wiremock:wiremock-standalone", "org.wiremock:wiremock-standalone:jar:sources", @@ -9690,6 +11314,40 @@ "io.cloudevents.jackson.JsonFormat" ] }, + "io.grpc:grpc-core": { + "io.grpc.LoadBalancerProvider": [ + "io.grpc.internal.PickFirstLoadBalancerProvider" + ], + "io.grpc.NameResolverProvider": [ + "io.grpc.internal.DnsNameResolverProvider" + ] + }, + "io.grpc:grpc-netty-shaded": { + "io.grpc.ManagedChannelProvider": [ + "io.grpc.netty.shaded.io.grpc.netty.NettyChannelProvider", + "io.grpc.netty.shaded.io.grpc.netty.UdsNettyChannelProvider" + ], + "io.grpc.NameResolverProvider": [ + "io.grpc.netty.shaded.io.grpc.netty.UdsNameResolverProvider" + ], + "io.grpc.ServerProvider": [ + "io.grpc.netty.shaded.io.grpc.netty.NettyServerProvider" + ], + "reactor.blockhound.integration.BlockHoundIntegration": [ + "io.grpc.netty.shaded.io.netty.util.internal.Hidden$NettyBlockHoundIntegration" + ] + }, + "io.grpc:grpc-services": { + "io.grpc.LoadBalancerProvider": [ + "io.grpc.protobuf.services.internal.HealthCheckingRoundRobinLoadBalancerProvider" + ] + }, + "io.grpc:grpc-util": { + "io.grpc.LoadBalancerProvider": [ + "io.grpc.util.OutlierDetectionLoadBalancerProvider", + "io.grpc.util.SecretRoundRobinLoadBalancerProvider$Provider" + ] + }, "io.micrometer:micrometer-observation": { "io.micrometer.context.ThreadLocalAccessor": [ "io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor" @@ -10031,6 +11689,11 @@ "org.springframework.boot.logging.log4j2.SpringBootPropertySource" ] }, + "org.springframework.boot:spring-boot-loader": { + "java.nio.file.spi.FileSystemProvider": [ + "org.springframework.boot.loader.nio.file.NestedFileSystemProvider" + ] + }, "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": { "org.junit.platform.launcher.TestExecutionListener": [ "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryEventPublisherBeansTestExecutionListener" diff --git a/rules/java/BUILD.bazel b/rules/java/BUILD.bazel index d3700c347..b77e2d446 100644 --- a/rules/java/BUILD.bazel +++ b/rules/java/BUILD.bazel @@ -1,16 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Java build rules (nvcf_spring_boot_image, nvcf_java_library). See defs.bzl. +# Root-owned reusable Java Starlark APIs. See defs.bzl, notice.bzl, proto.bzl, +# and spring.bzl. +package(default_visibility = ["//visibility:public"]) diff --git a/rules/java/README.md b/rules/java/README.md deleted file mode 100644 index 312493bfb..000000000 --- a/rules/java/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# rules/java - -Bazel build rules for NVCF Java services. Load them from `//rules/java:defs.bzl`. - -```starlark -load("//rules/java:defs.bzl", "nvcf_java_library", "nvcf_spring_boot_image") -``` - -## nvcf_spring_boot_image - -Packages a Java application's runtime classpath into a multi-arch OCI image. - -```starlark -nvcf_spring_boot_image( - name = "image", - main_class = "com.nvidia.nvcf.example.ExampleApplication", - deps = [":example_lib"], # java_library targets; transitive runtime jars are packaged - # base = "@temurin_jre", # default JRE base - # registry = "nvcr.io/...",# emits :image_push when set - # jvm_flags = ["-XX:MaxRAMPercentage=75"], -) -``` - -It generates the same target set as the Go image macro (`:image`, -`:image_index`, `:image_load`, `:image.tar`, and `:image_push` when a registry -is given), so a Java service plugs into the existing release machinery unchanged. - -Design note: the app runs from an exploded classpath (`/app/lib/*.jar`), not a -fat jar. Each dependency jar keeps its own `META-INF/spring.factories` and -`META-INF/spring/*.AutoConfiguration.imports`. A fat/singlejar would collapse -those same-path resources to a single file and silently break Spring Boot -auto-configuration. - -Multi-module constraint: jars are packaged flat under `/app/lib` by basename, so -every jar on the transitive runtime classpath must have a unique filename. Maven -jars already are (`artifact-version.jar`); a Bazel `java_library` jar is named -after its target label (`lib.jar`). A multi-module service must give its -library targets distinct names (do not reuse `lib` or `main` across packages) or -the compiled jars overwrite each other in the image layer. The example service -has a single library and is not affected. - -## nvcf_java_library - -Thin wrapper over `java_library`. Use it for NVCF Java code so services load a -single entry point and shared defaults can be added later in one place. - -## Maven dependencies - -Coordinates are resolved by `rules_jvm_external` and pinned in -`//:maven_install.json`. To add a dependency: - -1. Add the coordinate to `maven.install(artifacts = [...])` in the root - `MODULE.bazel`. -2. Re-pin: `bazel run @nv_third_party_deps//:pin` (or `REPIN=1 bazel run @nv_third_party_deps//:pin` when - updating an existing set). -3. Reference it as `@nv_third_party_deps//:group_artifact` in `deps`. List every artifact your - code imports directly, not just the aggregator starter, so the strict-deps - header compiler resolves the symbols. - -### Internal (nv-boot) dependencies - -The foundation resolves from Maven Central only, so it builds anywhere including -the public GitHub mirror. A service that depends on nv-boot or other internal -artifacts adds the internal Artifactory Maven virtual repository to the -`repositories` list in `maven.install` and lists the nv-boot coordinates in -`artifacts`. Point `repositories` at your internal Artifactory virtual repo URL -(kept out of this file to preserve OSS snapshot hygiene) ahead of Central, then -re-pin. Builds that need those artifacts then run only where that repository is -reachable. - -## Java toolchain - -Java builds use a hermetic remotejdk 21 (`--java_language_version=21`, -`--java_runtime_version=remotejdk_21` in `.bazelrc`), independent of the host -JDK, so builds are reproducible on any developer machine and in CI. diff --git a/rules/java/defs.bzl b/rules/java/defs.bzl index 290a633e1..c1d1e3854 100644 --- a/rules/java/defs.bzl +++ b/rules/java/defs.bzl @@ -1,25 +1,332 @@ -# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"Public Java build rules for NVCF services." - -load( - "//rules/java/private:spring_boot.bzl", - _nvcf_java_library = "nvcf_java_library", - _nvcf_spring_boot_image = "nvcf_spring_boot_image", +"""Shared Java build macros for the NVCF monorepo. + +Reusable Starlark APIs for Java libraries and their JUnit 5 + JaCoCo tests. +These macros are owned by the root module and consumed by every native Java +subtree through direct `//rules/java:defs.bzl` loads. Third-party artifacts +resolve from the single root `@nv_third_party_deps` hub; executable helpers +and shared build targets (Lombok, JaCoCo CLI, JUnit runner) live under +`//tools/bazel/java`. + +Two profiles coexist while services keep distinct compile settings: +- nv_boot_* : nv-boot-parent library profile (`-Xlint:deprecation`). +- nvct_* : cloud-tasks service profile (`--release 25` + Error Prone opts). +Generalize into one profile only after multiple services demonstrate the same +need, per the monorepo Java architecture. +""" + +load("@rules_java//java:defs.bzl", _java_binary = "java_binary", _java_library = "java_library") +load("@rules_java//java/common:java_info.bzl", "JavaInfo") +load("@rules_shell//shell:sh_test.bzl", _sh_test = "sh_test") + +# ============================================================================ +# Shared helper targets (root-owned). +# ============================================================================ +_LOMBOK_COMPILE_DEPS = ["//tools/bazel/java:lombok_annotations"] +_LOMBOK_PLUGINS = ["//tools/bazel/java:lombok_plugin"] +_JACOCO_TEST_RUNNER = "//tools/bazel/java:jacoco_test_runner.sh" +_JACOCO_CLI = "//tools/bazel/java:jacoco_cli" + +_JUNIT5_ARGS = [ + "execute", + "--details=flat", + "--disable-ansi-colors", + "--details-theme=ascii", + "--include-classname=.*(Test|IntegrationTest)", + "--fail-if-no-tests", +] + +_JUNIT5_RUNTIME_DEPS = [ + "@nv_third_party_deps//:org_junit_platform_junit_platform_console_standalone", +] + +_JUNIT5_COMPILE_DEPS = [ + "@nv_third_party_deps//:org_assertj_assertj_core", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_params", + "@nv_third_party_deps//:org_mockito_mockito_core", + "@nv_third_party_deps//:org_mockito_mockito_junit_jupiter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", + "@nv_third_party_deps//:org_springframework_spring_test", +] + +_MOCKITO_CORE = "@nv_third_party_deps//:org_mockito_mockito_core" +_JACOCO_AGENT = "@nv_third_party_deps//:org_jacoco_org_jacoco_agent_runtime" + +_JACOCO_AGENT_JVM_FLAGS = [ + ( + "-javaagent:$(location %s)=destfile=jacoco.exec,append=false," + + "dumponexit=true,includes=com.nvidia.*" + ) % _JACOCO_AGENT, +] + +def _unique(values): + seen = {} + result = [] + for value in values: + if value not in seen: + seen[value] = True + result.append(value) + return result + +# ============================================================================ +# Shared rules. +# ============================================================================ +def _nv_boot_runtime_classpath_test_impl(ctx): + runtime_jars = ctx.attr.target[JavaInfo].transitive_runtime_jars.to_list() + leaked = [] + + for jar in runtime_jars: + for artifact in ctx.attr.forbidden_artifacts: + if artifact in jar.basename: + leaked.append(jar.short_path) + break + + if leaked: + fail( + "%s exports Maven-optional/provided runtime jars:\n%s" % ( + ctx.attr.target.label, + "\n".join(sorted(leaked)), + ), + ) + + executable = ctx.actions.declare_file(ctx.label.name + ".sh") + ctx.actions.write( + output = executable, + content = "#!/bin/sh\nexit 0\n", + is_executable = True, + ) + return [DefaultInfo(executable = executable)] + +nv_boot_runtime_classpath_test = rule( + implementation = _nv_boot_runtime_classpath_test_impl, + attrs = { + "forbidden_artifacts": attr.string_list(mandatory = True), + "target": attr.label(mandatory = True, providers = [JavaInfo]), + }, + test = True, ) -nvcf_spring_boot_image = _nvcf_spring_boot_image -nvcf_java_library = _nvcf_java_library +def _workspace_runfiles_impl(ctx): + symlinks = {} + strip_prefix = ctx.attr.strip_prefix + + for src in ctx.files.srcs: + runfiles_path = src.short_path + if strip_prefix: + if not runfiles_path.startswith(strip_prefix): + fail("Expected %s to start with strip_prefix %s" % (runfiles_path, strip_prefix)) + runfiles_path = runfiles_path[len(strip_prefix):] + + if runfiles_path in symlinks: + fail("Duplicate runfiles path: %s" % runfiles_path) + symlinks[runfiles_path] = src + + return [DefaultInfo(runfiles = ctx.runfiles(symlinks = symlinks))] + +_workspace_runfiles = rule( + implementation = _workspace_runfiles_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "strip_prefix": attr.string(), + }, +) + +# Both profiles expose the same runfiles rule under their historical names. +nv_boot_workspace_runfiles = _workspace_runfiles +nvct_workspace_runfiles = _workspace_runfiles + +# ============================================================================ +# nv-boot-parent library profile. +# ============================================================================ +NV_JAVA_JAVACOPTS = [ + "-Xlint:deprecation", +] + +def nv_boot_library( + name, + srcs, + deps = [], + resources = [], + runtime_deps = [], + visibility = None, + resource_strip_prefix = ""): + _java_library( + name = name, + srcs = srcs, + deps = deps + _LOMBOK_COMPILE_DEPS, + javacopts = NV_JAVA_JAVACOPTS, + plugins = _LOMBOK_PLUGINS, + resources = resources, + resource_strip_prefix = resource_strip_prefix, + runtime_deps = runtime_deps, + visibility = visibility, + ) + +def nv_boot_library_test( + name, + srcs, + deps, + coverage_library, + data = [], + junit_classpath = [], + jvm_flags = [], + resources = [], + runtime_deps = [], + size = "small", + tags = [], + timeout = "short", + resource_strip_prefix = ""): + if type(coverage_library) != "string" or not coverage_library.startswith(":"): + fail( + "coverage_library must be the module library target as a local " + + "label starting with ':'", + ) + + coverage_sourcefiles = native.glob(["src/main/java/**/*.java"]) + coverage_source_root = native.package_name() + "/src/main/java" + junit_runner = name + "_junit_runner" + + _java_binary( + name = junit_runner, + srcs = srcs, + data = data + [_JACOCO_AGENT, _MOCKITO_CORE], + deps = deps + _LOMBOK_COMPILE_DEPS + _JUNIT5_COMPILE_DEPS, + javacopts = NV_JAVA_JAVACOPTS, + jvm_flags = _JACOCO_AGENT_JVM_FLAGS + [ + "-javaagent:$(location %s)" % _MOCKITO_CORE, + ] + jvm_flags, + main_class = "org.junit.platform.console.ConsoleLauncher", + plugins = _LOMBOK_PLUGINS, + resources = resources, + resource_strip_prefix = resource_strip_prefix, + runtime_deps = runtime_deps + _JUNIT5_RUNTIME_DEPS, + tags = ["manual"], + testonly = True, + visibility = ["//visibility:private"], + ) + + _sh_test( + name = name, + srcs = [_JACOCO_TEST_RUNNER], + args = [ + "$(location :%s)" % junit_runner, + "$(location %s)" % coverage_library, + coverage_source_root if coverage_sourcefiles else "", + native.package_name(), + "$(location %s)" % _JACOCO_CLI, + ] + _JUNIT5_ARGS + [ + "--class-path=$(location :%s.jar)" % junit_runner, + "--scan-classpath=$(location :%s.jar)" % junit_runner, + ] + [ + "--class-path=%s" % path + for path in junit_classpath + ], + data = [ + ":" + junit_runner, + ":%s.jar" % junit_runner, + coverage_library, + _JACOCO_CLI, + ] + coverage_sourcefiles, + size = size, + tags = tags, + timeout = timeout, + ) + +# ============================================================================ +# cloud-tasks service profile. +# ============================================================================ +NVCT_JAVACOPTS = [ + "--release", + "25", + "-Xep:CheckReturnValue:OFF", + "-Xep:ImpossibleNullComparison:OFF", + "-Xep:OptionalOfRedundantMethod:OFF", + "-Xlint:deprecation", +] + +def nvct_library( + name, + srcs, + deps = [], + resources = [], + runtime_deps = [], + visibility = None, + resource_strip_prefix = ""): + _java_library( + name = name, + srcs = srcs, + deps = deps + _LOMBOK_COMPILE_DEPS, + javacopts = NVCT_JAVACOPTS, + plugins = _LOMBOK_PLUGINS, + resources = resources, + resource_strip_prefix = resource_strip_prefix, + runtime_deps = runtime_deps, + visibility = visibility, + ) + +def nvct_library_test( + name, + deps, + coverage_library, + data = [], + jvm_flags = [], + resources = [], + runtime_deps = [], + size = "large", + srcs = [], + tags = [], + timeout = "long", + visibility = None): + if type(coverage_library) != "string" or not coverage_library.startswith(":"): + fail( + "coverage_library must be the module library target as a local " + + "label starting with ':'", + ) + + coverage_sourcefiles = native.glob(["src/main/java/**/*.java"]) + coverage_source_root = native.package_name() + "/src/main/java" + junit_runner = name + "_junit_runner" + + _java_binary( + name = junit_runner, + srcs = srcs, + data = _unique(data + [_JACOCO_AGENT, _MOCKITO_CORE]), + deps = _unique(deps + _LOMBOK_COMPILE_DEPS + _JUNIT5_COMPILE_DEPS), + javacopts = NVCT_JAVACOPTS, + jvm_flags = _JACOCO_AGENT_JVM_FLAGS + [ + "-javaagent:$(location %s)" % _MOCKITO_CORE, + ] + jvm_flags, + main_class = "org.junit.platform.console.ConsoleLauncher", + plugins = _LOMBOK_PLUGINS, + resources = resources, + runtime_deps = runtime_deps + _JUNIT5_RUNTIME_DEPS, + tags = ["manual"], + testonly = True, + visibility = ["//visibility:private"], + ) + + _sh_test( + name = name, + srcs = [_JACOCO_TEST_RUNNER], + args = [ + "$(location :%s)" % junit_runner, + "$(location %s)" % coverage_library, + coverage_source_root if coverage_sourcefiles else "", + native.package_name(), + "$(location %s)" % _JACOCO_CLI, + ] + _JUNIT5_ARGS + [ + "--class-path=$(location :%s.jar)" % junit_runner, + "--scan-classpath=$(location :%s.jar)" % junit_runner, + ], + data = _unique([ + ":" + junit_runner, + ":%s.jar" % junit_runner, + coverage_library, + _JACOCO_CLI, + ] + coverage_sourcefiles), + size = size, + tags = tags, + timeout = timeout, + visibility = visibility, + ) diff --git a/rules/java/notice.bzl b/rules/java/notice.bzl new file mode 100644 index 000000000..f2cebb706 --- /dev/null +++ b/rules/java/notice.bzl @@ -0,0 +1,203 @@ +"""Shared NOTICE generation rules for Java components in the NVCF monorepo.""" + +load("@rules_shell//shell:sh_test.bzl", _sh_test = "sh_test") + +_DIFF_TEST = "//tools/bazel/java:notice_diff_test.sh" +_GENERATOR = "//tools/bazel/java:generate_notice_tool" +_LICENSE_ALIASES = "//tools/bazel/java:license_aliases.json" +_MAVEN_INSTALL = "//:maven_install.json" + + +def _workspace_path(label): + if label.startswith(":"): + return native.package_name() + "/" + label[1:] + if label.startswith("//"): + package_and_name = label[2:].split(":") + if len(package_and_name) != 2: + fail("Expected an explicit //package:file label, got %s" % label) + return package_and_name[0] + "/" + package_and_name[1] + fail("Expected a local or absolute source-file label, got %s" % label) + + +def _action_arguments( + metadata, + shared_metadata, + root_manifests, + runtime_target, + first_party_groups): + arguments = [ + '--maven-install "$(location %s)"' % _MAVEN_INSTALL, + '--metadata "$(location %s)"' % metadata, + '--license-aliases "$(location %s)"' % _LICENSE_ALIASES, + ] + for label in shared_metadata: + arguments.append('--shared-metadata "$(location %s)"' % label) + for label in root_manifests: + arguments.append('--root-manifest "$(location %s)"' % label) + if runtime_target: + arguments.append('--runtime-jar "$(location %s)"' % runtime_target) + for group in first_party_groups: + arguments.append("--first-party-group %s" % group) + return arguments + + +def _update_arguments( + checked_notice, + metadata, + shared_metadata, + root_manifests, + runtime_target, + first_party_groups): + arguments = [ + '--maven-install "$${workspace}/maven_install.json"', + '--metadata "$${workspace}/%s"' % _workspace_path(metadata), + '--notice "$${workspace}/%s"' % _workspace_path(checked_notice), + '--license-aliases "$${workspace}/tools/bazel/java/license_aliases.json"', + ] + for label in shared_metadata: + arguments.append( + '--shared-metadata "$${workspace}/%s"' % _workspace_path(label) + ) + for label in root_manifests: + arguments.append( + '--root-manifest "$${workspace}/%s"' % _workspace_path(label) + ) + if runtime_target: + arguments.append('--runtime-jar "$(location %s)"' % runtime_target) + for group in first_party_groups: + arguments.append("--first-party-group %s" % group) + return arguments + + +def nvcf_notice( + checked_notice, + metadata, + shared_metadata = [], + root_manifests = [], + runtime_target = None, + first_party_groups = [], + name = "third_party_notice", + inventory_name = "runtime_inventory"): + """Generates and checks one component's complete third-party NOTICE.""" + if bool(root_manifests) == bool(runtime_target): + fail("Set exactly one of root_manifests or runtime_target") + + srcs = [ + _LICENSE_ALIASES, + _MAVEN_INSTALL, + checked_notice, + metadata, + ] + shared_metadata + root_manifests + if runtime_target: + srcs.append(runtime_target) + + action_arguments = _action_arguments( + metadata, + shared_metadata, + root_manifests, + runtime_target, + first_party_groups, + ) + native.genrule( + name = name, + srcs = srcs, + outs = [ + "THIRD_PARTY_NOTICE", + "runtime_inventory.json", + ], + cmd = """ +set -eu +"$(execpath {generator})" \ + {arguments} \ + --output "$(@D)/THIRD_PARTY_NOTICE" \ + --inventory-output "$(@D)/runtime_inventory.json" \ + --write +""".format( + arguments = " \\\n ".join(action_arguments), + generator = _GENERATOR, + ), + tools = [_GENERATOR], + ) + + native.filegroup( + name = inventory_name, + srcs = [":runtime_inventory.json"], + ) + + _sh_test( + name = "notice_check_test", + srcs = [_DIFF_TEST], + args = [ + "$(location :THIRD_PARTY_NOTICE)", + "$(location %s)" % checked_notice, + ], + data = [ + ":THIRD_PARTY_NOTICE", + checked_notice, + ], + size = "small", + ) + + update_arguments = _update_arguments( + checked_notice, + metadata, + shared_metadata, + root_manifests, + runtime_target, + first_party_groups, + ) + native.genrule( + name = "generate_notice", + srcs = srcs, + outs = ["generate_notice.sh"], + cmd = """ +cat > "$@" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +workspace="$${{BUILD_WORKSPACE_DIRECTORY:?Run this command through bazel run}}" +exec "$(execpath {generator})" \ + {arguments} \ + "$$@" +EOF +chmod +x "$@" +""".format( + arguments = " \\\n ".join(update_arguments), + generator = _GENERATOR, + ), + executable = True, + tools = [_GENERATOR], + ) + + +def nvcf_notice_delta( + inventory, + baseline_inventories, + name = "osrb_dependency_delta"): + """Generates machine-readable and license-grouped OSRB dependency deltas.""" + baseline_arguments = [ + '--baseline-inventory "$(location %s)"' % label + for label in baseline_inventories + ] + native.genrule( + name = name, + srcs = [inventory] + baseline_inventories, + outs = [ + name + ".json", + name + ".md", + ], + cmd = """ +set -eu +"$(execpath {generator})" \ + --delta-inventory "$(location {inventory})" \ + {baselines} \ + --delta-json-output "$(@D)/{name}.json" \ + --delta-markdown-output "$(@D)/{name}.md" +""".format( + baselines = " \\\n ".join(baseline_arguments), + generator = _GENERATOR, + inventory = inventory, + name = name, + ), + tools = [_GENERATOR], + ) diff --git a/rules/java/private/spring_boot.bzl b/rules/java/private/spring_boot.bzl deleted file mode 100644 index b3fddf947..000000000 --- a/rules/java/private/spring_boot.bzl +++ /dev/null @@ -1,142 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"OCI image + library rules for Java (Spring Boot) services." - -load("@rules_java//java:defs.bzl", "java_library") -load("@rules_java//java/common:java_info.bzl", "JavaInfo") -load("@rules_pkg//pkg:mappings.bzl", "pkg_files", "strip_prefix") -load("@rules_pkg//pkg:tar.bzl", "pkg_tar") -load("//rules/oci/private:common.bzl", "create_oci_image") - -DEFAULT_JAVA_BASE = "@temurin_jre" - -def _runtime_classpath_impl(ctx): - jars = depset(transitive = [ - dep[JavaInfo].transitive_runtime_jars - for dep in ctx.attr.deps - ]) - return [DefaultInfo(files = jars)] - -# Collects the transitive runtime jars of one or more Java targets so they can -# be packaged as individual files (see _nvcf_spring_boot_image_impl for why the -# jars are kept separate instead of merged into a fat jar). -_runtime_classpath = rule( - implementation = _runtime_classpath_impl, - attrs = { - "deps": attr.label_list( - providers = [JavaInfo], - mandatory = True, - doc = "Java targets whose transitive runtime jars form the classpath.", - ), - }, -) - -def _nvcf_spring_boot_image_impl(name, visibility, deps, main_class, base, registry, tags, jvm_flags): - cp_name = name + "_classpath" - _runtime_classpath( - name = cp_name, - deps = deps, - visibility = ["//visibility:private"], - ) - - files_name = name + "_classpath_files" - pkg_files( - name = files_name, - srcs = [cp_name], - # Flatten every jar to /app/lib/. Maven artifact jars are - # already uniquely named (artifact-version.jar). Bazel-produced jars are - # named after their target label (lib.jar), so two same-named - # java_library targets in different packages would collide here. See the - # deps attr doc for the single-basename constraint on multi-module apps. - strip_prefix = strip_prefix.files_only(), - prefix = "app/lib", - visibility = ["//visibility:private"], - ) - - layer_name = name + "_layer" - pkg_tar( - name = layer_name, - srcs = [files_name], - visibility = ["//visibility:private"], - ) - - # Run the app off the exploded classpath instead of a fat jar. Keeping each - # dependency jar separate preserves its META-INF/spring.factories and - # META-INF/spring/*.AutoConfiguration.imports; a fat/singlejar collapses - # those same-path resources to a single file and silently breaks Spring - # Boot auto-configuration. - entrypoint = ["java"] + jvm_flags + ["-cp", "/app/lib/*", main_class] - - create_oci_image( - name = name, - tars = [layer_name], - base = base, - entrypoint = entrypoint, - visibility = visibility, - registry = registry, - tags = tags, - ) - -nvcf_spring_boot_image = macro( - doc = "Packages a Java (Spring Boot) app's runtime classpath into a multi-arch OCI image.", - implementation = _nvcf_spring_boot_image_impl, - attrs = { - "deps": attr.label_list( - doc = ( - "Java targets (typically one java_library) providing the app and its runtime " + - "deps. Jars are packaged flat under /app/lib by basename, so every jar on the " + - "transitive runtime classpath must have a unique filename. Maven jars already " + - "are (artifact-version.jar); Bazel jars take their target label (lib.jar). " + - "A multi-module app must therefore give its java_library targets distinct names " + - "(avoid reusing 'lib' or 'main' across packages) or the compiled jars silently " + - "overwrite each other in the image layer." - ), - mandatory = True, - configurable = False, - ), - "main_class": attr.string( - doc = "Fully-qualified main class (the @SpringBootApplication class).", - mandatory = True, - configurable = False, - ), - "base": attr.label( - doc = "Base JRE OCI image.", - default = DEFAULT_JAVA_BASE, - configurable = False, - ), - "registry": attr.string( - doc = "Registry to push to. If unset, no push target is created.", - configurable = False, - ), - "tags": attr.string_list( - doc = "Tags for generated targets. 'manual' is always added.", - configurable = False, - ), - "jvm_flags": attr.string_list( - doc = "Extra JVM flags inserted before -cp in the entrypoint.", - configurable = False, - ), - }, -) - -def nvcf_java_library(name, **kwargs): - """Thin wrapper over java_library for NVCF Java code. - - Exists so services load a single NVCF entry point and so shared defaults - (for example Maven publishing) can be added here later without touching - every service BUILD file. - """ - java_library(name = name, **kwargs) diff --git a/rules/java/proto.bzl b/rules/java/proto.bzl new file mode 100644 index 000000000..02016b421 --- /dev/null +++ b/rules/java/proto.bzl @@ -0,0 +1,27 @@ +"""Project protobuf rules whose generator versions match the application runtime.""" + +load( + "@rules_proto_grpc//:defs.bzl", + "ProtoPluginInfo", + "proto_compile_attrs", + "proto_compile_impl", + "proto_compile_toolchains", +) + +# rules_proto_grpc_java does not expose its bundled gRPC plugin as an attribute. +# Keep its proven compile implementation while replacing only that private tool. +nvct_java_grpc_compile = rule( + implementation = proto_compile_impl, + attrs = dict( + proto_compile_attrs, + _plugins = attr.label_list( + providers = [ProtoPluginInfo], + default = [ + Label("@rules_proto_grpc_java//:proto_plugin"), + Label("//tools/bazel/java:grpc_java_1_63_plugin"), + ], + cfg = "exec", + ), + ), + toolchains = proto_compile_toolchains, +) diff --git a/rules/java/spring.bzl b/rules/java/spring.bzl new file mode 100644 index 000000000..b3e7538e3 --- /dev/null +++ b/rules/java/spring.bzl @@ -0,0 +1,178 @@ +load("@rules_spring//springboot:springboot.bzl", "springboot") + +def _spring_boot_metadata_impl(ctx): + app_jar = ctx.file.app_jar + output = ctx.actions.declare_file("%s.jar" % ctx.attr.name) + git_properties = ctx.actions.declare_file( + "%s_metadata/BOOT-INF/classes/git.properties" % ctx.attr.name, + ) + maven_properties = ctx.actions.declare_file( + "%s_metadata/BOOT-INF/classes/maven.properties" % ctx.attr.name, + ) + pom_properties = ctx.actions.declare_file( + "%s_metadata/META-INF/maven/%s/%s/pom.properties" % ( + ctx.attr.name, + ctx.attr.group_id, + ctx.attr.artifact_id, + ), + ) + + args = ctx.actions.args() + args.add(ctx.info_file) + args.add(git_properties) + args.add(maven_properties) + args.add(pom_properties) + args.add(ctx.attr.group_id) + args.add(ctx.attr.artifact_id) + args.add(ctx.attr.artifact_version) + args.add(ctx.attr.java_version) + + ctx.actions.run_shell( + inputs = [ctx.info_file], + outputs = [git_properties, maven_properties, pom_properties], + arguments = [args], + mnemonic = "SpringBootMetadataFiles", + progress_message = "Generating Spring Boot app metadata for %s" % output.short_path, + command = """ +set -eu + +status_file="$1" +git_properties="$2" +maven_properties="$3" +pom_properties="$4" +group_id="$5" +artifact_id="$6" +artifact_version="$7" +java_version="$8" + +status_value() { + key="$1" + awk -v key="${key}" '$1 == key { sub("^[^ ]+ ?", ""); print; found=1 } END { if (!found) exit 0 }' "${status_file}" +} + +git_full="$(status_value STABLE_GIT_COMMIT_ID_FULL)" +git_abbrev="$(status_value STABLE_GIT_COMMIT_ID_ABBREV)" +git_tags="$(status_value STABLE_GIT_TAGS)" +git_closest_tag="$(status_value STABLE_GIT_CLOSEST_TAG_NAME)" +build_version="$(status_value STABLE_BUILD_VERSION)" +if [ -n "${build_version}" ]; then + artifact_version="${build_version}" +fi + +mkdir -p \ + "$(dirname "${git_properties}")" \ + "$(dirname "${maven_properties}")" \ + "$(dirname "${pom_properties}")" + +cat > "${git_properties}" < "${maven_properties}" < "${pom_properties}" </tests/ +``` + +Useful files: + +```text +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.log +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.outputs/junit/TEST-junit-jupiter.xml +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.log +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.outputs/junit/TEST-junit-jupiter.xml +``` + +The Jupiter XML files contain the real Java testcases and are the reports +published by GitHub Actions. The nearby `tests/test.xml` files describe Bazel's single +outer `sh_test` wrapper and must not be used as JUnit reports. + +Use `--cache_test_results=no` when you want to force the tests to run again. + +Use `--test_output=streamed` instead of `--test_output=errors` when you want to +watch test output live: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //src/control-plane-services/cloud-tasks/nvct-core:tests \ + --cache_test_results=no \ + --test_output=streamed \ + --test_env=DOCKER_HOST \ + --test_env=DOCKER_TLS_VERIFY \ + --test_env=DOCKER_TLS_CERTDIR \ + --test_env=DOCKER_CERT_PATH +``` + +## Coverage And Sonar XML + +Coverage generation is part of +`//src/control-plane-services/cloud-tasks/nvct-core:tests` and +`//src/control-plane-services/cloud-tasks/nvct-service:tests`; no separate +coverage command is required. Running a +test with `--cache_test_results=no` refreshes these outputs: + +```text +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.outputs/index.html +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.outputs/jacoco.xml +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.outputs/jacoco.exec +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.outputs/index.html +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.outputs/jacoco.xml +bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.outputs/jacoco.exec +``` + +The test JVM runs the JaCoCo agent with `dumponexit=true`. After JUnit exits, +the Bazel shell test preserves the JUnit status and invokes the Bazel-declared +JaCoCo CLI to generate XML and HTML. This uses no custom Java coverage launcher +and no JaCoCo internal runtime API. + +Open `index.html` for the JaCoCo HTML report. Sonar consumes the corresponding +`jacoco.xml`, for example: + +```text +-Dsonar.coverage.jacoco.xmlReportPaths=bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-core/tests/test.outputs/jacoco.xml,bazel-testlogs/src/control-plane-services/cloud-tasks/nvct-service/tests/test.outputs/jacoco.xml +``` + +Class and method filters also produce reports, but those reports describe only +the selected test execution. + +## License/NOTICE Generation + +The monorepo has two NOTICE levels: + +1. Cloud Tasks' Bazel target generates and checks the complete third-party + NOTICE for the application. +2. The existing root `tools/scripts/collect-notices` process records the Cloud + Tasks NOTICE path in the monorepo's top-level `NOTICE`. + +Cloud Tasks derives its actual shipped coordinates from the jars nested in: + +```text +//src/control-plane-services/cloud-tasks/nvct-service:app +``` + +The root owns the shared rule and generator under `//rules/java` and +`//tools/bazel/java`. Cloud Tasks owns: + +```text +src/control-plane-services/cloud-tasks/NOTICE +src/control-plane-services/cloud-tasks/notice_metadata.json +``` + +Cloud Tasks does not need `notice_roots.json`: the executable `app.jar` is the +authoritative runtime closure. Its metadata file contains only dependencies +additional to nv-boot. The shared rule reads common metadata from: + +```text +//src/libraries/java/nv-boot-parent:notice_metadata.json +``` + +It selects only shared entries actually present in `app.jar`, merges the +service-owned entries, and fails on missing, conflicting, or duplicated +metadata. The generated Cloud Tasks NOTICE is complete and standalone; it is +not a concatenation of or link to nv-boot's NOTICE. + +Regenerate the checked-in NOTICE: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run //src/control-plane-services/cloud-tasks:generate_notice -- --write +``` + +When a new service runtime dependency lacks metadata, refresh only the +service-owned metadata and NOTICE: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run //src/control-plane-services/cloud-tasks:generate_notice -- \ + --update-metadata --write +``` + +The metadata-update mode may read an upstream dependency POM from the local +Maven cache or configured artifact repository to obtain its published name, +URL, and license declaration. That is metadata discovery only; it does not run +a Maven project build. + +Check NOTICE drift exactly as CI does: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //src/control-plane-services/cloud-tasks:notice_check_test \ + --cache_test_results=no \ + --test_output=errors +``` + +Build the complete runtime inventory and NVBug-ready dependency delta: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build \ + //src/control-plane-services/cloud-tasks:cloud_tasks_runtime_inventory \ + //src/control-plane-services/cloud-tasks:osrb_dependency_delta + +cat bazel-bin/src/control-plane-services/cloud-tasks/runtime_inventory.json +cat bazel-bin/src/control-plane-services/cloud-tasks/osrb_dependency_delta.json +cat bazel-bin/src/control-plane-services/cloud-tasks/osrb_dependency_delta.md +``` + +The delta compares exact versioned Cloud Tasks runtime coordinates with the +nv-boot runtime inventory and groups only the additional dependencies by +normalized license. Use that Markdown as the dependency portion of the NVBug +6040004 approval comment. Ambiguous and custom license expressions remain +explicit for OSRB review. + +After component NOTICE files are updated, validate the existing monorepo root +rollup: + +```bash +./tools/ci/check-license +``` + +`check-license` requires Bash 4 or newer. To intentionally refresh the +top-level path rollup, run `./tools/scripts/update-license`. + +## GitHub CI + +The monorepo uses `.github/workflows/bazel.yml`. There are no GitLab +`ENABLE_BAZEL_*` variables for this subtree. + +The workflow detects Cloud Tasks or shared Java changes and selects the +appropriate service lane. The build-container lane excludes `requires-docker` +tests at query time. The Docker-host lane receives Java 25 through +`actions/setup-java`, uses the host Docker daemon, and runs the complete Cloud +Tasks test scope, including unit and Testcontainers tests. Changes to nv-boot +or shared Java tooling must also select Cloud Tasks as a reverse-dependency +validation target. + +GitHub CI proves build, test, coverage, and layered NOTICE behavior. Public +container publication remains a separate release-policy decision. Bazel never +publishes Maven-shaped project artifacts. + +The component-local `bazel-java-ci.json` registers Cloud Tasks with the root +workflow. The detector infers the component path from that file. A future Java +service adds its own descriptor with: + +```json +{ + "ci_lane": "docker-host", + "component_kind": "java-service", + "id": "service-id", + "tests_skip": false +} +``` + +That one descriptor supplies shared Java triggers, nv-boot reverse-dependency +validation, CI execution-environment routing, and report upload. Do not add the +service name to parallel lists in `.github/workflows/bazel.yml`. + +### CI Execution Environments + +The `ci_lane` descriptor field has two supported values: + +- `build-container`: GitHub Actions runs the job inside the pinned + `ghcr.io/nvidia/nvcf/bazel-ci` image. Its build tools are preinstalled, but + it does not expose the host Docker daemon. Use it only when the component's + complete test scope does not require Testcontainers or Docker commands. +- `docker-host`: GitHub Actions runs directly on the `ubuntu-latest` virtual + machine. The workflow installs Java and Bazelisk, and Docker is available. + Cloud Tasks uses this lane because its tests start Cassandra containers. + +This distinction belongs to the GitHub CI environment, not to Bazel itself. +On a developer machine, Maven and Bazel both use Docker Desktop when their +tests require containers. Under the current one-lane-per-component policy, a +component with even one `requires-docker` test uses `docker-host` for its +complete suite. A Java component with no Docker-dependent tests may use +`build-container`. + +### Bazel Scope + +Cloud Tasks is part of the monorepo root Bazel module. CI invokes Bazel from +the repository root with: + +```text +//src/control-plane-services/cloud-tasks/... +``` + +The descriptor no longer contains `scope_mode`. Earlier, +`scope_mode: root` stated that CI must run from the monorepo root instead of +entering Cloud Tasks and expecting a nested `MODULE.bazel`. All Java components +now use that root scope implicitly. Some existing non-Java components remain +independent nested Bazel modules, but that is not a supported option for Java +components integrated into this monorepo. + +### Downloading CI Reports + +Open the completed GitHub Actions workflow run and download: + +```text +bazel-cloud-tasks-verification- +``` + +The artifact is retained for 14 days and contains: + +```text +generated/THIRD_PARTY_NOTICE +generated/runtime_inventory.json +generated/osrb_dependency_delta.json +generated/osrb_dependency_delta.md +testlogs/nvct-core/tests/test.log +testlogs/nvct-core/tests/test.outputs/junit/TEST-junit-jupiter.xml +testlogs/nvct-core/tests/test.outputs/jacoco.exec +testlogs/nvct-core/tests/test.outputs/jacoco.xml +testlogs/nvct-core/tests/test.outputs/index.html +testlogs/nvct-service/tests/test.outputs/... +``` + +Use the XML under `test.outputs/junit`; Bazel's outer `test.xml` describes the +shell test wrapper rather than the individual JUnit tests. The root-owned +`tools/ci/stage-bazel-java-artifacts` helper copies through Bazel's `bazel-bin` +and `bazel-testlogs` symlinks so the download contains real files after the CI +runner is destroyed. + +## Clean + +Clean Bazel outputs for this workspace: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" clean +``` + +Use `expunge` sparingly because it removes the whole output base and forces +dependency/tool re-fetching: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" clean --expunge +``` diff --git a/src/control-plane-services/cloud-tasks/BUILD.bazel b/src/control-plane-services/cloud-tasks/BUILD.bazel new file mode 100644 index 000000000..707c00733 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/BUILD.bazel @@ -0,0 +1,50 @@ +load("//rules/java:defs.bzl", "nvct_workspace_runfiles") +load("//rules/java:notice.bzl", "nvcf_notice", "nvcf_notice_delta") + +package(default_visibility = ["//visibility:public"]) + +# NOTICE and its service-local metadata are owned by this subtree. The +# third-party artifact lock is the single root //:maven_install.json; the +# generation algorithm is root-owned under //tools/bazel/java. +exports_files([ + "NOTICE", + "notice_metadata.json", +]) + +filegroup( + name = "integration_local_env_files", + srcs = [ + "local_env/docker-compose.test.yml", + ] + glob([ + "local_env/cassandra/**", + "local_env/vault/**", + ]), +) + +# The tests read local_env paths (e.g. local_env/vault/secrets.json) relative +# to the runfiles workspace root, the JVM working directory under `bazel test`. +# Strip the package prefix so these files land root-relative in runfiles; in the +# standalone repo this package was the repo root, so no strip was needed. +nvct_workspace_runfiles( + name = "integration_local_env", + srcs = [":integration_local_env_files"], + strip_prefix = "src/control-plane-services/cloud-tasks/", +) + +nvcf_notice( + checked_notice = ":NOTICE", + first_party_groups = ["com.nvidia.nvct"], + inventory_name = "cloud_tasks_runtime_inventory", + metadata = ":notice_metadata.json", + runtime_target = "//src/control-plane-services/cloud-tasks/nvct-service:app", + shared_metadata = [ + "//src/libraries/java/nv-boot-parent:notice_metadata.json", + ], +) + +nvcf_notice_delta( + baseline_inventories = [ + "//src/libraries/java/nv-boot-parent:runtime_inventory.json", + ], + inventory = ":runtime_inventory.json", +) diff --git a/src/control-plane-services/cloud-tasks/CLAUDE.md b/src/control-plane-services/cloud-tasks/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/control-plane-services/cloud-tasks/NOTICE b/src/control-plane-services/cloud-tasks/NOTICE new file mode 100644 index 000000000..f667592de --- /dev/null +++ b/src/control-plane-services/cloud-tasks/NOTICE @@ -0,0 +1,260 @@ + +Lists of 258 third-party dependencies. + (Public Domain) AOP alliance (aopalliance:aopalliance:1.0 - http://aopalliance.sourceforge.net) + (Apache License, Version 2.0) LZ4 Java Compression (at.yawk.lz4:lz4-java:1.10.3 - https://github.com/yawkat/lz4-java) + (EPL-2.0) (LGPL-2.1-only) Logback Classic Module (ch.qos.logback:logback-classic:1.5.34 - http://logback.qos.ch) + (EPL-2.0) (LGPL-2.1-only) Logback Core Module (ch.qos.logback:logback-core:1.5.34 - http://logback.qos.ch) + (The Apache Software License, Version 2.0) bucket4j-core (com.bucket4j:bucket4j-core:8.10.1 - http://github.com/bucket4j/bucket4j/bucket4j-core) + (The Apache Software License, Version 2.0) bucket4j_jdk17-core (com.bucket4j:bucket4j_jdk17-core:8.19.0 - http://github.com/bucket4j/bucket4j/bucket4j_jdk17-core) + (Apache 2) An implementation of the Apache Cassandra® native protocol (com.datastax.oss:native-protocol:1.5.2 - https://github.com/datastax/native-protocol) + (The Apache Software License, Version 2.0) Jackson-annotations (com.fasterxml.jackson.core:jackson-annotations:2.21 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson-core (com.fasterxml.jackson.core:jackson-core:2.21.4 - https://github.com/FasterXML/jackson-core) + (The Apache Software License, Version 2.0) jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.21.4 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson-dataformat-YAML (com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4 - https://github.com/FasterXML/jackson-dataformats-text) + (The Apache Software License, Version 2.0) Jackson datatype: JSR310 (com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4 - https://github.com/FasterXML/jackson-modules-java8) + (Apache License, Version 2.0) ClassMate (com.fasterxml:classmate:1.7.3 - https://github.com/FasterXML/java-classmate) + (Apache License, Version 2.0) Caffeine cache (com.github.ben-manes.caffeine:caffeine:3.2.4 - https://github.com/ben-manes/caffeine) + (Apache License, Version 2.0) Caffeine cache (com.github.ben-manes.caffeine:guava:3.2.4 - https://github.com/ben-manes/caffeine) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) btf (com.github.java-json-tools:btf:1.3 - https://github.com/java-json-tools/btf) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) jackson-coreutils (com.github.java-json-tools:jackson-coreutils:2.0 - https://github.com/java-json-tools/jackson-coreutils) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) json-patch (com.github.java-json-tools:json-patch:1.13 - https://github.com/java-json-tools/json-patch) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) msg-simple (com.github.java-json-tools:msg-simple:1.2 - https://github.com/java-json-tools/msg-simple) + (The Apache Software License, Version 2.0) jffi (com.github.jnr:jffi:1.2.16 - http://github.com/jnr/jffi) + (The Apache Software License, Version 2.0) jnr-constants (com.github.jnr:jnr-constants:0.10.3 - http://github.com/jnr/jnr-constants) + (The Apache Software License, Version 2.0) jnr-ffi (com.github.jnr:jnr-ffi:2.1.7 - http://github.com/jnr/jnr-ffi) + (Eclipse Public License - v 2.0) (GNU General Public License Version 2) (GNU Lesser General Public License Version 2.1) jnr-posix (com.github.jnr:jnr-posix:3.1.15 - http://nexus.sonatype.org/oss-repository-hosting.html) + (MIT License) jnr-x86asm (com.github.jnr:jnr-x86asm:1.0.2 - http://github.com/jnr/jnr-x86asm) + (Apache License, Version 2.0) JCIP Annotations under Apache License (com.github.stephenc.jcip:jcip-annotations:1.0-1 - http://stephenc.github.com/jcip-annotations) + (Apache 2.0) Google Android Annotations Library (com.google.android:annotations:4.1.1.4 - http://source.android.com/) + (Apache-2.0) proto-google-common-protos (com.google.api.grpc:proto-google-common-protos:2.29.0 - https://github.com/googleapis/sdk-platform-java) + (The Apache Software License, Version 2.0) FindBugs-jsr305 (com.google.code.findbugs:jsr305:3.0.2 - http://findbugs.sourceforge.net/) + (Apache-2.0) Gson (com.google.code.gson:gson:2.13.2 - https://github.com/google/gson) + (Apache 2.0) error-prone annotations (com.google.errorprone:error_prone_annotations:2.49.0 - https://errorprone.info) + (Apache License, Version 2.0) Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.3 - https://github.com/google/guava) + (Apache License, Version 2.0) Guava: Google Core Libraries for Java (com.google.guava:guava:33.6.0-jre - https://github.com/google/guava) + (The Apache Software License, Version 2.0) Guava ListenableFuture only (com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava - https://github.com/google/guava) + (Apache License, Version 2.0) J2ObjC Annotations (com.google.j2objc:j2objc-annotations:3.1 - https://github.com/google/j2objc/) + (BSD-3-Clause) Protocol Buffers [Core] (com.google.protobuf:protobuf-java:4.33.4 - https://developers.google.com/protocol-buffers/) + (BSD-3-Clause) Protocol Buffers [Util] (com.google.protobuf:protobuf-java-util:4.33.4 - https://developers.google.com/protocol-buffers/) + (The Apache Software License, Version 2.0) Nimbus Content Type (com.nimbusds:content-type:2.3 - https://bitbucket.org/connect2id/nimbus-content-type) + (The Apache Software License, Version 2.0) Nimbus LangTag (com.nimbusds:lang-tag:1.7 - https://bitbucket.org/connect2id/nimbus-language-tags) + (The Apache Software License, Version 2.0) Nimbus JOSE+JWT (com.nimbusds:nimbus-jose-jwt:10.4 - https://bitbucket.org/connect2id/nimbus-jose-jwt) + (Apache License, version 2.0) OAuth 2.0 SDK with OpenID Connect extensions (com.nimbusds:oauth2-oidc-sdk:11.26.1 - https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions) + (The Apache Software License, Version 2.0) okhttp-logging-interceptor (com.squareup.okhttp3:logging-interceptor:4.12.0 - https://square.github.io/okhttp/) + (The Apache Software License, Version 2.0) okhttp (com.squareup.okhttp3:okhttp:4.12.0 - https://square.github.io/okhttp/) + (The Apache Software License, Version 2.0) okio (com.squareup.okio:okio:3.6.0 - https://github.com/square/okio/) + (The Apache Software License, Version 2.0) okio (com.squareup.okio:okio-jvm:3.16.1 - https://github.com/square/okio/) + (Apache-2.0) config (com.typesafe:config:1.4.1 - https://github.com/lightbend/config) + (Apache-2.0) Apache Commons Codec (commons-codec:commons-codec:1.19.0 - https://commons.apache.org/proper/commons-codec/) + (Apache-2.0) Apache Commons IO (commons-io:commons-io:2.20.0 - https://commons.apache.org/proper/commons-io/) + (Apache-2.0) Apache Commons Logging (commons-logging:commons-logging:1.3.6 - https://commons.apache.org/proper/commons-logging/) + (Apache 2.0) io.grpc:grpc-api (io.grpc:grpc-api:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-context (io.grpc:grpc-context:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-core (io.grpc:grpc-core:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-inprocess (io.grpc:grpc-inprocess:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-netty-shaded (io.grpc:grpc-netty-shaded:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-protobuf (io.grpc:grpc-protobuf:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-protobuf-lite (io.grpc:grpc-protobuf-lite:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-services (io.grpc:grpc-services:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-stub (io.grpc:grpc-stub:1.63.0 - https://github.com/grpc/grpc-java) + (Apache 2.0) io.grpc:grpc-util (io.grpc:grpc-util:1.63.0 - https://github.com/grpc/grpc-java) + (Apache-2.0) Gson on Fire! (io.gsonfire:gson-fire:1.9.0 - http://gsonfire.io) + (The Apache Software License, Version 2.0) client-java (io.kubernetes:client-java:24.0.0 - https://github.com/kubernetes-client/java) + (The Apache Software License, Version 2.0) client-java-api (io.kubernetes:client-java-api:24.0.0 - https://github.com/kubernetes-client/java) + (The Apache Software License, Version 2.0) client-java-fluent (io.kubernetes:client-java-api-fluent:24.0.0 - https://github.com/kubernetes-client/java) + (The Apache Software License, Version 2.0) client-java-extended (io.kubernetes:client-java-extended:24.0.0 - https://github.com/kubernetes-client/java) + (The Apache Software License, Version 2.0) client-java-proto (io.kubernetes:client-java-proto:24.0.0 - https://github.com/kubernetes-client/java) + (The Apache Software License, Version 2.0) context-propagation (io.micrometer:context-propagation:1.2.1 - https://github.com/micrometer-metrics/context-propagation) + (The Apache Software License, Version 2.0) micrometer-commons (io.micrometer:micrometer-commons:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-core (io.micrometer:micrometer-core:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-jakarta9 (io.micrometer:micrometer-jakarta9:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-observation (io.micrometer:micrometer-observation:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-registry-prometheus (io.micrometer:micrometer-registry-prometheus:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-tracing (io.micrometer:micrometer-tracing:1.6.6 - https://github.com/micrometer-metrics/tracing) + (The Apache Software License, Version 2.0) micrometer-tracing-bridge-otel (io.micrometer:micrometer-tracing-bridge-otel:1.6.6 - https://github.com/micrometer-metrics/tracing) + (Apache License, Version 2.0) Netty/Buffer (io.netty:netty-buffer:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Base (io.netty:netty-codec-base:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Classes/Quic (io.netty:netty-codec-classes-quic:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Compression (io.netty:netty-codec-compression:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/DNS (io.netty:netty-codec-dns:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/HTTP (io.netty:netty-codec-http:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/HTTP2 (io.netty:netty-codec-http2:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Http3 (io.netty:netty-codec-http3:4.2.15.Final - https://netty.io/netty-codec-http3/) + (Apache License, Version 2.0) Netty/Codec/Native/Quic (io.netty:netty-codec-native-quic:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Socks (io.netty:netty-codec-socks:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Common (io.netty:netty-common:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Handler (io.netty:netty-handler:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Handler/Proxy (io.netty:netty-handler-proxy:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver (io.netty:netty-resolver:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver/DNS (io.netty:netty-resolver-dns:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver/DNS/Classes/MacOS (io.netty:netty-resolver-dns-classes-macos:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver/DNS/Native/MacOS (io.netty:netty-resolver-dns-native-macos:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport (io.netty:netty-transport:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport/Classes/Epoll (io.netty:netty-transport-classes-epoll:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport/Native/Epoll (io.netty:netty-transport-native-epoll:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.2.15.Final - https://netty.io/) + (The Apache License, Version 2.0) OpenTelemetry Semantic Conventions Java (io.opentelemetry.semconv:opentelemetry-semconv:1.37.0 - https://github.com/open-telemetry/semantic-conventions-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-api:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-context:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-logs:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-metrics:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-trace:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (Apache 2.0) perfmark:perfmark-api (io.perfmark:perfmark-api:0.26.0 - https://github.com/perfmark/perfmark) + (The Apache Software License, Version 2.0) Core functionality for the Reactor Netty library (io.projectreactor.netty:reactor-netty-core:1.3.6 - https://github.com/reactor/reactor-netty) + (The Apache Software License, Version 2.0) HTTP functionality for the Reactor Netty library (io.projectreactor.netty:reactor-netty-http:1.3.6 - https://github.com/reactor/reactor-netty) + (Apache License, Version 2.0) Non-Blocking Reactive Foundation for the JVM (io.projectreactor:reactor-core:3.8.6 - https://github.com/reactor/reactor-core) + (The Apache Software License, Version 2.0) Prometheus Metrics Config (io.prometheus:prometheus-metrics-config:1.4.3 - http://github.com/prometheus/client_java) + (The Apache Software License, Version 2.0) Prometheus Metrics Core (io.prometheus:prometheus-metrics-core:1.4.3 - http://github.com/prometheus/client_java) + (The Apache Software License, Version 2.0) Prometheus Metrics Exposition Formats (io.prometheus:prometheus-metrics-exposition-formats:1.4.3 - http://github.com/prometheus/client_java) + (The Apache Software License, Version 2.0) Prometheus Metrics Exposition Text Formats (io.prometheus:prometheus-metrics-exposition-textformats:1.4.3 - http://github.com/prometheus/client_java) + (The Apache Software License, Version 2.0) Prometheus Metrics Model (io.prometheus:prometheus-metrics-model:1.4.3 - http://github.com/prometheus/client_java) + (The Apache Software License, Version 2.0) Prometheus Metrics Tracer Common (io.prometheus:prometheus-metrics-tracer-common:1.4.3 - http://github.com/prometheus/client_java) + (Apache License 2.0) swagger-annotations-jakarta (io.swagger.core.v3:swagger-annotations-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (Apache License 2.0) swagger-core-jakarta (io.swagger.core.v3:swagger-core-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (Apache License 2.0) swagger-models-jakarta (io.swagger.core.v3:swagger-models-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (Apache License 2.0) swagger-annotations (io.swagger:swagger-annotations:1.6.16 - https://github.com/swagger-api/swagger-core) + (EDL 1.0) Jakarta Activation API (jakarta.activation:jakarta.activation-api:2.1.4 - https://github.com/jakartaee/jaf-api) + (EPL 2.0) (GPL2 w/ CPE) Jakarta Annotations API (jakarta.annotation:jakarta.annotation-api:3.0.0 - https://projects.eclipse.org/projects/ee4j.ca) + (EPL 2.0) (GPL2 w/ CPE) Jakarta Servlet (jakarta.servlet:jakarta.servlet-api:6.1.0 - https://projects.eclipse.org/projects/ee4j.servlet) + (Apache License 2.0) Jakarta Validation API (jakarta.validation:jakarta.validation-api:3.1.1 - https://beanvalidation.org) + (Eclipse Distribution License - v 1.0) Jakarta XML Binding API (jakarta.xml.bind:jakarta.xml.bind-api:4.0.5 - https://github.com/jakartaee/jaxb-api) + (CDDL + GPLv2 with classpath exception) javax.annotation API (javax.annotation:javax.annotation-api:1.3.2 - http://jcp.org/en/jsr/detail?id=250) + (Apache 2.0) gRPC Spring Boot Starter (net.devh:grpc-common-spring-boot:3.1.0.RELEASE - https://github.com/yidongnan/grpc-spring-boot-starter) + (Apache 2.0) gRPC Spring Boot Starter (net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE - https://github.com/yidongnan/grpc-spring-boot-starter) + (The Apache Software License, Version 2.0) net.javacrumbs.shedlock:shedlock-core (net.javacrumbs.shedlock:shedlock-core:7.7.0 - http://nexus.sonatype.org/oss-repository-hosting.html) + (The Apache Software License, Version 2.0) net.javacrumbs.shedlock:shedlock-provider-cassandra (net.javacrumbs.shedlock:shedlock-provider-cassandra:7.7.0 - http://nexus.sonatype.org/oss-repository-hosting.html) + (The Apache Software License, Version 2.0) net.javacrumbs.shedlock:shedlock-spring (net.javacrumbs.shedlock:shedlock-spring:7.7.0 - http://nexus.sonatype.org/oss-repository-hosting.html) + (The Apache Software License, Version 2.0) ASM based accessors helper used by json-smart (net.minidev:accessors-smart:2.6.0 - https://urielch.github.io/) + (The Apache Software License, Version 2.0) JSON Small and Fast Parser (net.minidev:json-smart:2.6.0 - https://urielch.github.io/) + (Apache 2) Apache Cassandra Java Driver - core (org.apache.cassandra:java-driver-core:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - guava shaded dep (org.apache.cassandra:java-driver-guava-shaded:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - Metrics - Micrometer (org.apache.cassandra:java-driver-metrics-micrometer:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - query builder (org.apache.cassandra:java-driver-query-builder:4.19.3 - https://github.com/datastax/java-driver) + (Apache-2.0) Apache Commons Collections (org.apache.commons:commons-collections4:4.5.0 - https://commons.apache.org/proper/commons-collections/) + (Apache-2.0) Apache Commons Compress (org.apache.commons:commons-compress:1.28.0 - https://commons.apache.org/proper/commons-compress/) + (Apache-2.0) Apache Commons Lang (org.apache.commons:commons-lang3:3.20.0 - https://commons.apache.org/proper/commons-lang/) + (Apache-2.0) Apache Log4j API (org.apache.logging.log4j:log4j-api:2.25.4 - https://logging.apache.org/log4j/2.x/) + (Apache-2.0) Log4j API to SLF4J Adapter (org.apache.logging.log4j:log4j-to-slf4j:2.25.4 - https://logging.apache.org/log4j/2.x/) + (Apache License, Version 2.0) tomcat-embed-core (org.apache.tomcat.embed:tomcat-embed-core:11.0.22 - https://tomcat.apache.org/) + (Apache License, Version 2.0) tomcat-embed-el (org.apache.tomcat.embed:tomcat-embed-el:11.0.22 - https://tomcat.apache.org/) + (Apache License, Version 2.0) tomcat-embed-websocket (org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22 - https://tomcat.apache.org/) + (Eclipse Public License - v 2.0) AspectJ Weaver (org.aspectj:aspectjweaver:1.9.25.1 - https://www.eclipse.org/aspectj/) + (The Apache Software License, Version 2.0) jose4j (org.bitbucket.b_c:jose4j:0.9.6 - https://bitbucket.org/b_c/jose4j/) + (Bouncy Castle Licence) Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs (org.bouncycastle:bcpkix-jdk18on:1.80 - https://www.bouncycastle.org/download/bouncy-castle-java/) + (Bouncy Castle Licence) Bouncy Castle Provider (org.bouncycastle:bcprov-jdk18on:1.84 - https://www.bouncycastle.org/download/bouncy-castle-java/) + (Bouncy Castle Licence) Bouncy Castle ASN.1 Extension and Utility APIs (org.bouncycastle:bcutil-jdk18on:1.80.2 - https://www.bouncycastle.org/download/bouncy-castle-java/) + (MIT license) Animal Sniffer Annotations (org.codehaus.mojo:animal-sniffer-annotations:1.23 - https://www.mojohaus.org/animal-sniffer) + (Public Domain, per Creative Commons CC0) (BSD-2-Clause) HdrHistogram (org.hdrhistogram:HdrHistogram:2.2.2 - http://hdrhistogram.github.io/HdrHistogram/) + (Apache License 2.0) Hibernate Validator Engine (org.hibernate.validator:hibernate-validator:9.0.1.Final - https://hibernate.org/validator) + (Apache License 2.0) JBoss Logging 3 (org.jboss.logging:jboss-logging:3.6.3.Final - https://www.jboss.org) + (Apache-2.0) Kotlin Stdlib (org.jetbrains.kotlin:kotlin-stdlib:2.2.21 - https://kotlinlang.org/) + (Apache-2.0) Kotlin Stdlib Jdk7 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21 - https://kotlinlang.org/) + (Apache-2.0) Kotlin Stdlib Jdk8 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21 - https://kotlinlang.org/) + (The Apache Software License, Version 2.0) JetBrains Java Annotations (org.jetbrains:annotations:17.0.0 - https://github.com/JetBrains/java-annotations) + (The Apache License, Version 2.0) JSpecify annotations (org.jspecify:jspecify:1.0.0 - http://jspecify.org/) + (Public Domain, per Creative Commons CC0) LatencyUtils (org.latencyutils:LatencyUtils:2.0.3 - http://latencyutils.github.io/LatencyUtils/) + (BSD-3-Clause) asm (org.ow2.asm:asm:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-analysis (org.ow2.asm:asm-analysis:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-commons (org.ow2.asm:asm-commons:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-tree (org.ow2.asm:asm-tree:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-util (org.ow2.asm:asm-util:9.9 - http://asm.ow2.io/) + (MIT-0) reactive-streams (org.reactivestreams:reactive-streams:1.0.4 - http://www.reactive-streams.org/) + (MIT) JUL to SLF4J bridge (org.slf4j:jul-to-slf4j:2.0.18 - http://www.slf4j.org) + (MIT) SLF4J API Module (org.slf4j:slf4j-api:2.0.18 - http://www.slf4j.org) + (The Apache License, Version 2.0) springdoc-openapi-starter-common (org.springdoc:springdoc-openapi-starter-common:3.0.3 - https://springdoc.org/) + (The Apache License, Version 2.0) springdoc-openapi-starter-webmvc-api (org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3 - https://springdoc.org/) + (Apache License, Version 2.0) spring-boot (org.springframework.boot:spring-boot:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-actuator (org.springframework.boot:spring-boot-actuator:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-actuator-autoconfigure (org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-autoconfigure (org.springframework.boot:spring-boot-autoconfigure:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-cassandra (org.springframework.boot:spring-boot-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-data-cassandra (org.springframework.boot:spring-boot-data-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-data-commons (org.springframework.boot:spring-boot-data-commons:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-health (org.springframework.boot:spring-boot-health:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-client (org.springframework.boot:spring-boot-http-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-codec (org.springframework.boot:spring-boot-http-codec:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-converter (org.springframework.boot:spring-boot-http-converter:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-jackson (org.springframework.boot:spring-boot-jackson:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-metrics (org.springframework.boot:spring-boot-micrometer-metrics:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-observation (org.springframework.boot:spring-boot-micrometer-observation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-tracing (org.springframework.boot:spring-boot-micrometer-tracing:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-tracing-opentelemetry (org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-opentelemetry (org.springframework.boot:spring-boot-opentelemetry:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-persistence (org.springframework.boot:spring-boot-persistence:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security (org.springframework.boot:spring-boot-security:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security-oauth2-client (org.springframework.boot:spring-boot-security-oauth2-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security-oauth2-resource-server (org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-servlet (org.springframework.boot:spring-boot-servlet:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter (org.springframework.boot:spring-boot-starter:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-actuator (org.springframework.boot:spring-boot-starter-actuator:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-aspectj (org.springframework.boot:spring-boot-starter-aspectj:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-data-cassandra (org.springframework.boot:spring-boot-starter-data-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-jackson (org.springframework.boot:spring-boot-starter-jackson:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-logging (org.springframework.boot:spring-boot-starter-logging:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-micrometer-metrics (org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security (org.springframework.boot:spring-boot-starter-security:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security-oauth2-client (org.springframework.boot:spring-boot-starter-security-oauth2-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security-oauth2-resource-server (org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-tomcat (org.springframework.boot:spring-boot-starter-tomcat:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-tomcat-runtime (org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-validation (org.springframework.boot:spring-boot-starter-validation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-webmvc (org.springframework.boot:spring-boot-starter-webmvc:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-tomcat (org.springframework.boot:spring-boot-tomcat:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-validation (org.springframework.boot:spring-boot-validation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-web-server (org.springframework.boot:spring-boot-web-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-webclient (org.springframework.boot:spring-boot-webclient:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-webmvc (org.springframework.boot:spring-boot-webmvc:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) Spring Cloud Commons (org.springframework.cloud:spring-cloud-commons:5.0.2 - https://projects.spring.io/spring-cloud/spring-cloud-commons/) + (Apache License, Version 2.0) Spring Cloud Context (org.springframework.cloud:spring-cloud-context:5.0.2 - https://projects.spring.io/spring-cloud/spring-cloud-context/) + (Apache License, Version 2.0) spring-cloud-kubernetes-client-autoconfig (org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig:5.0.2 - https://cloud.spring.io/spring-cloud-kubernetes-client-autoconfig) + (Apache License, Version 2.0) spring-cloud-kubernetes-client-config (org.springframework.cloud:spring-cloud-kubernetes-client-config:5.0.2 - https://cloud.spring.io/spring-cloud-kubernetes-client-config) + (Apache License, Version 2.0) spring-cloud-kubernetes-commons (org.springframework.cloud:spring-cloud-kubernetes-commons:5.0.2 - https://cloud.spring.io/spring-cloud-kubernetes-commons) + (Apache License, Version 2.0) spring-cloud-starter (org.springframework.cloud:spring-cloud-starter:5.0.2 - https://projects.spring.io/spring-cloud) + (Apache License, Version 2.0) spring-cloud-starter-bootstrap (org.springframework.cloud:spring-cloud-starter-bootstrap:5.0.2 - https://projects.spring.io/spring-cloud) + (Apache License, Version 2.0) Spring Cloud Kubernetes :: Kubernetes Native Starter :: Config (org.springframework.cloud:spring-cloud-starter-kubernetes-client-config:5.0.2 - https://cloud.spring.io/spring-cloud-starter-kubernetes-client-config) + (Apache License, Version 2.0) Spring Data for Apache Cassandra Core (org.springframework.data:spring-data-cassandra:5.0.6 - https://projects.spring.io/spring-data-cassandra/) + (Apache License, Version 2.0) Spring Data Core (org.springframework.data:spring-data-commons:4.0.6 - https://spring.io/projects/spring-data) + (Apache 2.0) Spring Retry (org.springframework.retry:spring-retry:2.0.13 - https://github.com/spring-projects/spring-retry) + (Apache License, Version 2.0) spring-security-config (org.springframework.security:spring-security-config:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-core (org.springframework.security:spring-security-core:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-crypto (org.springframework.security:spring-security-crypto:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-client (org.springframework.security:spring-security-oauth2-client:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-core (org.springframework.security:spring-security-oauth2-core:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-jose (org.springframework.security:spring-security-oauth2-jose:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-resource-server (org.springframework.security:spring-security-oauth2-resource-server:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-web (org.springframework.security:spring-security-web:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) Spring AOP (org.springframework:spring-aop:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Beans (org.springframework:spring-beans:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Context (org.springframework:spring-context:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Context Support (org.springframework:spring-context-support:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Core (org.springframework:spring-core:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Expression Language (SpEL) (org.springframework:spring-expression:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Transaction (org.springframework:spring-tx:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Web (org.springframework:spring-web:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring WebFlux (org.springframework:spring-webflux:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Web MVC (org.springframework:spring-webmvc:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) SnakeYAML (org.yaml:snakeyaml:2.5 - https://bitbucket.org/snakeyaml/snakeyaml) + (Apache License, Version 2.0) AWS Java SDK :: Annotations (software.amazon.awssdk:annotations:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Checksums (software.amazon.awssdk:checksums:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Checksums SPI (software.amazon.awssdk:checksums-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Endpoints SPI (software.amazon.awssdk:endpoints-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth AWS (software.amazon.awssdk:http-auth-aws:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth SPI (software.amazon.awssdk:http-auth-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Client Interface (software.amazon.awssdk:http-client-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Identity SPI (software.amazon.awssdk:identity-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Json Utils (software.amazon.awssdk:json-utils:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Metrics SPI (software.amazon.awssdk:metrics-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Profiles (software.amazon.awssdk:profiles:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Regions (software.amazon.awssdk:regions:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Retries (software.amazon.awssdk:retries:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Retries API (software.amazon.awssdk:retries-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: SDK Core (software.amazon.awssdk:sdk-core:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Third Party :: Jackson-core (software.amazon.awssdk:third-party-jackson-core:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Utilities (software.amazon.awssdk:utils:2.40.1 - https://aws.amazon.com/sdkforjava) + (The Apache Software License, Version 2.0) Jackson-core (tools.jackson.core:jackson-core:3.1.4 - https://github.com/FasterXML/jackson-core) + (The Apache Software License, Version 2.0) jackson-databind (tools.jackson.core:jackson-databind:3.1.4 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson module: Blackbird (tools.jackson.module:jackson-module-blackbird:3.1.4 - https://github.com/FasterXML/jackson-modules-base) diff --git a/src/control-plane-services/cloud-tasks/bazel-java-ci.json b/src/control-plane-services/cloud-tasks/bazel-java-ci.json new file mode 100644 index 000000000..cd805f6fa --- /dev/null +++ b/src/control-plane-services/cloud-tasks/bazel-java-ci.json @@ -0,0 +1,6 @@ +{ + "ci_lane": "docker-host", + "component_kind": "java-service", + "id": "cloud-tasks", + "tests_skip": false +} diff --git a/src/control-plane-services/cloud-tasks/local_env/cassandra/cassandra.yaml b/src/control-plane-services/cloud-tasks/local_env/cassandra/cassandra.yaml new file mode 100644 index 000000000..7e5c60f7b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/cassandra/cassandra.yaml @@ -0,0 +1,1424 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Cassandra storage config YAML + +# NOTE: +# See https://cassandra.apache.org/doc/latest/configuration/ for +# full explanations of configuration directives +# /NOTE + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +cluster_name: 'Test Cluster' + +# This defines the number of tokens randomly assigned to this node on the ring +# The more tokens, relative to other nodes, the larger the proportion of data +# that this node will store. You probably want all nodes to have the same number +# of tokens assuming they have equal hardware capability. +# +# If you leave this unspecified, Cassandra will use the default of 1 token for legacy compatibility, +# and will use the initial_token as described below. +# +# Specifying initial_token will override this setting on the node's initial start, +# on subsequent starts, this setting will apply even if initial token is set. +# +num_tokens: 256 + +# Triggers automatic allocation of num_tokens tokens for this node. The allocation +# algorithm attempts to choose tokens in a way that optimizes replicated load over +# the nodes in the datacenter for the replica factor. +# +# The load assigned to each node will be close to proportional to its number of +# vnodes. +# +# Only supported with the Murmur3Partitioner. + +# Replica factor is determined via the replication strategy used by the specified +# keyspace. +# allocate_tokens_for_keyspace: KEYSPACE + +# Replica factor is explicitly set, regardless of keyspace or datacenter. +# This is the replica factor within the datacenter, like NTS. +# allocate_tokens_for_local_replication_factor: 3 + +# initial_token allows you to specify tokens manually. While you can use it with +# vnodes (num_tokens > 1, above) -- in which case you should provide a +# comma-separated list -- it's primarily used when adding nodes to legacy clusters +# that do not have vnodes enabled. +# initial_token: + +# May either be "true" or "false" to enable globally +hinted_handoff_enabled: true + +# When hinted_handoff_enabled is true, a black list of data centers that will not +# perform hinted handoff +# hinted_handoff_disabled_datacenters: +# - DC1 +# - DC2 + +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +max_hint_window_in_ms: 10800000 # 3 hours + +# Maximum throttle in KBs per second, per delivery thread. This will be +# reduced proportionally to the number of nodes in the cluster. (If there +# are two nodes in the cluster, each delivery thread will use the maximum +# rate; if there are three, each will throttle to half of the maximum, +# since we expect two nodes to be delivering hints simultaneously.) +hinted_handoff_throttle_in_kb: 1024 + +# Number of threads with which to deliver hints; +# Consider increasing this number when you have multi-dc deployments, since +# cross-dc handoff tends to be slower +max_hints_delivery_threads: 2 + +# Directory where Cassandra should store hints. +# If not set, the default directory is $CASSANDRA_HOME/data/hints. +# hints_directory: /var/lib/cassandra/hints + +# How often hints should be flushed from the internal buffers to disk. +# Will *not* trigger fsync. +hints_flush_period_in_ms: 10000 + +# Maximum size for a single hints file, in megabytes. +max_hints_file_size_in_mb: 128 + +# Compression to apply to the hint files. If omitted, hints files +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +#hints_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# Maximum throttle in KBs per second, total. This will be +# reduced proportionally to the number of nodes in the cluster. +batchlog_replay_throttle_in_kb: 1024 + +# Authentication backend, implementing IAuthenticator; used to identify users +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthenticator, +# PasswordAuthenticator}. +# +# - AllowAllAuthenticator performs no checks - set it to disable authentication. +# - PasswordAuthenticator relies on username/password pairs to authenticate +# users. It keeps usernames and hashed passwords in system_auth.roles table. +# Please increase system_auth keyspace replication factor if you use this authenticator. +# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below) +authenticator: PasswordAuthenticator + +# Authorization backend, implementing IAuthorizer; used to limit access/provide permissions +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, +# CassandraAuthorizer}. +# +# - AllowAllAuthorizer allows any action to any user - set it to disable authorization. +# - CassandraAuthorizer stores permissions in system_auth.role_permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +authorizer: AllowAllAuthorizer + +# Part of the Authentication & Authorization backend, implementing IRoleManager; used +# to maintain grants and memberships between roles. +# Out of the box, Cassandra provides org.apache.cassandra.auth.CassandraRoleManager, +# which stores role information in the system_auth keyspace. Most functions of the +# IRoleManager require an authenticated login, so unless the configured IAuthenticator +# actually implements authentication, most of this functionality will be unavailable. +# +# - CassandraRoleManager stores role data in the system_auth keyspace. Please +# increase system_auth keyspace replication factor if you use this role manager. +role_manager: CassandraRoleManager + +# Network authorization backend, implementing INetworkAuthorizer; used to restrict user +# access to certain DCs +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllNetworkAuthorizer, +# CassandraNetworkAuthorizer}. +# +# - AllowAllNetworkAuthorizer allows access to any DC to any user - set it to disable authorization. +# - CassandraNetworkAuthorizer stores permissions in system_auth.network_permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +network_authorizer: AllowAllNetworkAuthorizer + +# Validity period for roles cache (fetching granted roles can be an expensive +# operation depending on the role manager, CassandraRoleManager is one example) +# Granted roles are cached for authenticated sessions in AuthenticatedUser and +# after the period specified here, become eligible for (async) reload. +# Defaults to 2000, set to 0 to disable caching entirely. +# Will be disabled automatically for AllowAllAuthenticator. +roles_validity_in_ms: 2000 + +# Refresh interval for roles cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If roles_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as roles_validity_in_ms. +# roles_update_interval_in_ms: 2000 + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 2000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +permissions_validity_in_ms: 2000 + +# Refresh interval for permissions cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If permissions_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as permissions_validity_in_ms. +# permissions_update_interval_in_ms: 2000 + +# Validity period for credentials cache. This cache is tightly coupled to +# the provided PasswordAuthenticator implementation of IAuthenticator. If +# another IAuthenticator implementation is configured, this cache will not +# be automatically used and so the following settings will have no effect. +# Please note, credentials are cached in their encrypted form, so while +# activating this cache may reduce the number of queries made to the +# underlying table, it may not bring a significant reduction in the +# latency of individual authentication attempts. +# Defaults to 2000, set to 0 to disable credentials caching. +credentials_validity_in_ms: 2000 + +# Refresh interval for credentials cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If credentials_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as credentials_validity_in_ms. +# credentials_update_interval_in_ms: 2000 + +# The partitioner is responsible for distributing groups of rows (by +# partition key) across nodes in the cluster. The partitioner can NOT be +# changed without reloading all data. If you are adding nodes or upgrading, +# you should set this to the same partitioner that you are currently using. +# +# The default partitioner is the Murmur3Partitioner. Older partitioners +# such as the RandomPartitioner, ByteOrderedPartitioner, and +# OrderPreservingPartitioner have been included for backward compatibility only. +# For new clusters, you should NOT change this value. +# +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# Directories where Cassandra should store data on disk. If multiple +# directories are specified, Cassandra will spread data evenly across +# them by partitioning the token ranges. +# If not set, the default directory is $CASSANDRA_HOME/data/data. +# data_file_directories: +# - /var/lib/cassandra/data + +# commit log. when running on magnetic HDD, this should be a +# separate spindle than the data directories. +# If not set, the default directory is $CASSANDRA_HOME/data/commitlog. +# commitlog_directory: /var/lib/cassandra/commitlog + +# Enable / disable CDC functionality on a per-node basis. This modifies the logic used +# for write path allocation rejection (standard: never reject. cdc: reject Mutation +# containing a CDC-enabled table if at space limit in cdc_raw_directory). +cdc_enabled: false + +# CommitLogSegments are moved to this directory on flush if cdc_enabled: true and the +# segment contains mutations for a CDC-enabled table. This should be placed on a +# separate spindle than the data directories. If not set, the default directory is +# $CASSANDRA_HOME/data/cdc_raw. +# cdc_raw_directory: /var/lib/cassandra/cdc_raw + +# Policy for data disk failures: +# +# die +# shut down gossip and client transports and kill the JVM for any fs errors or +# single-sstable errors, so the node can be replaced. +# +# stop_paranoid +# shut down gossip and client transports even for single-sstable errors, +# kill the JVM for errors during startup. +# +# stop +# shut down gossip and client transports, leaving the node effectively dead, but +# can still be inspected via JMX, kill the JVM for errors during startup. +# +# best_effort +# stop using the failed disk and respond to requests based on +# remaining available sstables. This means you WILL see obsolete +# data at CL.ONE! +# +# ignore +# ignore fatal errors and let requests fail, as in pre-1.2 Cassandra +disk_failure_policy: stop + +# Policy for commit disk failures: +# +# die +# shut down the node and kill the JVM, so the node can be replaced. +# +# stop +# shut down the node, leaving the node effectively dead, but +# can still be inspected via JMX. +# +# stop_commit +# shutdown the commit log, letting writes collect but +# continuing to service reads, as in pre-2.0.5 Cassandra +# +# ignore +# ignore fatal errors and let the batches fail +commit_failure_policy: stop + +# Maximum size of the native protocol prepared statement cache +# +# Valid values are either "auto" (omitting the value) or a value greater 0. +# +# Note that specifying a too large value will result in long running GCs and possbily +# out-of-memory errors. Keep the value at a small fraction of the heap. +# +# If you constantly see "prepared statements discarded in the last minute because +# cache limit reached" messages, the first step is to investigate the root cause +# of these messages and check whether prepared statements are used correctly - +# i.e. use bind markers for variable parts. +# +# Do only change the default value, if you really have more prepared statements than +# fit in the cache. In most cases it is not neccessary to change this value. +# Constantly re-preparing statements is a performance penalty. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +prepared_statements_cache_size_mb: + +# Maximum size of the key cache in memory. +# +# Each key cache hit saves 1 seek and each row cache hit saves 2 seeks at the +# minimum, sometimes more. The key cache is fairly tiny for the amount of +# time it saves, so it's worthwhile to use it at large numbers. +# The row cache saves even more time, but must contain the entire row, +# so it is extremely space-intensive. It's best to only use the +# row cache if you have hot rows or static rows. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(5% of Heap (in MB), 100MB)). Set to 0 to disable key cache. +key_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the key cache. Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 14400 or 4 hours. +key_cache_save_period: 14400 + +# Number of keys from the key cache to save +# Disabled by default, meaning all keys are going to be saved +# key_cache_keys_to_save: 100 + +# Row cache implementation class name. Available implementations: +# +# org.apache.cassandra.cache.OHCProvider +# Fully off-heap row cache implementation (default). +# +# org.apache.cassandra.cache.SerializingCacheProvider +# This is the row cache implementation availabile +# in previous releases of Cassandra. +# row_cache_class_name: org.apache.cassandra.cache.OHCProvider + +# Maximum size of the row cache in memory. +# Please note that OHC cache implementation requires some additional off-heap memory to manage +# the map structures and some in-flight memory during operations before/after cache entries can be +# accounted against the cache capacity. This overhead is usually small compared to the whole capacity. +# Do not specify more memory that the system can afford in the worst usual situation and leave some +# headroom for OS block level cache. Do never allow your system to swap. +# +# Default value is 0, to disable row caching. +row_cache_size_in_mb: 0 + +# Duration in seconds after which Cassandra should save the row cache. +# Caches are saved to saved_caches_directory as specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 0 to disable saving the row cache. +row_cache_save_period: 0 + +# Number of keys from the row cache to save. +# Specify 0 (which is the default), meaning all keys are going to be saved +# row_cache_keys_to_save: 100 + +# Maximum size of the counter cache in memory. +# +# Counter cache helps to reduce counter locks' contention for hot counter cells. +# In case of RF = 1 a counter cache hit will cause Cassandra to skip the read before +# write entirely. With RF > 1 a counter cache hit will still help to reduce the duration +# of the lock hold, helping with hot counter cell updates, but will not allow skipping +# the read entirely. Only the local (clock, count) tuple of a counter cell is kept +# in memory, not the whole counter, so it's relatively cheap. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(2.5% of Heap (in MB), 50MB)). Set to 0 to disable counter cache. +# NOTE: if you perform counter deletes and rely on low gcgs, you should disable the counter cache. +counter_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the counter cache (keys only). Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Default is 7200 or 2 hours. +counter_cache_save_period: 7200 + +# Number of keys from the counter cache to save +# Disabled by default, meaning all keys are going to be saved +# counter_cache_keys_to_save: 100 + +# saved caches +# If not set, the default directory is $CASSANDRA_HOME/data/saved_caches. +# saved_caches_directory: /var/lib/cassandra/saved_caches + +# commitlog_sync may be either "periodic", "group", or "batch." +# +# When in batch mode, Cassandra won't ack writes until the commit log +# has been flushed to disk. Each incoming write will trigger the flush task. +# commitlog_sync_batch_window_in_ms is a deprecated value. Previously it had +# almost no value, and is being removed. +# +# commitlog_sync_batch_window_in_ms: 2 +# +# group mode is similar to batch mode, where Cassandra will not ack writes +# until the commit log has been flushed to disk. The difference is group +# mode will wait up to commitlog_sync_group_window_in_ms between flushes. +# +# commitlog_sync_group_window_in_ms: 1000 +# +# the default option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# When in periodic commitlog mode, the number of milliseconds to block writes +# while waiting for a slow disk flush to complete. +# periodic_commitlog_sync_lag_block_in_ms: + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +# Max mutation size is also configurable via max_mutation_size_in_kb setting in +# cassandra.yaml. The default is half the size commitlog_segment_size_in_mb * 1024. +# This should be positive and less than 2048. +# +# NOTE: If max_mutation_size_in_kb is set explicitly then commitlog_segment_size_in_mb must +# be set to at least twice the size of max_mutation_size_in_kb / 1024 +# +commitlog_segment_size_in_mb: 32 + +# Compression to apply to the commit log. If omitted, the commit log +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +# commitlog_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# Compression to apply to SSTables as they flush for compressed tables. +# Note that tables without compression enabled do not respect this flag. +# +# As high ratio compressors like LZ4HC, Zstd, and Deflate can potentially +# block flushes for too long, the default is to flush with a known fast +# compressor in those cases. Options are: +# +# none : Flush without compressing blocks but while still doing checksums. +# fast : Flush with a fast compressor. If the table is already using a +# fast compressor that compressor is used. +# table: Always flush with the same compressor that the table uses. This +# was the pre 4.0 behavior. +# +# flush_compression: fast + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "localhost" + +# For workloads with more data than can fit in memory, Cassandra's +# bottleneck will be reads that need to fetch data from +# disk. "concurrent_reads" should be set to (16 * number_of_drives) in +# order to allow the operations to enqueue low enough in the stack +# that the OS and drives can reorder them. Same applies to +# "concurrent_counter_writes", since counter writes read the current +# values before incrementing and writing them back. +# +# On the other hand, since writes are almost never IO bound, the ideal +# number of "concurrent_writes" is dependent on the number of cores in +# your system; (8 * number_of_cores) is a good rule of thumb. +concurrent_reads: 32 +concurrent_writes: 32 +concurrent_counter_writes: 32 + +# For materialized view writes, as there is a read involved, so this should +# be limited by the less of concurrent reads or concurrent writes. +concurrent_materialized_view_writes: 32 + +# Maximum memory to use for inter-node and client-server networking buffers. +# +# Defaults to the smaller of 1/16 of heap or 128MB. This pool is allocated off-heap, +# so is in addition to the memory allocated for heap. The cache also has on-heap +# overhead which is roughly 128 bytes per chunk (i.e. 0.2% of the reserved size +# if the default 64k chunk size is used). +# Memory is only allocated when needed. +# networking_cache_size_in_mb: 128 + +# Enable the sstable chunk cache. The chunk cache will store recently accessed +# sections of the sstable in-memory as uncompressed buffers. +# file_cache_enabled: false + +# Maximum memory to use for sstable chunk cache and buffer pooling. +# 32MB of this are reserved for pooling buffers, the rest is used for chunk cache +# that holds uncompressed sstable chunks. +# Defaults to the smaller of 1/4 of heap or 512MB. This pool is allocated off-heap, +# so is in addition to the memory allocated for heap. The cache also has on-heap +# overhead which is roughly 128 bytes per chunk (i.e. 0.2% of the reserved size +# if the default 64k chunk size is used). +# Memory is only allocated when needed. +# file_cache_size_in_mb: 512 + +# Flag indicating whether to allocate on or off heap when the sstable buffer +# pool is exhausted, that is when it has exceeded the maximum memory +# file_cache_size_in_mb, beyond which it will not cache buffers but allocate on request. + +# buffer_pool_use_heap_if_exhausted: true + +# The strategy for optimizing disk read +# Possible values are: +# ssd (for solid state disks, the default) +# spinning (for spinning disks) +# disk_optimization_strategy: ssd + +# Total permitted memory to use for memtables. Cassandra will stop +# accepting writes when the limit is exceeded until a flush completes, +# and will trigger a flush based on memtable_cleanup_threshold +# If omitted, Cassandra will set both to 1/4 the size of the heap. +# memtable_heap_space_in_mb: 2048 +# memtable_offheap_space_in_mb: 2048 + +# memtable_cleanup_threshold is deprecated. The default calculation +# is the only reasonable choice. See the comments on memtable_flush_writers +# for more information. +# +# Ratio of occupied non-flushing memtable size to total permitted size +# that will trigger a flush of the largest memtable. Larger mct will +# mean larger flushes and hence less compaction, but also less concurrent +# flush activity which can make it difficult to keep your disks fed +# under heavy write load. +# +# memtable_cleanup_threshold defaults to 1 / (memtable_flush_writers + 1) +# memtable_cleanup_threshold: 0.11 + +# Specify the way Cassandra allocates and manages memtable memory. +# Options are: +# +# heap_buffers +# on heap nio buffers +# +# offheap_buffers +# off heap (direct) nio buffers +# +# offheap_objects +# off heap objects +memtable_allocation_type: heap_buffers + +# Limit memory usage for Merkle tree calculations during repairs. The default +# is 1/16th of the available heap. The main tradeoff is that smaller trees +# have less resolution, which can lead to over-streaming data. If you see heap +# pressure during repairs, consider lowering this, but you cannot go below +# one megabyte. If you see lots of over-streaming, consider raising +# this or using subrange repair. +# +# For more details see https://issues.apache.org/jira/browse/CASSANDRA-14096. +# +# repair_session_space_in_mb: + +# Total space to use for commit logs on disk. +# +# If space gets above this value, Cassandra will flush every dirty CF +# in the oldest segment and remove it. So a small total commitlog space +# will tend to cause more flush activity on less-active columnfamilies. +# +# The default value is the smaller of 8192, and 1/4 of the total space +# of the commitlog volume. +# +# commitlog_total_space_in_mb: 8192 + +# This sets the number of memtable flush writer threads per disk +# as well as the total number of memtables that can be flushed concurrently. +# These are generally a combination of compute and IO bound. +# +# Memtable flushing is more CPU efficient than memtable ingest and a single thread +# can keep up with the ingest rate of a whole server on a single fast disk +# until it temporarily becomes IO bound under contention typically with compaction. +# At that point you need multiple flush threads. At some point in the future +# it may become CPU bound all the time. +# +# You can tell if flushing is falling behind using the MemtablePool.BlockedOnAllocation +# metric which should be 0, but will be non-zero if threads are blocked waiting on flushing +# to free memory. +# +# memtable_flush_writers defaults to two for a single data directory. +# This means that two memtables can be flushed concurrently to the single data directory. +# If you have multiple data directories the default is one memtable flushing at a time +# but the flush will use a thread per data directory so you will get two or more writers. +# +# Two is generally enough to flush on a fast disk [array] mounted as a single data directory. +# Adding more flush writers will result in smaller more frequent flushes that introduce more +# compaction overhead. +# +# There is a direct tradeoff between number of memtables that can be flushed concurrently +# and flush size and frequency. More is not better you just need enough flush writers +# to never stall waiting for flushing to free memory. +# +#memtable_flush_writers: 2 + +# Total space to use for change-data-capture logs on disk. +# +# If space gets above this value, Cassandra will throw WriteTimeoutException +# on Mutations including tables with CDC enabled. A CDCCompactor is responsible +# for parsing the raw CDC logs and deleting them when parsing is completed. +# +# The default value is the min of 4096 mb and 1/8th of the total space +# of the drive where cdc_raw_directory resides. +# cdc_total_space_in_mb: 4096 + +# When we hit our cdc_raw limit and the CDCCompactor is either running behind +# or experiencing backpressure, we check at the following interval to see if any +# new space for cdc-tracked tables has been made available. Default to 250ms +# cdc_free_space_check_interval_ms: 250 + +# A fixed memory pool size in MB for for SSTable index summaries. If left +# empty, this will default to 5% of the heap size. If the memory usage of +# all index summaries exceeds this limit, SSTables with low read rates will +# shrink their index summaries in order to meet this limit. However, this +# is a best-effort process. In extreme conditions Cassandra may need to use +# more than this amount of memory. +index_summary_capacity_in_mb: + +# How frequently index summaries should be resampled. This is done +# periodically to redistribute memory from the fixed-size pool to sstables +# proportional their recent read rates. Setting to -1 will disable this +# process, leaving existing index summaries at their current sampling level. +index_summary_resize_interval_in_minutes: 60 + +# Whether to, when doing sequential writing, fsync() at intervals in +# order to force the operating system to flush the dirty +# buffers. Enable this to avoid sudden dirty buffer flushing from +# impacting read latencies. Almost always a good idea on SSDs; not +# necessarily on platters. +trickle_fsync: false +trickle_fsync_interval_in_kb: 10240 + +# TCP port, for commands and data +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +storage_port: 7000 + +# SSL port, for legacy encrypted communication. This property is unused unless enabled in +# server_encryption_options (see below). As of cassandra 4.0, this property is deprecated +# as a single port can be used for either/both secure and insecure connections. +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +ssl_storage_port: 7001 + +# Address or interface to bind to and tell other Cassandra nodes to connect to. +# You _must_ change this if you want multiple nodes to be able to communicate! +# +# Set listen_address OR listen_interface, not both. +# +# Leaving it blank leaves it up to InetAddress.getLocalHost(). This +# will always do the Right Thing _if_ the node is properly configured +# (hostname, name resolution, etc), and the Right Thing is to use the +# address associated with the hostname (it might not be). If unresolvable +# it will fall back to InetAddress.getLoopbackAddress(), which is wrong for production systems. +# +# Setting listen_address to 0.0.0.0 is always wrong. +# +listen_address: localhost + +# Set listen_address OR listen_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# listen_interface: eth0 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using listen_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# listen_interface_prefer_ipv6: false + +# Address to broadcast to other Cassandra nodes +# Leaving this blank will set it to the same value as listen_address +broadcast_address: localhost + +# When using multiple physical network interfaces, set this +# to true to listen on broadcast_address in addition to +# the listen_address, allowing nodes to communicate in both +# interfaces. +# Ignore this property if the network configuration automatically +# routes between the public and private networks such as EC2. +# listen_on_broadcast_address: false + +# Internode authentication backend, implementing IInternodeAuthenticator; +# used to allow/disallow connections from peer nodes. +# internode_authenticator: org.apache.cassandra.auth.AllowAllInternodeAuthenticator + +# Whether to start the native transport server. +# The address on which the native transport is bound is defined by rpc_address. +start_native_transport: true +# port for the CQL native transport to listen for clients on +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +native_transport_port: 9042 +# Enabling native transport encryption in client_encryption_options allows you to either use +# encryption for the standard port or to use a dedicated, additional port along with the unencrypted +# standard native_transport_port. +# Enabling client encryption and keeping native_transport_port_ssl disabled will use encryption +# for native_transport_port. Setting native_transport_port_ssl to a different value +# from native_transport_port will use encryption for native_transport_port_ssl while +# keeping native_transport_port unencrypted. +# native_transport_port_ssl: 9142 +# The maximum threads for handling requests (note that idle threads are stopped +# after 30 seconds so there is not corresponding minimum setting). +# native_transport_max_threads: 128 +# +# The maximum size of allowed frame. Frame (requests) larger than this will +# be rejected as invalid. The default is 256MB. If you're changing this parameter, +# you may want to adjust max_value_size_in_mb accordingly. This should be positive and less than 2048. +# native_transport_max_frame_size_in_mb: 256 + +# If checksumming is enabled as a protocol option, denotes the size of the chunks into which frame +# are bodies will be broken and checksummed. +# native_transport_frame_block_size_in_kb: 32 + +# The maximum number of concurrent client connections. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections: -1 + +# The maximum number of concurrent client connections per source ip. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections_per_ip: -1 + +# Controls whether Cassandra honors older, yet currently supported, protocol versions. +# The default is true, which means all supported protocols will be honored. +native_transport_allow_older_protocols: true + +# Controls when idle client connections are closed. Idle connections are ones that had neither reads +# nor writes for a time period. +# +# Clients may implement heartbeats by sending OPTIONS native protocol message after a timeout, which +# will reset idle timeout timer on the server side. To close idle client connections, corresponding +# values for heartbeat intervals have to be set on the client side. +# +# Idle connection timeouts are disabled by default. +# native_transport_idle_timeout_in_ms: 60000 + +# The address or interface to bind the native transport server to. +# +# Set rpc_address OR rpc_interface, not both. +# +# Leaving rpc_address blank has the same effect as on listen_address +# (i.e. it will be based on the configured hostname of the node). +# +# Note that unlike listen_address, you can specify 0.0.0.0, but you must also +# set broadcast_rpc_address to a value other than 0.0.0.0. +# +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +rpc_address: 0.0.0.0 + +# Set rpc_address OR rpc_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# rpc_interface: eth1 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using rpc_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# rpc_interface_prefer_ipv6: false + +# RPC address to broadcast to drivers and other Cassandra nodes. This cannot +# be set to 0.0.0.0. If left blank, this will be set to the value of +# rpc_address. If rpc_address is set to 0.0.0.0, broadcast_rpc_address must +# be set. +broadcast_rpc_address: localhost + +# enable or disable keepalive on rpc/native connections +rpc_keepalive: true + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# See also: +# /proc/sys/net/core/wmem_max +# /proc/sys/net/core/rmem_max +# /proc/sys/net/ipv4/tcp_wmem +# /proc/sys/net/ipv4/tcp_wmem +# and 'man tcp' +# internode_send_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# internode_recv_buff_size_in_bytes: + +# Set to true to have Cassandra create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# keyspace data. Removing these links is the operator's +# responsibility. +incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Cassandra won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +auto_snapshot: true + +# Granularity of the collation index of rows within a partition. +# Increase if your rows are large, or if you have a very large +# number of rows per partition. The competing goals are these: +# +# - a smaller granularity means more index entries are generated +# and looking up rows withing the partition by collation column +# is faster +# - but, Cassandra will keep the collation index in memory for hot +# rows (as part of the key cache), so a larger granularity means +# you can cache more hot rows +column_index_size_in_kb: 64 + +# Per sstable indexed key cache entries (the collation index in memory +# mentioned above) exceeding this size will not be held on heap. +# This means that only partition information is held on heap and the +# index entries are read from disk. +# +# Note that this size refers to the size of the +# serialized index information and not the size of the partition. +column_index_cache_size_in_kb: 2 + +# Number of simultaneous compactions to allow, NOT including +# validation "compactions" for anti-entropy repair. Simultaneous +# compactions can help preserve read performance in a mixed read/write +# workload, by mitigating the tendency of small sstables to accumulate +# during a single long running compactions. The default is usually +# fine and if you experience problems with compaction running too +# slowly or too fast, you should look at +# compaction_throughput_mb_per_sec first. +# +# concurrent_compactors defaults to the smaller of (number of disks, +# number of cores), with a minimum of 2 and a maximum of 8. +# +# If your data directories are backed by SSD, you should increase this +# to the number of cores. +#concurrent_compactors: 1 + +# Number of simultaneous repair validations to allow. If not set or set to +# a value less than 1, it defaults to the value of concurrent_compactors. +# To set a value greeater than concurrent_compactors at startup, the system +# property cassandra.allow_unlimited_concurrent_validations must be set to +# true. To dynamically resize to a value > concurrent_compactors on a running +# node, first call the bypassConcurrentValidatorsLimit method on the +# org.apache.cassandra.db:type=StorageService mbean +# concurrent_validations: 0 + +# Number of simultaneous materialized view builder tasks to allow. +concurrent_materialized_view_builders: 1 + +# Throttles compaction to the given total throughput across the entire +# system. The faster you insert data, the faster you need to compact in +# order to keep the sstable count down, but in general, setting this to +# 16 to 32 times the rate you are inserting data is more than sufficient. +# Setting this to 0 disables throttling. Note that this accounts for all types +# of compaction, including validation compaction (building Merkle trees +# for repairs). +compaction_throughput_mb_per_sec: 64 + +# When compacting, the replacement sstable(s) can be opened before they +# are completely written, and used in place of the prior sstables for +# any range that has been written. This helps to smoothly transfer reads +# between the sstables, reducing page cache churn and keeping hot rows hot +sstable_preemptive_open_interval_in_mb: 50 + +# When enabled, permits Cassandra to zero-copy stream entire eligible +# SSTables between nodes, including every component. +# This speeds up the network transfer significantly subject to +# throttling specified by stream_throughput_outbound_megabits_per_sec. +# Enabling this will reduce the GC pressure on sending and receiving node. +# When unset, the default is enabled. While this feature tries to keep the +# disks balanced, it cannot guarantee it. This feature will be automatically +# disabled if internode encryption is enabled. Currently this can be used with +# Leveled Compaction. Once CASSANDRA-14586 is fixed other compaction strategies +# will benefit as well when used in combination with CASSANDRA-6696. +# stream_entire_sstables: true + +# Throttles all outbound streaming file transfers on this node to the +# given total throughput in Mbps. This is necessary because Cassandra does +# mostly sequential IO when streaming data during bootstrap or repair, which +# can lead to saturating the network connection and degrading rpc performance. +# When unset, the default is 200 Mbps or 25 MB/s. +# stream_throughput_outbound_megabits_per_sec: 200 + +# Throttles all streaming file transfer between the datacenters, +# this setting allows users to throttle inter dc stream throughput in addition +# to throttling all network stream traffic as configured with +# stream_throughput_outbound_megabits_per_sec +# When unset, the default is 200 Mbps or 25 MB/s +# inter_dc_stream_throughput_outbound_megabits_per_sec: 200 + +# How long the coordinator should wait for read operations to complete. +# Lowest acceptable value is 10 ms. +read_request_timeout_in_ms: 5000 +# How long the coordinator should wait for seq or index scans to complete. +# Lowest acceptable value is 10 ms. +range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete. +# Lowest acceptable value is 10 ms. +write_request_timeout_in_ms: 2000 +# How long the coordinator should wait for counter writes to complete. +# Lowest acceptable value is 10 ms. +counter_write_request_timeout_in_ms: 5000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row. +# Lowest acceptable value is 10 ms. +cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +# Lowest acceptable value is 10 ms. +truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations. +# Lowest acceptable value is 10 ms. +request_timeout_in_ms: 10000 + +# Defensive settings for protecting Cassandra from true network partitions. +# See (CASSANDRA-14358) for details. +# +# The amount of time to wait for internode tcp connections to establish. +# internode_tcp_connect_timeout_in_ms = 2000 +# +# The amount of time unacknowledged data is allowed on a connection before we throw out the connection +# Note this is only supported on Linux + epoll, and it appears to behave oddly above a setting of 30000 +# (it takes much longer than 30s) as of Linux 4.12. If you want something that high set this to 0 +# which picks up the OS default and configure the net.ipv4.tcp_retries2 sysctl to be ~8. +# internode_tcp_user_timeout_in_ms = 30000 + +# The maximum continuous period a connection may be unwritable in application space +# internode_application_timeout_in_ms = 30000 + +# Global, per-endpoint and per-connection limits imposed on messages queued for delivery to other nodes +# and waiting to be processed on arrival from other nodes in the cluster. These limits are applied to the on-wire +# size of the message being sent or received. +# +# The basic per-link limit is consumed in isolation before any endpoint or global limit is imposed. +# Each node-pair has three links: urgent, small and large. So any given node may have a maximum of +# N*3*(internode_application_send_queue_capacity_in_bytes+internode_application_receive_queue_capacity_in_bytes) +# messages queued without any coordination between them although in practice, with token-aware routing, only RF*tokens +# nodes should need to communicate with significant bandwidth. +# +# The per-endpoint limit is imposed on all messages exceeding the per-link limit, simultaneously with the global limit, +# on all links to or from a single node in the cluster. +# The global limit is imposed on all messages exceeding the per-link limit, simultaneously with the per-endpoint limit, +# on all links to or from any node in the cluster. +# +# internode_application_send_queue_capacity_in_bytes: 4194304 #4MiB +# internode_application_send_queue_reserve_endpoint_capacity_in_bytes: 134217728 #128MiB +# internode_application_send_queue_reserve_global_capacity_in_bytes: 536870912 #512MiB +# internode_application_receive_queue_capacity_in_bytes: 4194304 #4MiB +# internode_application_receive_queue_reserve_endpoint_capacity_in_bytes: 134217728 #128MiB +# internode_application_receive_queue_reserve_global_capacity_in_bytes: 536870912 #512MiB + + +# How long before a node logs slow queries. Select queries that take longer than +# this timeout to execute, will generate an aggregated log message, so that slow queries +# can be identified. Set this value to zero to disable slow query logging. +slow_query_log_timeout_in_ms: 500 + +# Enable operation timeout information exchange between nodes to accurately +# measure request timeouts. If disabled, replicas will assume that requests +# were forwarded to them instantly by the coordinator, which means that +# under overload conditions we will waste that much extra time processing +# already-timed-out requests. +# +# Warning: It is generally assumed that users have setup NTP on their clusters, and that clocks are modestly in sync, +# since this is a requirement for general correctness of last write wins. +#cross_node_timeout: true + +# Set keep-alive period for streaming +# This node will send a keep-alive message periodically with this period. +# If the node does not receive a keep-alive message from the peer for +# 2 keep-alive cycles the stream session times out and fail +# Default value is 300s (5 minutes), which means stalled stream +# times out in 10 minutes by default +# streaming_keep_alive_period_in_secs: 300 + +# Limit number of connections per host for streaming +# Increase this when you notice that joins are CPU-bound rather that network +# bound (for example a few nodes with big files). +# streaming_connections_per_host: 1 + + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# endpoint_snitch -- Set this to a class that implements +# IEndpointSnitch. The snitch has two functions: +# +# - it teaches Cassandra enough about your network topology to route +# requests efficiently +# - it allows Cassandra to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Cassandra will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# CASSANDRA WILL NOT ALLOW YOU TO SWITCH TO AN INCOMPATIBLE SNITCH +# ONCE DATA IS INSERTED INTO THE CLUSTER. This would cause data loss. +# This means that if you start with the default SimpleSnitch, which +# locates every node on "rack1" in "datacenter1", your only options +# if you need to add another datacenter are GossipingPropertyFileSnitch +# (and the older PFS). From there, if you want to migrate to an +# incompatible snitch like Ec2Snitch you can do it by adding new nodes +# under Ec2Snitch (which will locate them in a new "datacenter") and +# decommissioning the old ones. +# +# Out of the box, Cassandra provides: +# +# SimpleSnitch: +# Treats Strategy order as proximity. This can improve cache +# locality when disabling read repair. Only appropriate for +# single-datacenter deployments. +# +# GossipingPropertyFileSnitch +# This should be your go-to snitch for production use. The rack +# and datacenter for the local node are defined in +# cassandra-rackdc.properties and propagated to other nodes via +# gossip. If cassandra-topology.properties exists, it is used as a +# fallback, allowing migration from the PropertyFileSnitch. +# +# PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# +# Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# +# Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Cassandra will switch to the private IP after +# establishing a connection.) +# +# RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's IP +# address, respectively. Unless this happens to match your +# deployment conventions, this is best used as an example of +# writing a custom Snitch class and is provided in that spirit. +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# controls how often to perform the more expensive part of host score +# calculation +dynamic_snitch_update_interval_in_ms: 100 +# controls how often to reset all host scores, allowing a bad host to +# possibly recover +dynamic_snitch_reset_interval_in_ms: 600000 +# if set greater than zero, this will allow +# 'pinning' of replicas to hosts in order to increase cache capacity. +# The badness threshold will control how much worse the pinned host has to be +# before the dynamic snitch will prefer other replicas over it. This is +# expressed as a double which represents a percentage. Thus, a value of +# 0.2 means Cassandra would continue to prefer the static snitch values +# until the pinned host was 20% worse than the fastest. +dynamic_snitch_badness_threshold: 0.1 + +# Configure server-to-server internode encryption +# +# JVM and netty defaults for supported SSL socket protocols and cipher suites can +# be replaced using custom encryption options. This is not recommended +# unless you have policies in place that dictate certain settings, or +# need to disable vulnerable ciphers or protocols in case the JVM cannot +# be updated. +# +# FIPS compliant settings can be configured at JVM level and should not +# involve changing encryption settings here: +# https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/FIPS.html +# +# **NOTE** this default configuration is an insecure configuration. If you need to +# enable server-to-server encryption generate server keystores (and truststores for mutual +# authentication) per: +# http://download.oracle.com/javase/8/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# Then perform the following configuration changes: +# +# Step 1: Set internode_encryption= and explicitly set optional=true. Restart all nodes +# +# Step 2: Set optional=false (or remove it) and if you generated truststores and want to use mutual +# auth set require_client_auth=true. Restart all nodes +server_encryption_options: + # On outbound connections, determine which type of peers to securely connect to. + # The available options are : + # none : Do not encrypt outgoing connections + # dc : Encrypt connections to peers in other datacenters but not within datacenters + # rack : Encrypt connections to peers in other racks but not within racks + # all : Always use encrypted connections + internode_encryption: none + # When set to true, encrypted and unencrypted connections are allowed on the storage_port + # This should _only be true_ while in unencrypted or transitional operation + # optional defaults to true if internode_encryption is none + # optional: true + # If enabled, will open up an encrypted listening socket on ssl_storage_port. Should only be used + # during upgrade to 4.0; otherwise, set to false. + enable_legacy_ssl_storage_port: false + # Set to a valid keystore if internode_encryption is dc, rack or all + keystore: conf/.keystore + keystore_password: cassandra + # Verify peer server certificates + require_client_auth: false + # Set to a valid trustore if require_client_auth is true + truststore: conf/.truststore + truststore_password: cassandra + # Verify that the host name in the certificate matches the connected host + require_endpoint_verification: false + # More advanced defaults: + # protocol: TLS + # store_type: JKS + # cipher_suites: [ + # TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + # TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + # TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA, + # TLS_RSA_WITH_AES_256_CBC_SHA + # ] + +# Configure client-to-server encryption. +# +# **NOTE** this default configuration is an insecure configuration. If you need to +# enable client-to-server encryption generate server keystores (and truststores for mutual +# authentication) per: +# http://download.oracle.com/javase/8/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# Then perform the following configuration changes: +# +# Step 1: Set enabled=true and explicitly set optional=true. Restart all nodes +# +# Step 2: Set optional=false (or remove it) and if you generated truststores and want to use mutual +# auth set require_client_auth=true. Restart all nodes +client_encryption_options: + # Enable client-to-server encryption + enabled: true + # When set to true, encrypted and unencrypted connections are allowed on the native_transport_port + # This should _only be true_ while in unencrypted or transitional operation + # optional defaults to true when enabled is false, and false when enabled is true. + optional: true + # Set keystore and keystore_password to valid keystores if enabled is true + keystore: /etc/cassandra/keystore.node0 + keystore_password: 123456 + # Verify client certificates + require_client_auth: true + # Set trustore and truststore_password if require_client_auth is true + truststore: /etc/cassandra/truststore.node0 + truststore_password: 123456 + # More advanced defaults: + protocol: TLS + algorithm: SunX509 + store_type: JKS + cipher_suites: [ TLS_RSA_WITH_AES_256_CBC_SHA ] + +# internode_compression controls whether traffic between nodes is +# compressed. +# Can be: +# +# all +# all traffic is compressed +# +# dc +# traffic between different datacenters is compressed +# +# none +# nothing is compressed. +internode_compression: dc + +# Enable or disable tcp_nodelay for inter-dc communication. +# Disabling it will result in larger (but fewer) network packets being sent, +# reducing overhead from the TCP protocol itself, at the cost of increasing +# latency if you block for cross-datacenter responses. +inter_dc_tcp_nodelay: false + +# TTL for different trace types used during logging of the repair process. +tracetype_query_ttl: 86400 +tracetype_repair_ttl: 604800 + +# If unset, all GC Pauses greater than gc_log_threshold_in_ms will log at +# INFO level +# UDFs (user defined functions) are disabled by default. +# As of Cassandra 3.0 there is a sandbox in place that should prevent execution of evil code. +enable_user_defined_functions: false + +# Enables scripted UDFs (JavaScript UDFs). +# Java UDFs are always enabled, if enable_user_defined_functions is true. +# Enable this option to be able to use UDFs with "language javascript" or any custom JSR-223 provider. +# This option has no effect, if enable_user_defined_functions is false. +enable_scripted_user_defined_functions: false + +# The default Windows kernel timer and scheduling resolution is 15.6ms for power conservation. +# Lowering this value on Windows can provide much tighter latency and better throughput, however +# some virtualized environments may see a negative performance impact from changing this setting +# below their system default. The sysinternals 'clockres' tool can confirm your system's default +# setting. +windows_timer_interval: 1 + + +# Enables encrypting data at-rest (on disk). Different key providers can be plugged in, but the default reads from +# a JCE-style keystore. A single keystore can hold multiple keys, but the one referenced by +# the "key_alias" is the only key that will be used for encrypt opertaions; previously used keys +# can still (and should!) be in the keystore and will be used on decrypt operations +# (to handle the case of key rotation). +# +# It is strongly recommended to download and install Java Cryptography Extension (JCE) +# Unlimited Strength Jurisdiction Policy Files for your version of the JDK. +# (current link: http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html) +# +# Currently, only the following file types are supported for transparent data encryption, although +# more are coming in future cassandra releases: commitlog, hints +transparent_data_encryption_options: + enabled: false + chunk_length_kb: 64 + cipher: AES/CBC/PKCS5Padding + key_alias: testing:1 + # CBC IV length for AES needs to be 16 bytes (which is also the default size) + # iv_length: 16 + key_provider: + - class_name: org.apache.cassandra.security.JKSKeyProvider + parameters: + - keystore: conf/.keystore + keystore_password: cassandra + store_type: JCEKS + key_password: cassandra + + +##################### +# SAFETY THRESHOLDS # +##################### + +# When executing a scan, within or across a partition, we need to keep the +# tombstones seen in memory so we can return them to the coordinator, which +# will use them to make sure other replicas also know about the deleted rows. +# With workloads that generate a lot of tombstones, this can cause performance +# problems and even exaust the server heap. +# (http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets) +# Adjust the thresholds here if you understand the dangers and want to +# scan more tombstones anyway. These thresholds may also be adjusted at runtime +# using the StorageService mbean. +tombstone_warn_threshold: 1000 +tombstone_failure_threshold: 100000 + +# Filtering and secondary index queries at read consistency levels above ONE/LOCAL_ONE use a +# mechanism called replica filtering protection to ensure that results from stale replicas do +# not violate consistency. (See CASSANDRA-8272 and CASSANDRA-15907 for more details.) This +# mechanism materializes replica results by partition on-heap at the coordinator. The more possibly +# stale results returned by the replicas, the more rows materialized during the query. +replica_filtering_protection: + # These thresholds exist to limit the damage severely out-of-date replicas can cause during these + # queries. They limit the number of rows from all replicas individual index and filtering queries + # can materialize on-heap to return correct results at the desired read consistency level. + # + # "cached_replica_rows_warn_threshold" is the per-query threshold at which a warning will be logged. + # "cached_replica_rows_fail_threshold" is the per-query threshold at which the query will fail. + # + # These thresholds may also be adjusted at runtime using the StorageService mbean. + # + # If the failure threshold is breached, it is likely that either the current page/fetch size + # is too large or one or more replicas is severely out-of-sync and in need of repair. + cached_rows_warn_threshold: 2000 + cached_rows_fail_threshold: 32000 + +# Log WARN on any multiple-partition batch size exceeding this value. 5kb per batch by default. +# Caution should be taken on increasing the size of this threshold as it can lead to node instability. +batch_size_warn_threshold_in_kb: 5 + +# Fail any multiple-partition batch exceeding this value. 50kb (10x warn threshold) by default. +batch_size_fail_threshold_in_kb: 50 + +# Log WARN on any batches not of type LOGGED than span across more partitions than this limit +unlogged_batch_across_partitions_warn_threshold: 10 + +# Log a warning when compacting partitions larger than this value +compaction_large_partition_warning_threshold_mb: 100 + +# GC Pauses greater than 200 ms will be logged at INFO level +# This threshold can be adjusted to minimize logging if necessary +# gc_log_threshold_in_ms: 200 + +# GC Pauses greater than gc_warn_threshold_in_ms will be logged at WARN level +# Adjust the threshold based on your application throughput requirement. Setting to 0 +# will deactivate the feature. +# gc_warn_threshold_in_ms: 1000 + +# Maximum size of any value in SSTables. Safety measure to detect SSTable corruption +# early. Any value size larger than this threshold will result into marking an SSTable +# as corrupted. This should be positive and less than 2048. +# max_value_size_in_mb: 256 + +# Coalescing Strategies # +# Coalescing multiples messages turns out to significantly boost message processing throughput (think doubling or more). +# On bare metal, the floor for packet processing throughput is high enough that many applications won't notice, but in +# virtualized environments, the point at which an application can be bound by network packet processing can be +# surprisingly low compared to the throughput of task processing that is possible inside a VM. It's not that bare metal +# doesn't benefit from coalescing messages, it's that the number of packets a bare metal network interface can process +# is sufficient for many applications such that no load starvation is experienced even without coalescing. +# There are other benefits to coalescing network messages that are harder to isolate with a simple metric like messages +# per second. By coalescing multiple tasks together, a network thread can process multiple messages for the cost of one +# trip to read from a socket, and all the task submission work can be done at the same time reducing context switching +# and increasing cache friendliness of network message processing. +# See CASSANDRA-8692 for details. + +# Strategy to use for coalescing messages in OutboundTcpConnection. +# Can be fixed, movingaverage, timehorizon, disabled (default). +# You can also specify a subclass of CoalescingStrategies.CoalescingStrategy by name. +# otc_coalescing_strategy: DISABLED + +# How many microseconds to wait for coalescing. For fixed strategy this is the amount of time after the first +# message is received before it will be sent with any accompanying messages. For moving average this is the +# maximum amount of time that will be waited as well as the interval at which messages must arrive on average +# for coalescing to be enabled. +# otc_coalescing_window_us: 200 + +# Do not try to coalesce messages if we already got that many messages. This should be more than 2 and less than 128. +# otc_coalescing_enough_coalesced_messages: 8 + +# How many milliseconds to wait between two expiration runs on the backlog (queue) of the OutboundTcpConnection. +# Expiration is done if messages are piling up in the backlog. Droppable messages are expired to free the memory +# taken by expired messages. The interval should be between 0 and 1000, and in most installations the default value +# will be appropriate. A smaller value could potentially expire messages slightly sooner at the expense of more CPU +# time and queue contention while iterating the backlog of messages. +# An interval of 0 disables any wait time, which is the behavior of former Cassandra versions. +# +# otc_backlog_expiration_interval_ms: 200 + +# Track a metric per keyspace indicating whether replication achieved the ideal consistency +# level for writes without timing out. This is different from the consistency level requested by +# each write which may be lower in order to facilitate availability. +# ideal_consistency_level: EACH_QUORUM + +# Automatically upgrade sstables after upgrade - if there is no ordinary compaction to do, the +# oldest non-upgraded sstable will get upgraded to the latest version +# automatic_sstable_upgrade: false +# Limit the number of concurrent sstable upgrades +# max_concurrent_automatic_sstable_upgrades: 1 + +# Audit logging - Logs every incoming CQL command request, authentication to a node. See the docs +# on audit_logging for full details about the various configuration options. +audit_logging_options: + enabled: false + logger: + - class_name: BinAuditLogger + # audit_logs_dir: + # included_keyspaces: + # excluded_keyspaces: system, system_schema, system_virtual_schema + # included_categories: + # excluded_categories: + # included_users: + # excluded_users: + # roll_cycle: HOURLY + # block: true + # max_queue_weight: 268435456 # 256 MiB + # max_log_size: 17179869184 # 16 GiB + ## archive command is "/path/to/script.sh %path" where %path is replaced with the file being rolled: + # archive_command: + # max_archive_retries: 10 + + + # default options for full query logging - these can be overridden from command line when executing + # nodetool enablefullquerylog + #full_query_logging_options: + # log_dir: + # roll_cycle: HOURLY + # block: true + # max_queue_weight: 268435456 # 256 MiB + # max_log_size: 17179869184 # 16 GiB + ## archive command is "/path/to/script.sh %path" where %path is replaced with the file being rolled: + # archive_command: + # max_archive_retries: 10 + +# validate tombstones on reads and compaction +# can be either "disabled", "warn" or "exception" +# corrupted_tombstone_strategy: disabled + +# Diagnostic Events # +# If enabled, diagnostic events can be helpful for troubleshooting operational issues. Emitted events contain details +# on internal state and temporal relationships across events, accessible by clients via JMX. +diagnostic_events_enabled: false + +# Use native transport TCP message coalescing. If on upgrade to 4.0 you found your throughput decreasing, and in +# particular you run an old kernel or have very fewer client connections, this option might be worth evaluating. +#native_transport_flush_in_batches_legacy: false + +# Enable tracking of repaired state of data during reads and comparison between replicas +# Mismatches between the repaired sets of replicas can be characterized as either confirmed +# or unconfirmed. In this context, unconfirmed indicates that the presence of pending repair +# sessions, unrepaired partition tombstones, or some other condition means that the disparity +# cannot be considered conclusive. Confirmed mismatches should be a trigger for investigation +# as they may be indicative of corruption or data loss. +# There are separate flags for range vs partition reads as single partition reads are only tracked +# when CL > 1 and a digest mismatch occurs. Currently, range queries don't use digests so if +# enabled for range reads, all range reads will include repaired data tracking. As this adds +# some overhead, operators may wish to disable it whilst still enabling it for partition reads +repaired_data_tracking_for_range_reads_enabled: false +repaired_data_tracking_for_partition_reads_enabled: false +# If false, only confirmed mismatches will be reported. If true, a separate metric for unconfirmed +# mismatches will also be recorded. This is to avoid potential signal:noise issues are unconfirmed +# mismatches are less actionable than confirmed ones. +report_unconfirmed_repaired_data_mismatches: false + +######################### +# EXPERIMENTAL FEATURES # +######################### + +# Enables materialized view creation on this node. +# Materialized views are considered experimental and are not recommended for production use. +enable_materialized_views: false + +# Enables SASI index creation on this node. +# SASI indexes are considered experimental and are not recommended for production use. +enable_sasi_indexes: false + +# Enables creation of transiently replicated keyspaces on this node. +# Transient replication is experimental and is not recommended for production use. +enable_transient_replication: false diff --git a/src/control-plane-services/cloud-tasks/local_env/cassandra/entrypoint.sh b/src/control-plane-services/cloud-tasks/local_env/cassandra/entrypoint.sh new file mode 100755 index 000000000..445d01a1d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/cassandra/entrypoint.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +cd /docker-entrypoint-initdb.d || exit 1 +while ! cqlsh -e 'describe cluster' -u cassandra -p cassandra >/dev/null 2>&1; do sleep 6; done +echo "Cassandra cluster ready: executing cql scripts found in docker-entrypoint-initdb.d" +for f in $(find . -type f -name "*.cql" -print | sort); do + echo "running $f" + cqlsh -f "$f" -u cassandra -p cassandra + echo "$f executed" +done +echo "Cassandra init scripts executed" diff --git a/src/control-plane-services/cloud-tasks/local_env/cassandra/keystore.node0 b/src/control-plane-services/cloud-tasks/local_env/cassandra/keystore.node0 new file mode 100644 index 000000000..d71c880ef Binary files /dev/null and b/src/control-plane-services/cloud-tasks/local_env/cassandra/keystore.node0 differ diff --git a/src/control-plane-services/cloud-tasks/local_env/cassandra/schema/0001_initial_schema.cql b/src/control-plane-services/cloud-tasks/local_env/cassandra/schema/0001_initial_schema.cql new file mode 100644 index 000000000..54bfb10f6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/cassandra/schema/0001_initial_schema.cql @@ -0,0 +1,125 @@ +-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +DROP KEYSPACE IF EXISTS nvct; + +CREATE KEYSPACE IF NOT EXISTS nvct WITH REPLICATION = {'class' : 'SimpleStrategy', 'replication_factor' : 1 }; + +USE nvct; + +// UDT for model. +CREATE TYPE IF NOT EXISTS model_udt ( + name TEXT, + version TEXT, + url TEXT +); + +// UDT for resource +CREATE TYPE IF NOT EXISTS resource_udt ( + name TEXT, + version TEXT, + url TEXT +); + +CREATE TYPE IF NOT EXISTS gpu_spec_udt( + instance_type TEXT, + gpu TEXT, + backend TEXT, + configuration TEXT, + clusters FROZEN>, + regions FROZEN>, + attributes FROZEN>, + max_request_concurrency INT, + helm_validation_policy TEXT +); + +CREATE TYPE IF NOT EXISTS health_udt ( + sis_request_id UUID, + gpu TEXT, + backend TEXT, + instance_type TEXT, + error TEXT +); + +CREATE TYPE IF NOT EXISTS telemetries_udt +( + logs_telemetry_id UUID, + metrics_telemetry_id UUID, + traces_telemetry_id UUID, +); + +CREATE TABLE IF NOT EXISTS tasks_v2 +( + nca_id TEXT, + task_id UUID, + name TEXT, + description TEXT, + tags FROZEN>, + container_image TEXT, + container_args TEXT, + container_environment TEXT, + models FROZEN>, + resources FROZEN>, + gpu_spec gpu_spec_udt, + max_runtime_duration DURATION, + max_queued_duration DURATION, + terminal_grace_period_duration DURATION, + result_handling_strategy TEXT, + helm_chart TEXT, + results_location TEXT, + status TEXT, + telemetries FROZEN, + health TEXT, + health_info FROZEN, + percent_complete INT, + last_updated_at TIMESTAMP, + last_heartbeat_at TIMESTAMP, + created_at TIMESTAMP, + has_secrets BOOLEAN, + PRIMARY KEY ((task_id)) +); + +CREATE CUSTOM INDEX IF NOT EXISTS tasks_v2_by_nca_id_sai_idx ON tasks_v2 (nca_id) USING 'StorageAttachedIndex'; + +CREATE TABLE IF NOT EXISTS events_by_task +( + task_id UUID, + event_id UUID, + nca_id TEXT, + message TEXT, + created_at TIMESTAMP, + PRIMARY KEY ((task_id), event_id) +); + +CREATE TABLE IF NOT EXISTS results_by_task +( + task_id UUID, + result_id UUID, + nca_id TEXT, + name TEXT, + metadata TEXT, + created_at TIMESTAMP, + PRIMARY KEY ((task_id), result_id) +); + +// Table for managing distributed locks for scheduled background threads. +// Primary key must be 'name', +CREATE TABLE IF NOT EXISTS lock +( + name TEXT, + lockUntil TIMESTAMP, + lockedAt TIMESTAMP, + lockedBy TEXT, + PRIMARY KEY ((name)) +); diff --git a/src/control-plane-services/cloud-tasks/local_env/cassandra/truststore.node0 b/src/control-plane-services/cloud-tasks/local_env/cassandra/truststore.node0 new file mode 100644 index 000000000..09e1130e4 Binary files /dev/null and b/src/control-plane-services/cloud-tasks/local_env/cassandra/truststore.node0 differ diff --git a/src/control-plane-services/cloud-tasks/local_env/docker-compose.test.yml b/src/control-plane-services/cloud-tasks/local_env/docker-compose.test.yml new file mode 100644 index 000000000..5c73ee4d6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/docker-compose.test.yml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Integration tests only (nvct-core / nvct-service IntegrationTestConfiguration). Ephemeral host +# port for CQL so nvct-core then nvct-service Surefire runs do not collide on localhost:9042. +name: nvct-cloud-tasks-test + +services: + cassandra: + image: cassandra:5 + ports: + - "9042" + environment: + - HEAP_NEWSIZE=128M + - MAX_HEAP_SIZE=256M + - DC=datacenter1 + - LANG=C.UTF-8 + volumes: + - ./cassandra/cassandra.yaml:/etc/cassandra/cassandra.yaml + - ./cassandra/keystore.node0:/etc/cassandra/keystore.node0 + - ./cassandra/truststore.node0:/etc/cassandra/truststore.node0 + - ./cassandra/schema:/docker-entrypoint-initdb.d + - ./cassandra/entrypoint.sh:/entrypoint.sh + command: + - /bin/bash + - -c + - nohup /entrypoint.sh & cassandra -f -R diff --git a/src/control-plane-services/cloud-tasks/local_env/docker-compose.yml b/src/control-plane-services/cloud-tasks/local_env/docker-compose.yml new file mode 100644 index 000000000..881883548 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/docker-compose.yml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Local development: CQL on localhost:9042 (matches spring.cassandra defaults for profile `local`). +# Maven / Testcontainers use docker-compose.test.yml (ephemeral publish) to avoid reactor port clashes. +name: nvct-cloud-tasks-local + +services: + cassandra: + image: cassandra:5 + ports: + - "9042:9042" + environment: + - HEAP_NEWSIZE=128M + - MAX_HEAP_SIZE=256M + - DC=datacenter1 + - LANG=C.UTF-8 + volumes: + - ./cassandra/cassandra.yaml:/etc/cassandra/cassandra.yaml + - ./cassandra/keystore.node0:/etc/cassandra/keystore.node0 + - ./cassandra/truststore.node0:/etc/cassandra/truststore.node0 + - ./cassandra/schema:/docker-entrypoint-initdb.d + - ./cassandra/entrypoint.sh:/entrypoint.sh + command: + - /bin/bash + - -c + - nohup /entrypoint.sh & cassandra -f -R diff --git a/src/control-plane-services/cloud-tasks/local_env/vault/secrets.json b/src/control-plane-services/cloud-tasks/local_env/vault/secrets.json new file mode 100644 index 000000000..c87ca4cf3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/local_env/vault/secrets.json @@ -0,0 +1,27 @@ +{ + "kv": { + "cassandra": { + "username": "cassandra", + "password": "cassandra" + }, + "oauth2": { + "client-id": "dummy-nvct-client-id", + "client-secret": "dummy-nvct-client-secret" + }, + "sidecars": { + "image-pull-secret": "JG9hdXRodG9rZW46bnZhcGktc3RnLWR1bW15LXNpZGVjYXItc2VjcmV0LWZvci1pbnRlZ3JhdGlvbi10ZXN0cw==" + }, + "tracing": { + "accessToken": "dummy-lightstep-access-token" + }, + "worker-tracing": "dummy-worker-lightstep-access-token", + "tokens": { + "ess": "dummy-ess-static-token", + "notary": "dummy-notary-static-token", + "nvcf": "dummy-nvcf-static-token", + "reval": "dummy-reval-static-token", + "icms": "dummy-icms-static-token", + "api-keys": "dummy-api-keys-static-token" + } + } +} diff --git a/src/control-plane-services/cloud-tasks/lombok.config b/src/control-plane-services/cloud-tasks/lombok.config new file mode 100644 index 000000000..304ad4502 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/lombok.config @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true +clear lombok.jacksonized.jacksonVersion +lombok.jacksonized.jacksonVersion += 3 diff --git a/src/control-plane-services/cloud-tasks/notice_metadata.json b/src/control-plane-services/cloud-tasks/notice_metadata.json new file mode 100644 index 000000000..75cdbe18e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/notice_metadata.json @@ -0,0 +1,545 @@ +{ + "artifacts": { + "com.bucket4j:bucket4j-core:8.10.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "bucket4j-core", + "url": "http://github.com/bucket4j/bucket4j/bucket4j-core" + }, + "com.bucket4j:bucket4j_jdk17-core:8.19.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "bucket4j_jdk17-core", + "url": "http://github.com/bucket4j/bucket4j/bucket4j_jdk17-core" + }, + "com.google.android:annotations:4.1.1.4": { + "licenses": [ + "Apache 2.0" + ], + "name": "Google Android Annotations Library", + "url": "http://source.android.com/" + }, + "com.google.api.grpc:proto-google-common-protos:2.29.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "proto-google-common-protos", + "url": "https://github.com/googleapis/sdk-platform-java" + }, + "com.google.api.grpc:proto-google-common-protos:2.51.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "proto-google-common-protos", + "url": "https://github.com/googleapis/sdk-platform-java" + }, + "com.google.code.gson:gson:2.13.2": { + "licenses": [ + "Apache-2.0" + ], + "name": "Gson", + "url": "https://github.com/google/gson" + }, + "com.google.protobuf:protobuf-java-util:4.33.4": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "Protocol Buffers [Util]", + "url": "https://developers.google.com/protocol-buffers/" + }, + "com.google.protobuf:protobuf-java:4.33.4": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "Protocol Buffers [Core]", + "url": "https://developers.google.com/protocol-buffers/" + }, + "com.squareup.okhttp3:logging-interceptor:4.12.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "okhttp-logging-interceptor", + "url": "https://square.github.io/okhttp/" + }, + "com.squareup.okhttp3:okhttp:4.12.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "okhttp", + "url": "https://square.github.io/okhttp/" + }, + "com.squareup.okio:okio:3.6.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "okio", + "url": "https://github.com/square/okio/" + }, + "commons-io:commons-io:2.20.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons IO", + "url": "https://commons.apache.org/proper/commons-io/" + }, + "io.dropwizard.metrics:metrics-core:4.1.18": { + "licenses": [ + "Apache License 2.0" + ], + "name": "Metrics Core", + "url": "https://metrics.dropwizard.io" + }, + "io.grpc:grpc-api:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-api", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-api:1.74.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-api", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-context:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-context", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-core:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-core", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-inprocess:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-inprocess", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-netty-shaded:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-netty-shaded", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-protobuf-lite:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-protobuf-lite", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-protobuf-lite:1.74.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-protobuf-lite", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-protobuf:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-protobuf", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-protobuf:1.74.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-protobuf", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-services:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-services", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-stub:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-stub", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-stub:1.74.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-stub", + "url": "https://github.com/grpc/grpc-java" + }, + "io.grpc:grpc-util:1.63.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "io.grpc:grpc-util", + "url": "https://github.com/grpc/grpc-java" + }, + "io.gsonfire:gson-fire:1.9.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Gson on Fire!", + "url": "http://gsonfire.io" + }, + "io.kubernetes:client-java-api-fluent:24.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "client-java-fluent", + "url": "https://github.com/kubernetes-client/java" + }, + "io.kubernetes:client-java-api:24.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "client-java-api", + "url": "https://github.com/kubernetes-client/java" + }, + "io.kubernetes:client-java-extended:24.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "client-java-extended", + "url": "https://github.com/kubernetes-client/java" + }, + "io.kubernetes:client-java-proto:24.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "client-java-proto", + "url": "https://github.com/kubernetes-client/java" + }, + "io.kubernetes:client-java:24.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "client-java", + "url": "https://github.com/kubernetes-client/java" + }, + "io.micrometer:micrometer-registry-prometheus:1.16.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-registry-prometheus", + "url": "https://github.com/micrometer-metrics/micrometer" + }, + "io.netty:netty-codec-native-quic:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Native/Quic", + "url": "https://netty.io/" + }, + "io.netty:netty-resolver-dns-classes-macos:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Resolver/DNS/Classes/MacOS", + "url": "https://netty.io/" + }, + "io.netty:netty-resolver-dns-native-macos:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Resolver/DNS/Native/MacOS", + "url": "https://netty.io/" + }, + "io.netty:netty-transport-classes-epoll:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Transport/Classes/Epoll", + "url": "https://netty.io/" + }, + "io.netty:netty-transport-native-epoll:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Transport/Native/Epoll", + "url": "https://netty.io/" + }, + "io.perfmark:perfmark-api:0.26.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "perfmark:perfmark-api", + "url": "https://github.com/perfmark/perfmark" + }, + "io.prometheus:prometheus-metrics-config:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Config", + "url": "http://github.com/prometheus/client_java" + }, + "io.prometheus:prometheus-metrics-core:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Core", + "url": "http://github.com/prometheus/client_java" + }, + "io.prometheus:prometheus-metrics-exposition-formats:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Exposition Formats", + "url": "http://github.com/prometheus/client_java" + }, + "io.prometheus:prometheus-metrics-exposition-textformats:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Exposition Text Formats", + "url": "http://github.com/prometheus/client_java" + }, + "io.prometheus:prometheus-metrics-model:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Model", + "url": "http://github.com/prometheus/client_java" + }, + "io.prometheus:prometheus-metrics-tracer-common:1.4.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Prometheus Metrics Tracer Common", + "url": "http://github.com/prometheus/client_java" + }, + "io.swagger:swagger-annotations:1.6.16": { + "licenses": [ + "Apache License 2.0" + ], + "name": "swagger-annotations", + "url": "https://github.com/swagger-api/swagger-core" + }, + "jakarta.servlet:jakarta.servlet-api:6.1.0": { + "licenses": [ + "EPL 2.0", + "GPL2 w/ CPE" + ], + "name": "Jakarta Servlet", + "url": "https://projects.eclipse.org/projects/ee4j.servlet" + }, + "javax.annotation:javax.annotation-api:1.3.2": { + "licenses": [ + "CDDL + GPLv2 with classpath exception" + ], + "name": "javax.annotation API", + "url": "http://jcp.org/en/jsr/detail?id=250" + }, + "net.devh:grpc-common-spring-boot:3.1.0.RELEASE": { + "licenses": [ + "Apache 2.0" + ], + "name": "gRPC Spring Boot Starter", + "url": "https://github.com/yidongnan/grpc-spring-boot-starter" + }, + "net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE": { + "licenses": [ + "Apache 2.0" + ], + "name": "gRPC Spring Boot Starter", + "url": "https://github.com/yidongnan/grpc-spring-boot-starter" + }, + "net.javacrumbs.shedlock:shedlock-core:7.7.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "net.javacrumbs.shedlock:shedlock-core", + "url": "http://nexus.sonatype.org/oss-repository-hosting.html" + }, + "net.javacrumbs.shedlock:shedlock-provider-cassandra:7.7.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "net.javacrumbs.shedlock:shedlock-provider-cassandra", + "url": "http://nexus.sonatype.org/oss-repository-hosting.html" + }, + "net.javacrumbs.shedlock:shedlock-spring:7.7.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "net.javacrumbs.shedlock:shedlock-spring", + "url": "http://nexus.sonatype.org/oss-repository-hosting.html" + }, + "org.apache.commons:commons-collections4:4.5.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons Collections", + "url": "https://commons.apache.org/proper/commons-collections/" + }, + "org.apache.commons:commons-compress:1.28.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons Compress", + "url": "https://commons.apache.org/proper/commons-compress/" + }, + "org.apache.tomcat.embed:tomcat-embed-core:11.0.22": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "tomcat-embed-core", + "url": "https://tomcat.apache.org/" + }, + "org.apache.tomcat.embed:tomcat-embed-websocket:11.0.22": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "tomcat-embed-websocket", + "url": "https://tomcat.apache.org/" + }, + "org.aspectj:aspectjweaver:1.9.25.1": { + "licenses": [ + "Eclipse Public License - v 2.0" + ], + "name": "AspectJ Weaver", + "url": "https://www.eclipse.org/aspectj/" + }, + "org.bitbucket.b_c:jose4j:0.9.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jose4j", + "url": "https://bitbucket.org/b_c/jose4j/" + }, + "org.bouncycastle:bcpkix-jdk18on:1.80": { + "licenses": [ + "Bouncy Castle Licence" + ], + "name": "Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs", + "url": "https://www.bouncycastle.org/download/bouncy-castle-java/" + }, + "org.bouncycastle:bcutil-jdk18on:1.80.2": { + "licenses": [ + "Bouncy Castle Licence" + ], + "name": "Bouncy Castle ASN.1 Extension and Utility APIs", + "url": "https://www.bouncycastle.org/download/bouncy-castle-java/" + }, + "org.codehaus.mojo:animal-sniffer-annotations:1.23": { + "licenses": [ + "MIT license" + ], + "name": "Animal Sniffer Annotations", + "url": "https://www.mojohaus.org/animal-sniffer" + }, + "org.codehaus.mojo:animal-sniffer-annotations:1.24": { + "licenses": [ + "MIT license" + ], + "name": "Animal Sniffer Annotations", + "url": "https://www.mojohaus.org/animal-sniffer" + }, + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21": { + "licenses": [ + "Apache-2.0" + ], + "name": "Kotlin Stdlib Jdk7", + "url": "https://kotlinlang.org/" + }, + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21": { + "licenses": [ + "Apache-2.0" + ], + "name": "Kotlin Stdlib Jdk8", + "url": "https://kotlinlang.org/" + }, + "org.springframework.boot:spring-boot-starter-aspectj:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-aspectj", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-tomcat-runtime", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-tomcat:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-tomcat", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-webmvc:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-webmvc", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-tomcat:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-tomcat", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.cloud:spring-cloud-kubernetes-client-autoconfig:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-cloud-kubernetes-client-autoconfig", + "url": "https://cloud.spring.io/spring-cloud-kubernetes-client-autoconfig" + }, + "org.springframework.cloud:spring-cloud-kubernetes-client-config:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-cloud-kubernetes-client-config", + "url": "https://cloud.spring.io/spring-cloud-kubernetes-client-config" + }, + "org.springframework.cloud:spring-cloud-kubernetes-commons:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-cloud-kubernetes-commons", + "url": "https://cloud.spring.io/spring-cloud-kubernetes-commons" + }, + "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Cloud Kubernetes :: Kubernetes Native Starter :: Config", + "url": "https://cloud.spring.io/spring-cloud-starter-kubernetes-client-config" + }, + "org.springframework.retry:spring-retry:2.0.13": { + "licenses": [ + "Apache 2.0" + ], + "name": "Spring Retry", + "url": "https://github.com/spring-projects/spring-retry" + }, + "org.springframework:spring-context-support:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Context Support", + "url": "https://github.com/spring-projects/spring-framework" + } + }, + "generated_by": "tools/bazel/java/generate_notice.py --update-metadata" +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-core/BUILD.bazel new file mode 100644 index 000000000..dc9ca4be4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/BUILD.bazel @@ -0,0 +1,228 @@ +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") +load("//rules/java:proto.bzl", "nvct_java_grpc_compile") + +package(default_visibility = ["//visibility:public"]) + +NVCT_CORE_SRCS = glob(["src/main/java/**/*.java"]) + +NVCT_CORE_TEST_SRCS = glob(["src/test/java/**/*.java"]) + +NVCT_CORE_TEST_RESOURCES = glob(["src/test/resources/**"]) + [ + "//src/control-plane-services/cloud-tasks:integration_local_env_files", +] + +proto_library( + name = "nvct_proto", + srcs = ["src/main/proto/nvct.proto"], + deps = ["@com_google_protobuf//:timestamp_proto"], +) + +nvct_java_grpc_compile( + name = "nvct_proto_java_grpc_srcs", + protos = [":nvct_proto"], +) + +# rules_proto_grpc_java's convenience java_grpc_library macro hardcodes its +# private @maven runtime hub. Owning this small wrapper keeps all application +# runtime jars in the merged @nv_third_party_deps hub shared with nv-boot-parent. +java_library( + name = "nvct_proto_java_grpc", + srcs = [":nvct_proto_java_grpc_srcs"], + deps = [ + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java_util", + "@nv_third_party_deps//:io_grpc_grpc_api", + "@nv_third_party_deps//:io_grpc_grpc_protobuf", + "@nv_third_party_deps//:io_grpc_grpc_stub", + "@nv_third_party_deps//:javax_annotation_javax_annotation_api", + ], + exports = [ + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java_util", + "@nv_third_party_deps//:io_grpc_grpc_api", + "@nv_third_party_deps//:io_grpc_grpc_protobuf", + "@nv_third_party_deps//:io_grpc_grpc_stub", + "@nv_third_party_deps//:javax_annotation_javax_annotation_api", + ], +) + +nvct_library( + name = "nvct_core", + srcs = NVCT_CORE_SRCS, + deps = [ + ":nvct_proto_java_grpc", + "@nv_third_party_deps//:com_bucket4j_bucket4j_jdk17_core", + "@nv_third_party_deps//:commons_codec_commons_codec", + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_caffeine", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java", + "@nv_third_party_deps//:com_nimbusds_oauth2_oidc_sdk", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-audit:nv_boot_starter_audit", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra:nv_boot_starter_cassandra", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-core:nv_boot_starter_core", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions:nv_boot_starter_exceptions", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-observability:nv_boot_starter_observability", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-registries:nv_boot_starter_registries", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties:nv_boot_starter_reloadable_properties", + "@nv_third_party_deps//:io_grpc_grpc_api", + "@nv_third_party_deps//:io_grpc_grpc_netty_shaded", + "@nv_third_party_deps//:io_grpc_grpc_stub", + "@nv_third_party_deps//:io_micrometer_micrometer_core", + "@nv_third_party_deps//:io_micrometer_micrometer_observation", + "@nv_third_party_deps//:io_micrometer_micrometer_registry_prometheus", + "@nv_third_party_deps//:io_micrometer_micrometer_tracing", + "@nv_third_party_deps//:io_swagger_core_v3_swagger_annotations_jakarta", + "@nv_third_party_deps//:io_swagger_core_v3_swagger_models_jakarta", + "@nv_third_party_deps//:io_netty_netty_transport", + "@nv_third_party_deps//:io_netty_netty_handler", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_servlet_jakarta_servlet_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:net_devh_grpc_server_spring_boot_starter", + "@nv_third_party_deps//:net_javacrumbs_shedlock_shedlock_core", + "@nv_third_party_deps//:net_javacrumbs_shedlock_shedlock_provider_cassandra", + "@nv_third_party_deps//:net_javacrumbs_shedlock_shedlock_spring", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_metrics_micrometer", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_core", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_query_builder", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_apache_logging_log4j_log4j_api", + "@nv_third_party_deps//:org_hibernate_validator_hibernate_validator", + "@nv_third_party_deps//:org_jspecify_jspecify", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springdoc_springdoc_openapi_starter_common", + "@nv_third_party_deps//:org_springdoc_springdoc_openapi_starter_webmvc_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_metrics", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_aspectj", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security_oauth2_client", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security_oauth2_resource_server", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_validation", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_starter_kubernetes_client_config", + "@nv_third_party_deps//:org_springframework_data_spring_data_cassandra", + "@nv_third_party_deps//:org_springframework_data_spring_data_commons", + "@nv_third_party_deps//:org_springframework_spring_aop", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_context_support", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_expression", + "@nv_third_party_deps//:org_springframework_spring_tx", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:org_springframework_spring_webmvc", + "@nv_third_party_deps//:org_springframework_retry_spring_retry", + "@nv_third_party_deps//:org_springframework_security_spring_security_config", + "@nv_third_party_deps//:org_springframework_security_spring_security_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_client", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_jose", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_resource_server", + "@nv_third_party_deps//:org_springframework_security_spring_security_web", + "@nv_third_party_deps//:io_projectreactor_reactor_core", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_core", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_http", + "@nv_third_party_deps//:tools_jackson_core_jackson_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", + "@nv_third_party_deps//:tools_jackson_module_jackson_module_blackbird", + ], +) + +NVCT_CORE_TEST_DEPS = [ + ":nvct_core", + ":nvct_proto_java_grpc", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_core", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_guava_shaded", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_google_protobuf_protobuf_java", + "@nv_third_party_deps//:com_nimbusds_nimbus_jose_jwt", + "//src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test:nv_boot_mock_servers_test", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-audit:nv_boot_starter_audit", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-core:nv_boot_starter_core", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions:nv_boot_starter_exceptions", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-observability:nv_boot_starter_observability", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-registries:nv_boot_starter_registries", + "//src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties:nv_boot_starter_reloadable_properties", + "@nv_third_party_deps//:io_grpc_grpc_api", + "@nv_third_party_deps//:io_grpc_grpc_stub", + "@nv_third_party_deps//:io_micrometer_micrometer_core", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_api", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_testing", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_trace", + "@nv_third_party_deps//:io_projectreactor_reactor_core", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_core", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:net_minidev_json_smart", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_apache_logging_log4j_log4j_api", + "@nv_third_party_deps//:org_assertj_assertj_core", + "@nv_third_party_deps//:org_awaitility_awaitility", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_params", + "@nv_third_party_deps//:org_mockito_mockito_core", + "@nv_third_party_deps//:org_mockito_mockito_junit_jupiter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_tracing", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_restclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_resttestclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_security", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_data_cassandra_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_data_spring_data_cassandra", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_test", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:org_springframework_spring_webmvc", + "@nv_third_party_deps//:org_testcontainers_testcontainers", + "@nv_third_party_deps//:org_testcontainers_testcontainers_cassandra", + "@nv_third_party_deps//:tools_jackson_core_jackson_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", + "@nv_third_party_deps//:org_wiremock_wiremock_standalone", +] + +nvct_library( + name = "nvct_core_test_fixtures", + srcs = NVCT_CORE_TEST_SRCS, + deps = NVCT_CORE_TEST_DEPS, + resources = NVCT_CORE_TEST_RESOURCES, +) + +nvct_library_test( + name = "tests", + coverage_library = ":nvct_core", + srcs = NVCT_CORE_TEST_SRCS, + deps = NVCT_CORE_TEST_DEPS, + data = [ + "//src/control-plane-services/cloud-tasks:integration_local_env", + ], + # Spring Framework 7 pauses cached contexts when switching configurations. + # The embedded gRPC server cannot reliably rebind its random port during + # that pause/restart cycle on Linux, so retain the pre-Spring-7 lifecycle. + jvm_flags = ["-Dspring.test.context.cache.pause=never"], + resources = NVCT_CORE_TEST_RESOURCES, + tags = [ + "exclusive", + "requires-docker", + ], + timeout = "long", +) diff --git a/src/control-plane-services/cloud-tasks/nvct-core/local_env b/src/control-plane-services/cloud-tasks/nvct-core/local_env new file mode 120000 index 000000000..a02c0a7d3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/local_env @@ -0,0 +1 @@ +../local_env \ No newline at end of file diff --git a/src/control-plane-services/cloud-tasks/nvct-core/pom.xml b/src/control-plane-services/cloud-tasks/nvct-core/pom.xml new file mode 100644 index 000000000..573642cd4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/pom.xml @@ -0,0 +1,343 @@ + + + + 4.0.0 + + + com.nvidia.nvct + cloud-tasks + 0.0.1-SNAPSHOT + + + nvct-core + NVCT Core + jar + + NVIDIA Cloud Tasks Core Library — Shared library containing core business logic shared by + both open-source/self-hosted and managed environments. + + + + + + 8.19.0 + 1.3.2 + 1.7.1 + + + 3.1.0.RELEASE + 3.25.6 + 0.6.1 + 1.63.0 + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-jackson + + + + org.springframework + spring-webflux + + + org.springframework + spring-context-support + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-aspectj + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + + + + + io.micrometer + micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-api + + + net.devh + grpc-server-spring-boot-starter + ${spring-boot.grpc.version} + + + guava + com.google.guava + + + + + org.springframework.cloud + spring-cloud-starter-kubernetes-client-config + + + + + + com.bucket4j + bucket4j-core + + + + + + org.springframework.retry + spring-retry + + + + + com.nvidia.boot + nv-boot-starter-registries + + + com.nvidia.boot + nv-boot-starter-exceptions + + + com.nvidia.boot + nv-boot-starter-core + + + com.nvidia.boot + nv-boot-starter-audit + + + com.nvidia.boot + nv-boot-starter-reloadable-properties + + + com.nvidia.boot + nv-boot-starter-cassandra + + + com.nvidia.boot + nv-boot-starter-observability + + + + + io.projectreactor.netty + reactor-netty-http + + + org.apache.cassandra + java-driver-metrics-micrometer + + + tools.jackson.module + jackson-module-blackbird + + + + com.github.ben-manes.caffeine + caffeine + + + + + + com.github.ben-manes.caffeine + guava + + + net.javacrumbs.shedlock + shedlock-provider-cassandra + + + net.javacrumbs.shedlock + shedlock-spring + + + + + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + + + + javax.annotation + javax.annotation-api + ${javax-annotation-api.version} + + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-restclient + test + + + org.springframework.boot + spring-boot-starter-data-cassandra-test + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.assertj + assertj-core + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-cassandra + test + + + org.testcontainers + testcontainers-localstack + test + + + org.wiremock + wiremock-standalone + test + + + com.nvidia.boot + nv-boot-mock-servers-test + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + + + + + kr.motd.maven + os-maven-plugin + ${os-maven-plugin.version} + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-integration-local-env + process-test-resources + + copy-resources + + + ${project.build.testOutputDirectory}/local_env + + + ${project.basedir}/local_env + + docker-compose.test.yml + cassandra/** + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + ${protobuf-maven-plugin.version} + + grpc-java + + com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier} + + + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + + compile + compile-custom + + + + + + + diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/AuthManagerResolverConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/AuthManagerResolverConfiguration.java new file mode 100644 index 000000000..ad9170efd --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/AuthManagerResolverConfiguration.java @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import static org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER; +import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames.EXP; +import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames.IAT; + +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; +import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.util.CollectionUtils; + +@Configuration(proxyBeanMethods = false) +public class AuthManagerResolverConfiguration { + + private final ApiKeysService apiKeysService; + private final String issuerUri; + private final String jwkSetUri; + private final SignatureAlgorithm jwsAlgorithm; + + + public AuthManagerResolverConfiguration( + ApiKeysService apiKeysService, + @Value("${spring.security.oauth2.resourceserver.jwt.jws-algorithms}") String jwsAlgorithm, + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri) { + this.apiKeysService = apiKeysService; + this.issuerUri = issuerUri; + this.jwkSetUri = jwkSetUri; + this.jwsAlgorithm = SignatureAlgorithm.valueOf(jwsAlgorithm); // Fail-fast if invalid. + } + + @Bean + AuthenticationManagerResolver authenticationManagerResolver() { + var jwtResolver = jwtResolver(); + return request -> { + var authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorization != null && authorization.startsWith("Bearer nvapi-")) { + return apiKeyAuthenticationManager(); + } + return jwtResolver.resolve(request); + }; + } + + private AuthenticationManager apiKeyAuthenticationManager() { + var provider = new OpaqueTokenAuthenticationProvider(apiKeyIntrospector()); + provider.setAuthenticationConverter(apiKeyConverter()); + return provider::authenticate; + } + + private OpaqueTokenIntrospector apiKeyIntrospector() { + return token -> apiKeysService.resolveNCAIdFromApiKey(token).getOAuth2Principal(); + } + + private static OpaqueTokenAuthenticationConverter apiKeyConverter() { + return (introspectedToken, authenticatedPrincipal) -> { + Instant iat = authenticatedPrincipal.getAttribute(IAT); + Instant exp = authenticatedPrincipal.getAttribute(EXP); + var accessToken = new OAuth2AccessToken(BEARER, introspectedToken, iat, exp); + return new BearerTokenAuthentication(authenticatedPrincipal, accessToken, + authenticatedPrincipal.getAuthorities()); + }; + } + + private JwtIssuerAuthenticationManagerResolver jwtResolver() { + var managers = Map.of(issuerUri, jwtAuthenticationManager()); + return new JwtIssuerAuthenticationManagerResolver(managers::get); + } + + private AuthenticationManager jwtAuthenticationManager() { + var provider = new JwtAuthenticationProvider(jwtDecoder()); + provider.setJwtAuthenticationConverter(jwtAuthenticationConverter()); + return provider::authenticate; + } + + private JwtDecoder jwtDecoder() { + var decoder = NimbusJwtDecoder + .withJwkSetUri(jwkSetUri) + .jwsAlgorithm(jwsAlgorithm) + .build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri)); + return decoder; + } + + private JwtAuthenticationConverter jwtAuthenticationConverter() { + var converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(jwt -> { + var scopes = jwt.getClaimAsStringList("scopes"); + if (CollectionUtils.isEmpty(scopes)) { + return Collections.emptyList(); + } + return scopes.stream() + .map(SimpleGrantedAuthority::new) + .map(GrantedAuthority.class::cast) + .toList(); + }); + return converter; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ClockConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ClockConfiguration.java new file mode 100644 index 000000000..63d25fdf4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ClockConfiguration.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import java.time.Clock; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class ClockConfiguration { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/DistributedLockConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/DistributedLockConfiguration.java new file mode 100644 index 000000000..1f5ddde64 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/DistributedLockConfiguration.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.datastax.oss.driver.api.core.ConsistencyLevel; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.cassandra.CassandraLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableSchedulerLock(defaultLockAtMostFor = "60s") +public class DistributedLockConfiguration { + + @Bean + public LockProvider lockProvider( + CqlSession cqlSession, + // Higher quorum for cross region locks + @Value("${nvct.scheduled-routines.lock-consistency:EACH_QUORUM}") + DefaultConsistencyLevel consistencyLevel) { + return new CassandraLockProvider( + CassandraLockProvider.Configuration.builder() + .withCqlSession(cqlSession) + .withTableName("lock") + // higher quorum for cross region locks + .withConsistencyLevel(consistencyLevel) + .withSerialConsistencyLevel(ConsistencyLevel.SERIAL) + .build()); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/GrpcConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/GrpcConfiguration.java new file mode 100644 index 000000000..849eb56c3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/GrpcConfiguration.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import io.grpc.Status; +import io.grpc.Status.Code; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import net.devh.boot.grpc.server.advice.GrpcAdvice; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandler; +import net.devh.boot.grpc.server.security.authentication.BearerAuthenticationReader; +import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader; +import net.devh.boot.grpc.server.security.interceptors.DefaultAuthenticatingServerInterceptor; +import net.devh.boot.grpc.server.security.interceptors.ExceptionTranslatingServerInterceptor; +import net.devh.boot.grpc.server.serverfactory.GrpcServerConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.web.ErrorResponseException; + +@Configuration(proxyBeanMethods = false) +@GrpcAdvice +public class GrpcConfiguration { + + @Bean + public GrpcAuthenticationReader authenticationReader() { + return new BearerAuthenticationReader(BearerTokenAuthenticationToken::new); + } + + @Bean + public ExceptionTranslatingServerInterceptor exceptionTranslatingServerInterceptor() { + return new ExceptionTranslatingServerInterceptor(); + } + + @Bean + public DefaultAuthenticatingServerInterceptor authenticatingServerInterceptor( + final GrpcAuthenticationReader authenticationReader) { + // pass-through auth manager, since we need to auth in a non-blocking context + return new DefaultAuthenticatingServerInterceptor(authentication -> authentication, + authenticationReader); + } + + @Bean + public GrpcServerConfigurer keepAliveServerConfigurer() { + return serverBuilder -> { + if (serverBuilder instanceof NettyServerBuilder sb) { + sb.permitKeepAliveWithoutCalls(true); + } + }; + } + + @GrpcExceptionHandler + public Status handleErrorResponseException(ErrorResponseException e) { + var code = switch (e.getStatusCode().value()) { + case 400 -> Code.INTERNAL; + case 401 -> Code.UNAUTHENTICATED; + case 403 -> Code.PERMISSION_DENIED; + case 404 -> Code.NOT_FOUND; + case 429, 502, 503, 504 -> Code.UNAVAILABLE; + default -> Code.UNKNOWN; + }; + return Status.fromCode(code).withDescription(e.getBody().getDetail()).withCause(e); + } + + @GrpcExceptionHandler + public Status handleException(AccessDeniedException e) { + return handleErrorResponseException(new ForbiddenException(e.getMessage(), e.getCause())); + } + + @GrpcExceptionHandler + public Status handleException(AuthenticationException e) { + return handleErrorResponseException( + new UnauthorizedException(e.getMessage(), e.getCause())); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/JacksonConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/JacksonConfiguration.java new file mode 100644 index 000000000..27a0dbdcb --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/JacksonConfiguration.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.core.StreamWriteFeature; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.cfg.EnumFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.blackbird.BlackbirdModule; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; + +@Slf4j +@Configuration +public class JacksonConfiguration { + + @Bean + @Primary + public JsonMapper jsonMapper() { + return JsonMapper.builder() + .addModule(new BlackbirdModule()) + .addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class) + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .enable(StreamWriteFeature.STRICT_DUPLICATE_DETECTION) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(EnumFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MethodSecurityConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MethodSecurityConfiguration.java new file mode 100644 index 000000000..40ad42590 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MethodSecurityConfiguration.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +@Configuration +@EnableMethodSecurity +public class MethodSecurityConfiguration { + // empty to inherit defaults +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MetricsConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MetricsConfiguration.java new file mode 100644 index 000000000..77c8fffad --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/MetricsConfiguration.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +public class MetricsConfiguration { + @Bean + MeterRegistryCustomizer nvctMeterRegistryCustomizer() { + return registry -> registry.config() + .onMeterAdded(meter -> log.debug("meter added: {}", meter.getId())) + .onMeterRemoved(meter -> log.debug("meter removed: {}", meter.getId())) + .onMeterRegistrationFailed((id, reason) -> log.error( + "meter registration failed, id:{} reason:{}", id, reason)); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/NotaryJwtConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/NotaryJwtConfiguration.java new file mode 100644 index 000000000..b2e790e56 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/NotaryJwtConfiguration.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@Configuration(proxyBeanMethods = false) +public class NotaryJwtConfiguration { + + @Bean + @Qualifier("notaryJwtDecoder") + public JwtDecoder notaryJwtDecoder(@Value("${nvct.notary.jwt.jwk-set-uri}") String jwkSetUri) { + var decoder = NimbusJwtDecoder + .withJwkSetUri(jwkSetUri) + .jwsAlgorithm(SignatureAlgorithm.ES256) + .build(); + // Notary worker assertion tokens intentionally do not carry exp. We still need + // signature verification here, while temporal checks are handled separately in a + // different validator. + decoder.setJwtValidator(token -> OAuth2TokenValidatorResult.success()); + return decoder; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ObservationConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ObservationConfiguration.java new file mode 100644 index 000000000..86772735a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ObservationConfiguration.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.aop.ObservedAspect; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class ObservationConfiguration { + + @Bean + public ObservedAspect observedAspect(ObservationRegistry observationRegistry) { + return new ObservedAspect(observationRegistry); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OpenApiDocConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OpenApiDocConfiguration.java new file mode 100644 index 000000000..72960e9c1 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OpenApiDocConfiguration.java @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.nvidia.nvct.rest.event.XAccountEventController; +import com.nvidia.nvct.rest.result.XAccountResultController; +import com.nvidia.nvct.rest.secret.XAccountSecretManagementController; +import com.nvidia.nvct.rest.task.XAccountTaskManagementController; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import jakarta.annotation.PostConstruct; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.utils.SpringDocUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiDocConfiguration { + + private final boolean excludeSuperAdminApis; + + public OpenApiDocConfiguration( + @Value("${nvct.openapi.exclude.super-admin-apis:true}") boolean excludeSuperAdminApis) { + this.excludeSuperAdminApis = excludeSuperAdminApis; + } + + @PostConstruct + public void postConstruct() { + // In general, we do NOT want details of our NVIDIA Super Admin endpoints to be exposed + // via our OpenAPI Specs generated from the /v3/openapi endpoint. However, we still want + // the ability to generate OpenAPI Specs that contain details for all(Super Admin and + // Account Admin) the endpoints to help our SRE and UI teams. So, we cannot just + // use the @Hidden annotation in our cross-account controllers and exclude the docs for + // Super Admin endpoints the OpenAPI Specs. We are planning on automating the process of + // generating OpenAPI Specs from within a dedicated job in the CI pipeline. Once a MR is + // merged and a new tag is ready, the job will generate OpenAPI Specs containing just + // the Account Admin endpoints by doing this: + // + // $ cd local_env; docker-compose up; cd .. + // $ java -Dspring.profiles.active=local -jar target/app.jar + // $ curl localhost:8080/v3/openapi > nvct-openapi.json + // + // Then, the job will terminate the app and generate uber OpenAPI Specs that will contain + // details of both NVIDIA Super Admin and Account Admin endpoints like this: + // + // $ java -Dspring.profiles.active=local \ + // -Dnvct.openapi.exclude.super-admin-apis=false -jar target/app.jar + // $ curl localhost:8080/v3/openapi > full-nvct-openapi.json + // + // Once the job generates the two JSON files, it will shutdown the docker containers, + // terminate the app, and automatically update the gitlab repo containing OpenAPI Specs + // with the newly generated JSON files. + if (excludeSuperAdminApis) { + var superAdminControllers = List.of(XAccountEventController.class, + XAccountResultController.class, + XAccountTaskManagementController.class, + XAccountSecretManagementController.class) + .toArray(new Class[0]); + SpringDocUtils.getConfig().addHiddenRestControllers(superAdminControllers); + } + } + + // Using Cloud Tasks as title instead of spring.application.name property. We cannot change + // the value of spring.application.name property easily at this point. + @SuppressWarnings("unused") + @Bean + public OpenAPI customOpenAPI( + @Value("${spring.application.version}") String version) { + var title = "Cloud Tasks"; + return new OpenAPI() + .info(new Info().title(title) + .version(version) + .contact(new Contact().name("NVIDIA").url("https://www.nvidia.com/")) + .termsOfService("https://www.nvidia.com/en-us/legal_info")); + + } + + @SuppressWarnings("unused") + @Bean + public OperationCustomizer operationCustomizer() { + // Replace '\n' introduced due to Java multi-line strings in the description + // field of @Operation annotation. + return (operation, handlerMethod) -> { + var description = operation.getDescription(); + if (StringUtils.isNotBlank(description)) { + operation.setDescription(description.replace("\n", " ")); + } + return operation; + }; + } + + @SuppressWarnings("unused") + @Bean + public OpenApiCustomizer openApiCustomizer() { + // Replace '\n' introduced due to Java multi-line strings in the description + // field of @Tag annotation. + return openApi -> openApi.getTags().forEach(tag -> { + var description = tag.getDescription(); + if (StringUtils.isNotBlank(description)) { + tag.setDescription(description.replace("\n", " ")); + } + }); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OutboundHttpResourcesConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OutboundHttpResourcesConfiguration.java new file mode 100644 index 000000000..de258d5e0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/OutboundHttpResourcesConfiguration.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.nvidia.nvct.service.ess.EssClient; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.ngc.NgcRegistryClient; +import com.nvidia.nvct.service.nvcf.NvcfClient; +import com.nvidia.nvct.service.reval.RevalClient; +import com.nvidia.nvct.service.token.client.NotaryClient; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Creates singleton {@link ManagedHttpResources} beans for each outbound client. + * These pools survive {@link org.springframework.cloud.context.config.annotation.RefreshScope + * @RefreshScope} refreshes — only the {@code WebClient} is rebuilt when config changes. + * Spring disposes them on application shutdown via {@code destroyMethod = "close"}. + */ +@Configuration +class OutboundHttpResourcesConfiguration { + + @Bean(destroyMethod = "close") + ManagedHttpResources essHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(EssClient.CLIENT_REGISTRATION_ID); + } + + @Bean(destroyMethod = "close") + ManagedHttpResources ngcRegistryHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(NgcRegistryClient.CLIENT_REGISTRATION_ID); + } + + @Bean(destroyMethod = "close") + ManagedHttpResources nvcfHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(NvcfClient.CLIENT_REGISTRATION_ID); + } + + @Bean(destroyMethod = "close") + ManagedHttpResources revalHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(RevalClient.CLIENT_REGISTRATION_ID); + } + + @Bean(destroyMethod = "close") + ManagedHttpResources icmsHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(IcmsClient.CLIENT_REGISTRATION_ID); + } + + @Bean(destroyMethod = "close") + ManagedHttpResources notaryHttpResources() { + return NvctOAuth2ClientUtils + .getClientHttpConnectorManaged(NotaryClient.CLIENT_REGISTRATION_ID); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/RegistryConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/RegistryConfiguration.java new file mode 100644 index 000000000..76b4f68b1 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/RegistryConfiguration.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import com.nvidia.boot.registries.configurations.RegistryConfigPathProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registry configuration for NVCT. Uses {@code proxyBeanMethods = false} to avoid early + * singleton creation that triggers ConfigurationClassPostProcessor warnings. Safe here + * because the {@code @Bean} method does not call other {@code @Bean} methods on this class. + */ +@Configuration(proxyBeanMethods = false) +public class RegistryConfiguration { + private static final String REGISTRY_CONFIG_PATH = "nvct.registries"; + + @Bean + public RegistryConfigPathProvider registryConfigPathProvider() { + return () -> REGISTRY_CONFIG_PATH; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/SecurityConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/SecurityConfiguration.java new file mode 100644 index 000000000..055608b06 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/SecurityConfiguration.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration; + +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +import com.nvidia.nvct.configuration.filters.ExceptionHandlerFilter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + AuthenticationManagerResolver authenticationManagerResolver, + ExceptionHandlerFilter exceptionHandlerFilter) { + http + // CSRF protection is intentionally disabled: this is a stateless + // (SessionCreationPolicy.STATELESS) OAuth2 resource server authenticated by + // JWT / api-key bearer tokens, with no cookies or server-side sessions. CSRF is + // a cookie/session-auth attack and does not apply here; enabling it would break + // the token-only API without adding any protection. + .csrf(csrf -> csrf.disable()) // codeql[java/spring-disabled-csrf-protection] + .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) + // Reuse the ValidationAwareExceptionHandler to handle the exception inside + // the filters,especially for the custom authentication resolver exceptions. + .addFilterBefore(exceptionHandlerFilter, LogoutFilter.class) + // Enable JWT and api-key security + .oauth2ResourceServer(configurer -> configurer + .authenticationManagerResolver(authenticationManagerResolver)) + .authorizeHttpRequests( + request -> request + .requestMatchers("/health").permitAll() + .requestMatchers("/v3/openapi").permitAll() + // management port is not exposed via load balancer. Readiness + // and liveness probes, metrics, prometheus etc. are accessible + // via management port. + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated()); + return http.build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ExceptionTracingExceptionHandler.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ExceptionTracingExceptionHandler.java new file mode 100644 index 000000000..af5073ad6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ExceptionTracingExceptionHandler.java @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.exceptions; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ConflictException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.exceptions.handlers.BootMvcExceptionHandler; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public class ExceptionTracingExceptionHandler extends BootMvcExceptionHandler { + + @Autowired + private Tracer tracer; + + @Override + protected void recordException(Exception ex) { + NvctUtils.recordExceptionUsingCurrentSpan(tracer, ex); + } + + @ExceptionHandler(WebClientResponseException.BadRequest.class) + protected ResponseEntity handleException( + WebClientResponseException.BadRequest ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.Unauthorized.class) + protected ResponseEntity handleException( + WebClientResponseException.Unauthorized ex, + WebRequest request) throws Exception { + return super.handleException( + new UnauthorizedException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.Forbidden.class) + protected ResponseEntity handleException( + WebClientResponseException.Forbidden ex, + WebRequest request) throws Exception { + return super.handleException( + new ForbiddenException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.NotFound.class) + protected ResponseEntity handleException( + WebClientResponseException.NotFound ex, + WebRequest request) throws Exception { + return super.handleException( + new NotFoundException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.Conflict.class) + protected ResponseEntity handleException( + WebClientResponseException.Conflict ex, + WebRequest request) throws Exception { + return super.handleException( + new ConflictException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.TooManyRequests.class) + protected ResponseEntity handleException( + WebClientResponseException.TooManyRequests ex, + WebRequest request) throws Exception { + return super.handleException( + new TooManyRequestsException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.MethodNotAllowed.class) + protected ResponseEntity handleException( + WebClientResponseException.MethodNotAllowed ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.NotAcceptable.class) + protected ResponseEntity handleException( + WebClientResponseException.NotAcceptable ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.Gone.class) + protected ResponseEntity handleException( + WebClientResponseException.Gone ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.UnsupportedMediaType.class) + protected ResponseEntity handleException( + WebClientResponseException.UnsupportedMediaType ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + + @ExceptionHandler(WebClientResponseException.UnprocessableContent.class) + protected ResponseEntity handleException( + WebClientResponseException.UnprocessableContent ex, + WebRequest request) throws Exception { + return super.handleException( + new BadRequestException(ex.getMessage(), ex.getCause()), request); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandler.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandler.java new file mode 100644 index 000000000..1a69886b4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandler.java @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.exceptions; + +import tools.jackson.databind.JsonNode; +import com.nvidia.boot.exceptions.BootResponseException; +import jakarta.annotation.Nonnull; +import java.util.List; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.jspecify.annotations.Nullable; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; + +@Configuration +@ControllerAdvice +public class ValidationAwareExceptionHandler extends ExceptionTracingExceptionHandler { + + private static final int MAX_REJECTED_VALUE_LOG_CHARS = 256; + + private static String toCleanMessage(HandlerMethodValidationException ex) { + StringBuilder sb = new StringBuilder("Validation failed with "); + appendError(sb, ex.getAllErrors()); + return sb.toString(); + } + + private static void appendError( + StringBuilder sb, List errors) { + for (MessageSourceResolvable error : errors) { + sb.append('['); + if (error instanceof FieldError fieldError) { + sb.append("Field error in object '") + .append(fieldError.getObjectName()) + .append("' on field '") + .append(fieldError.getField()) + .append("': rejected value [") + .append(truncateRejectedValueForLog(fieldError.getRejectedValue())) + .append("]; ") + .append(fieldError.getDefaultMessage()); + } else { + sb.append(error); + } + sb.append("] "); + } + sb.deleteCharAt(sb.length() - 1); + } + + /** + * Keeps validation problem responses and framework DEBUG logs bounded when rejected values are + * very large (e.g. secret value length integration tests). + */ + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + var problemDetail = + ProblemDetail.forStatusAndDetail(status, buildMethodArgumentNotValidDetail(ex)); + return handleExceptionInternal(ex, problemDetail, headers, status, request); + } + + private static String buildMethodArgumentNotValidDetail(MethodArgumentNotValidException ex) { + var errors = ex.getBindingResult().getFieldErrors(); + if (errors.isEmpty()) { + return "Validation failed"; + } + StringBuilder sb = new StringBuilder("Validation failed: "); + for (FieldError fe : errors) { + sb.append("[object=") + .append(fe.getObjectName()) + .append(", field=") + .append(fe.getField()) + .append(", rejected=") + .append(truncateRejectedValueForLog(fe.getRejectedValue())) + .append(", message=") + .append(fe.getDefaultMessage()) + .append("] "); + } + return sb.toString().trim(); + } + + private static String truncateRejectedValueForLog(@Nullable Object rejected) { + if (rejected == null) { + return "null"; + } + String raw; + if (rejected instanceof JsonNode node) { + if (node.isString()) { + raw = node.asString(); + } else { + raw = node.toString(); + } + } else if (rejected instanceof CharSequence seq) { + raw = seq.toString(); + } else { + raw = ObjectUtils.nullSafeConciseToString(rejected); + } + if (raw.length() <= MAX_REJECTED_VALUE_LOG_CHARS) { + return raw; + } + return raw.substring(0, MAX_REJECTED_VALUE_LOG_CHARS) + + "... (truncated for log/response, total length " + + raw.length() + + " chars)"; + } + + @Override + protected ResponseEntity handleHandlerMethodValidationException( + @Nonnull HandlerMethodValidationException ex, @Nonnull HttpHeaders headers, + @Nonnull HttpStatusCode status, @Nonnull WebRequest request) { + var problemDetail = ProblemDetail.forStatusAndDetail(status, toCleanMessage(ex)); + return super.handleExceptionInternal(ex, problemDetail, headers, status, request); + } + + @Nonnull + @Override + protected ResponseEntity handleExceptionInternal( + @Nonnull Exception ex, + Object body, + HttpHeaders headers, + @Nonnull HttpStatusCode status, + @Nonnull WebRequest request) { + Object problemBody = body instanceof ProblemDetail ? body : resolveBody(ex, status); + return super.handleExceptionInternal(ex, problemBody, headers, status, request); + } + + private static ProblemDetail resolveBody(Exception ex, HttpStatusCode status) { + if (ex instanceof BootResponseException bre) { + return bre.getBody(); + } + return ProblemDetail.forStatusAndDetail(status, ex.getMessage()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/filters/ExceptionHandlerFilter.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/filters/ExceptionHandlerFilter.java new file mode 100644 index 000000000..7ebfd8887 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/filters/ExceptionHandlerFilter.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.filters; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Component +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + @Autowired + @Qualifier("handlerExceptionResolver") + private HandlerExceptionResolver resolver; + + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + // When the exception resolver doesn't handle the exception from filter, we + // will throw the exception and let it act as default spring boot behavior. + // If we don't add this, that will make 200 status code for those unhandled exceptions. + if (resolver.resolveException(request, response, null, e) == null) { + throw e; + } + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/AccountRateLimiterProperties.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/AccountRateLimiterProperties.java new file mode 100644 index 000000000..0eb79bbf5 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/AccountRateLimiterProperties.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.ratelimit; + +import jakarta.annotation.PostConstruct; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Data +@Configuration +@RefreshScope +@ConfigurationProperties(prefix = "nvct.rate-limiters.account-rate-limiter") +public class AccountRateLimiterProperties { + + private static final String MESG_INVALID_OVERRIDES_ENTRY = + "Invalid or duplicate account entry in the account-rate-limiter's overrides config"; + + private long allowedInvocationsPerSecond = 100; + private List overrides; + + @Setter(AccessLevel.NONE) + private Map overridesMap = Collections.emptyMap(); + + @Setter(AccessLevel.NONE) + private AccountRateCappingProperties defaultRateCappingProperties; + + @Data + @Builder + public static class AccountRateCappingProperties { + private String ncaId; + private long allowedInvocationsPerSecond; + } + + @PostConstruct + void postConstruct() { + if (!CollectionUtils.isEmpty(overrides)) { + this.overridesMap = overrides.stream() + .filter(props -> StringUtils.isNotBlank(props.getNcaId())) + .collect(Collectors.toMap(AccountRateCappingProperties::getNcaId, + Function.identity())); + + if (overridesMap.size() != overrides.size()) { + log.warn(MESG_INVALID_OVERRIDES_ENTRY); + } + } + + this.defaultRateCappingProperties = + AccountRateCappingProperties.builder() + .ncaId("*") + .allowedInvocationsPerSecond(allowedInvocationsPerSecond) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/TaskRateLimiterProperties.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/TaskRateLimiterProperties.java new file mode 100644 index 000000000..138722ad1 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/ratelimit/TaskRateLimiterProperties.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.ratelimit; + +import com.nvidia.nvct.util.NvctConstants; +import jakarta.annotation.PostConstruct; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Data +@Configuration +@RefreshScope +@ConfigurationProperties(prefix = "nvct.rate-limiters.task-rate-limiter") +public class TaskRateLimiterProperties { + + private static final String MESG_INVALID_OVERRIDES_ENTRY = + "Invalid or duplicate entry in the task-rate-limiter's overrides config"; + + private long allowedInvocationsPerSecond = 100; + private List overrides; + + // Only contains override entries that have taskId. + @Setter(AccessLevel.NONE) + private Map taskOverridesMap = Collections.emptyMap(); + + @Setter(AccessLevel.NONE) + private TaskRateCappingProperties defaultRateCappingProperties; + + @Builder + @Data + public static class TaskRateCappingProperties { + private UUID taskId; + private long allowedInvocationsPerSecond; + } + + @PostConstruct + void postConstruct() { + if (!CollectionUtils.isEmpty(overrides)) { + this.taskOverridesMap = overrides.stream() + .filter(props -> props.getTaskId() != null) + .collect(Collectors.toMap( + TaskRateCappingProperties::getTaskId, Function.identity())); + + if (taskOverridesMap.size() != overrides.size()) { + log.warn(MESG_INVALID_OVERRIDES_ENTRY); + } + } + + this.defaultRateCappingProperties = + TaskRateCappingProperties.builder() + .taskId(NvctConstants.UUID_WILDCARD) + .allowedInvocationsPerSecond(allowedInvocationsPerSecond) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/FixedBearerExchangeFilterFunction.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/FixedBearerExchangeFilterFunction.java new file mode 100644 index 000000000..2b00a074d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/FixedBearerExchangeFilterFunction.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.staticclientauth; + +import jakarta.annotation.Nonnull; +import java.util.function.Supplier; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import reactor.core.publisher.Mono; + + +public record FixedBearerExchangeFilterFunction(Supplier tokenSupplier) implements + ExchangeFilterFunction { + + @Nonnull + @Override + public Mono filter(@Nonnull ClientRequest request, ExchangeFunction next) { + return next.exchange(ClientRequest.from(request) + .headers(headers -> headers.setBearerAuth(tokenSupplier.get())) + .build()); + + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/StaticClientAuthConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/StaticClientAuthConfiguration.java new file mode 100644 index 000000000..0a81eae03 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/configuration/staticclientauth/StaticClientAuthConfiguration.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.staticclientauth; + + +import lombok.Data; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; + +@AutoConfiguration +public class StaticClientAuthConfiguration { + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.notary.static.token") + @ConfigurationProperties("nvct.notary.static") + public static class StaticClientNotaryProperties { + + private String token; + } + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.nvcf.static.token") + @ConfigurationProperties("nvct.nvcf.static") + public static class StaticClientNvcfProperties { + + private String token; + } + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.reval.static.token") + @ConfigurationProperties("nvct.reval.static") + public static class StaticClientRevalProperties { + + private String token; + } + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.icms.static.token") + @ConfigurationProperties("nvct.icms.static") + public static class StaticClientIcmsProperties { + + private String token; + } + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.ess.static.token") + @ConfigurationProperties("nvct.ess.static") + public static class StaticClientEssProperties { + + private String token; + } + + @Data + @RefreshScope + @Configuration + @ConditionalOnProperty("nvct.api-keys.static.token") + @ConfigurationProperties("nvct.api-keys.static") + public static class StaticClientApiKeysProperties { + + private String token; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcSkywayService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcSkywayService.java new file mode 100644 index 000000000..70f3f9c7c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcSkywayService.java @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + + +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.nvct.proto.SkywayAuthRequest; +import com.nvidia.nvct.proto.SkywayAuthResponse; +import com.nvidia.nvct.proto.SkywayGrpc; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.instance.InstanceService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.service.token.GrpcAuthService; +import io.grpc.stub.StreamObserver; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; + +@Slf4j +@GrpcService +@RequiredArgsConstructor +public class GrpcSkywayService extends SkywayGrpc.SkywayImplBase { + private final GrpcAuthService grpcAuthService; + private final AccountService accountService; + private final InstanceService instanceService; + private final TaskService taskService; + + private static final String MESG_INVALID_NCA_ID = "Permission denied for the task ID: %s"; + private static final String MESG_NO_ACTIVE_INSTANCE = "No active instances for the task ID: %s"; + private static final String SKYWAY_AUTH_SCOPE = "skyway:auth"; + + @Override + public void authGetLogs( + SkywayAuthRequest request, + StreamObserver responseObserver) { + validateAuth(); + var authentication = validateClientAuth(request, SCOPE_LIST_TASKS); + var authResponse = buildAuthResponse(request, authentication); + responseObserver.onNext(authResponse); + responseObserver.onCompleted(); + } + + + @Override + public void authExecuteCommand( + SkywayAuthRequest request, + StreamObserver responseObserver) { + validateAuth(); + var authentication = validateClientAuth(request, SCOPE_LAUNCH_TASK); + var authResponse = buildAuthResponse(request, authentication); + responseObserver.onNext(authResponse); + responseObserver.onCompleted(); + } + + @Override + public void authListInstances( + SkywayAuthRequest request, + StreamObserver responseObserver) { + validateAuth(); + var authentication = validateClientAuth(request, SCOPE_LIST_TASKS); + var authResponse = buildAuthResponse(request, authentication); + responseObserver.onNext(authResponse); + responseObserver.onCompleted(); + } + + private void validateAuth() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof BearerTokenAuthenticationToken bearer)) { + throw new UnauthorizedException("Unauthorized"); + } + + grpcAuthService.validateBearer(bearer, SKYWAY_AUTH_SCOPE); + } + + private Authentication validateClientAuth( + SkywayAuthRequest clientInvokeRequest, + String targetScope) { + var bearer = new BearerTokenAuthenticationToken( + clientInvokeRequest.getClientAuthorizationToken()); + + // check if super admin invocation + if (clientInvokeRequest.hasTargetNcaId()) { + return grpcAuthService.validateBearer(bearer, "admin:" + targetScope); + } + return grpcAuthService.validateBearer(bearer, targetScope); + } + + private SkywayAuthResponse buildAuthResponse( + SkywayAuthRequest request, Authentication authentication) { + var ncaId = request.hasTargetNcaId() ? request.getTargetNcaId() + : accountService.getNcaId(authentication); + var authResponseBuilder = SkywayAuthResponse.newBuilder() + .setTaskId(request.getTaskId()) + .setClientNcaId(ncaId) + .setClientAuthSubject(authentication.getName()); + + try { + var taskId = UUID.fromString(request.getTaskId()); + var taskEntity = taskService.fetchTask(taskId); + if (!taskEntity.getNcaId().equals(ncaId)) { + var errMsg = MESG_INVALID_NCA_ID.formatted(taskId); + log.error(errMsg); + throw new ForbiddenException(errMsg); + } + + var instances = instanceService + .getInstances(taskEntity) + .orElseThrow( + () -> new NotFoundException(MESG_NO_ACTIVE_INSTANCE.formatted(taskId))) + .stream() + .map(instance -> SkywayAuthResponse.Instance.newBuilder() + .setInstanceId(instance.getInstanceId()) + .setLocation(instance.getLocation()) + .setState(instance.getInstanceState() == null ? "UNKNOWN" : + instance.getInstanceState().toString()) + .build()) + .toList(); + authResponseBuilder.addAllInstances(instances); + } catch (NotFoundException e) { + log.error(e.getMessage(), e); + throw new ForbiddenException(e.getMessage()); + } + return authResponseBuilder.build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcWorkerService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcWorkerService.java new file mode 100644 index 000000000..bd5da1678 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/GrpcWorkerService.java @@ -0,0 +1,331 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.LAUNCHED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.proto.ExecutionStatus.TASK_CONTAINER_INITIALIZING; +import static com.nvidia.nvct.proto.ExecutionStatus.WORKER_TERMINATED; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; +import static com.nvidia.nvct.util.ProtoMappingUtils.toTimestamp; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.nvct.proto.ArtifactsRequest; +import com.nvidia.nvct.proto.ArtifactsResponse; +import com.nvidia.nvct.proto.ArtifactsResponse.ArtifactResponse; +import com.nvidia.nvct.proto.ConnectRequest; +import com.nvidia.nvct.proto.ConnectResponse; +import com.nvidia.nvct.proto.ExecutionStatus; +import com.nvidia.nvct.proto.HeartbeatRequest; +import com.nvidia.nvct.proto.HeartbeatResponse; +import com.nvidia.nvct.proto.RefreshTokenRequest; +import com.nvidia.nvct.proto.RefreshTokenResponse; +import com.nvidia.nvct.proto.ResultMetadataRequest; +import com.nvidia.nvct.proto.ResultMetadataResponse; +import com.nvidia.nvct.proto.SecretCredentialsRequest; +import com.nvidia.nvct.proto.SecretCredentialsResponse; +import com.nvidia.nvct.proto.WorkerGrpc; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.metrics.TaskErrorMetricsService; +import com.nvidia.nvct.service.metrics.TaskRunningMetricsService; +import com.nvidia.nvct.service.metrics.TaskSuccessMetricsService; +import com.nvidia.nvct.service.registry.RegistryArtifactService; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.icms.IcmsService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.service.token.TokenService; +import com.nvidia.nvct.util.NvctUtils; +import io.grpc.stub.StreamObserver; +import io.micrometer.tracing.Tracer; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.beans.factory.annotation.Value; + +@Slf4j +@GrpcService +public class GrpcWorkerService extends WorkerGrpc.WorkerImplBase { + + private static final String MESG_START_GRPC_ENDPOINT = + "Task id '{}': Starting call to gRPC endpoint '{}'"; + private static final String MESG_END_GRPC_ENDPOINT = + "Task id '{}': Completed call to gRPC endpoint '{}'"; + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + private static final String MESG_RECEIVING_HEARTBEAT_AFTER_TERMINAL_STATUS = + "Task id '{}': Receiving heartbeat after Task has reached terminal status '{}'"; + private static final String MESG_NOT_SUPPORTED_ARTIFACT_TYPE = + "Task id '%s': Received unsupported artifact type '%s'"; + private static final String MESG_TASK_CONTAINER_INITIALIZING = + "Task id '%s': Task Container initializing"; + private static final String MESG_WORKER_TERMINATED = + "Task id '%s': Worker terminated due to SIGTERM from the control plane"; + + private static final Duration VALIDITY = Duration.ofHours(3); + + private final String awsRegion; + private final TokenService tokenService; + private final IcmsService icmsService; + private final AccountService accountService; + private final RegistryArtifactService artifactService; + private final EventService eventService; + private final TaskService taskService; + private final ResultService resultService; + private final JsonMapper jsonMapper; + private final TaskRunningMetricsService taskRunningMetricsService; + private final TaskSuccessMetricsService taskSuccessMetricsService; + private final TaskErrorMetricsService taskErrorMetricsService; + private final Tracer tracer; + + public GrpcWorkerService( + TokenService tokenService, + IcmsService icmsService, + AccountService accountService, + RegistryArtifactService artifactService, + EventService eventService, + TaskService taskService, + ResultService resultService, + JsonMapper jsonMapper, + TaskRunningMetricsService taskRunningMetricsService, + TaskSuccessMetricsService taskSuccessMetricsService, + TaskErrorMetricsService taskErrorMetricsService, + Tracer tracer, + @Value("${nvct.aws.region}") String awsRegion) { + this.tokenService = tokenService; + this.icmsService = icmsService; + this.accountService = accountService; + this.artifactService = artifactService; + this.eventService = eventService; + this.taskService = taskService; + this.resultService = resultService; + this.awsRegion = awsRegion; + this.taskRunningMetricsService = taskRunningMetricsService; + this.taskSuccessMetricsService = taskSuccessMetricsService; + this.taskErrorMetricsService = taskErrorMetricsService; + this.tracer = tracer; + this.jsonMapper = jsonMapper; + } + + @Override + public void connect(ConnectRequest request, StreamObserver responseObserver) { + var taskId = UUID.fromString(request.getTaskId()); + log.info(MESG_START_GRPC_ENDPOINT, taskId, "connect"); + + var taskEntity = taskService.fetchTask(taskId); + var ncaId = taskEntity.getNcaId(); + + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + if (taskEntity.getStatus() == QUEUED) { + taskService.updateTask(taskId, LAUNCHED); + var mesg = STATUS_CHANGE_EVENT_MESSAGE.formatted(QUEUED, LAUNCHED); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + responseObserver.onNext(ConnectResponse.newBuilder() + .setConnectedRegion(awsRegion).build()); + responseObserver.onCompleted(); + log.info(MESG_END_GRPC_ENDPOINT, taskId, "connect"); + } + + @Override + public void sendHeartbeat( + HeartbeatRequest request, + StreamObserver responseObserver) { + var taskId = UUID.fromString(request.getTaskId()); + var uniqueStrForLogs = "sendHeartbeat-" + Instant.now().getEpochSecond(); + log.info(MESG_START_GRPC_ENDPOINT, taskId, uniqueStrForLogs); + + var taskEntity = taskService.fetchTask(taskId); + var ncaId = taskEntity.getNcaId(); + + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + if (TERMINAL_TASK_STATUSES.contains(taskEntity.getStatus())) { + // Ignore heartbeat once the Task has already reached terminal status. + var status = taskEntity.getStatus(); + log.warn(MESG_RECEIVING_HEARTBEAT_AFTER_TERMINAL_STATUS, taskId, status); + } else { + if (request.hasErrorMessage()) { + // Map ExecutionStatus in proto to TaskStatus. + var status = request.getStatus() == ExecutionStatus.EXCEEDED_MAX_RUNTIME_DURATION ? + EXCEEDED_MAX_RUNTIME_DURATION : ERRORED; + var errorMessage = request.getErrorMessage(); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(taskEntity.getStatus(), status, errorMessage); + log.info(MESG_TASK_INFO, taskId, mesg); + taskService.updateTask(taskId, status, + icmsService.getHealthDto(taskId, + request.getInstanceType(), + errorMessage)); + log.info(MESG_TASK_INFO, taskId, "Updated with terminal status " + status); + eventService.insertEvent(ncaId, taskId, mesg); + } else if (taskEntity.getStatus() == LAUNCHED) { + if (request.getStatus() == TASK_CONTAINER_INITIALIZING) { + var mesg = MESG_TASK_CONTAINER_INITIALIZING.formatted(taskId); + log.info(mesg); + taskService.updateTask(taskId); // Update lastUpdatedAt timestamp + } else if (request.getStatus() == WORKER_TERMINATED) { + // If worker's control plane fails to pull the container image or helm chart, + // either due to bad registry credentials or incorrect URL in the Task + // definition, then NVCT/NEWT will first send the error information to ICMS and + // then send SIGTERM signal to the Utils Container. On receiving the SIGTERM + // signal, Utils Container will send a heartbeat with WORKER_TERMINATED + // ExecutionStatus to the NVCT API. NVCT API will just ignore such a heartbeat + // and NOT update the Task's lastUpdatedAt timestamp so that the async + // MonitorLaunchedTaskRoutine can get the health information from ICMS during + // the next iteration. This way, the user can see the actual error message that + // the control plane received when pulling the container image or helm chart. + var mesg = MESG_WORKER_TERMINATED.formatted(taskId); + log.info(mesg); + } else { + var mesg = STATUS_CHANGE_EVENT_MESSAGE.formatted(LAUNCHED, RUNNING); + log.info(MESG_TASK_INFO, taskId, mesg); + taskService.updateTask(taskId, RUNNING, Instant.now()); + eventService.insertEvent(ncaId, taskId, mesg); + var accountName = accountService.getAccountName(ncaId); + taskRunningMetricsService.recordRunningTask(ncaId, accountName); + } + } else if (taskEntity.getStatus() == RUNNING) { + log.info(MESG_TASK_INFO, taskId, "Received heartbeat"); + taskService.updateTask(taskId, Instant.now()); + } + } + responseObserver.onNext(HeartbeatResponse.newBuilder() + .setTaskId(taskId.toString()) + .setExecutionStatus(taskEntity.getStatus().toString()) + .build()); + responseObserver.onCompleted(); + log.info(MESG_END_GRPC_ENDPOINT, taskId, uniqueStrForLogs); + } + + @Override + public void getArtifacts( + ArtifactsRequest request, + StreamObserver responseObserver) { + var taskId = UUID.fromString(request.getTaskId()); + log.info(MESG_START_GRPC_ENDPOINT, taskId, "getArtifacts"); + + var ncaId = taskService.fetchTask(taskId).getNcaId(); + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + var artifacts = artifactService.getPresignedUrls(ncaId, taskId); + var artifactResponse = new ArrayList(); + for (var artifact : artifacts) { + var files = new ArrayList(); + for (var file : artifact.files()) { + files.add(ArtifactResponse.ArtifactFile.newBuilder() + .setPath(Objects.requireNonNullElse(file.path(), "")) + .setUrl(file.url()) + .build()); + } + var kind = switch (artifact.artifactType()) { + case ArtifactTypeEnum.MODEL -> ArtifactResponse.ArtifactKindEnum.MODEL; + case ArtifactTypeEnum.RESOURCE -> ArtifactResponse.ArtifactKindEnum.RESOURCE; + default -> { + var errorMsg = MESG_NOT_SUPPORTED_ARTIFACT_TYPE.formatted(taskId, + artifact.artifactType()); + log.error(errorMsg); + throw new IllegalStateException(errorMsg); + } + }; + artifactResponse.add(ArtifactResponse.newBuilder() + .setKind(kind) + .setName(artifact.name()) + .setVersion(artifact.version()) + .addAllFiles(files) + .build()); + } + var response = ArtifactsResponse.newBuilder() + .addAllArtifacts(artifactResponse) + .build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + log.info(MESG_END_GRPC_ENDPOINT, taskId, "getArtifacts"); + } + + @Override + public StreamObserver sendResultMetadata( + StreamObserver responseObserver) { + return new ResultMetadataStreamObserver(responseObserver, + taskService, + tokenService, + eventService, + resultService, + icmsService, + taskSuccessMetricsService, + taskErrorMetricsService, + jsonMapper, + tracer); + } + + @Override + public void refreshToken( + RefreshTokenRequest request, + StreamObserver responseObserver) { + var taskId = UUID.fromString(request.getTaskId()); + log.info(MESG_START_GRPC_ENDPOINT, taskId, "refreshToken"); + + var ncaId = taskService.fetchTask(taskId).getNcaId(); + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + var newToken = tokenService.issueWorkerAccessAssertion(ncaId, taskId); + var response = RefreshTokenResponse.newBuilder().setToken(newToken).build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + log.info(MESG_END_GRPC_ENDPOINT, taskId, "refreshToken"); + } + + @Override + public void requestSecretCredentials( + SecretCredentialsRequest request, + StreamObserver responseObserver) { + var taskId = UUID.fromString(request.getTaskId()); + log.info(MESG_START_GRPC_ENDPOINT, taskId, "requestSecretCredentials"); + + var task = taskService.fetchTask(taskId); + var ncaId = task.getNcaId(); + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + var assertionToken = tokenService.issueSecretsAssertion(task); + var response = SecretCredentialsResponse.newBuilder() + .setSecretCredentialsToken(assertionToken) + .setExpiration(toTimestamp(Instant.now().plus(VALIDITY))).build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + log.info(MESG_END_GRPC_ENDPOINT, taskId, "requestSecretCredentials"); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/ResultMetadataStreamObserver.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/ResultMetadataStreamObserver.java new file mode 100644 index 000000000..4344f1c0b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/ResultMetadataStreamObserver.java @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.google.protobuf.ByteString; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.proto.ResultMetadataRequest; +import com.nvidia.nvct.proto.ResultMetadataResponse; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.metrics.TaskErrorMetricsService; +import com.nvidia.nvct.service.metrics.TaskSuccessMetricsService; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.icms.IcmsService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.service.token.TokenService; +import com.nvidia.nvct.util.NvctUtils; +import io.grpc.stub.StreamObserver; +import io.micrometer.tracing.Tracer; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +@Slf4j +public class ResultMetadataStreamObserver implements StreamObserver { + private static final String MESG_TASK_ERROR = + "Task id '%s': Worker error '%s', status '%s', details '%s'"; + private static final String MESG_INVALID_STATUS = + "Task id '%s': Invalid ExecutionStatus %s"; + private static final String MESG_INVALID_METADATA = + "Task id '%s': Invalid ResultMetadata %s"; + + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + private static final String MESG_START_GRPC_ENDPOINT = + "Task id '{}': Starting call to gRPC endpoint 'SendResultMetadata' with ExecutionStatus '{}'"; + private static final String MESG_END_GRPC_ENDPOINT = + "Task id '{}': Completed gRPC call 'SendResultMetadata'"; + private static final String MESG_RECEIVING_RESULT_AFTER_TERMINAL_STATUS = + "Task id '{}': Receiving result metadata after Task has reached terminal status '{}'"; + private static final String MESG_LOG_PERCENT_COMPLETE = + "Task id '{}': '{}' percent complete"; + private static final String MESG_ERROR_IN_SEND_RESULT_METADATA = + "Encountered error in sendResultMetadata"; + + private final StreamObserver responseObserver; + private final TaskService taskService; + private final TokenService tokenService; + private final EventService eventService; + private final ResultService resultService; + private final IcmsService icmsService; + private final JsonMapper jsonMapper; + private final TaskSuccessMetricsService taskSuccessMetricsService; + private final TaskErrorMetricsService taskErrorMetricsService; + private final Tracer tracer; + + private UUID taskId; + private TaskStatus executionStatus; + + public ResultMetadataStreamObserver( + StreamObserver responseObserver, + TaskService taskService, + TokenService tokenService, + EventService eventService, + ResultService resultService, + IcmsService icmsService, + TaskSuccessMetricsService taskSuccessMetricsService, + TaskErrorMetricsService taskErrorMetricsService, + JsonMapper jsonMapper, + Tracer tracer) { + this.responseObserver = responseObserver; + this.taskService = taskService; + this.tokenService = tokenService; + this.eventService = eventService; + this.resultService = resultService; + this.icmsService = icmsService; + this.taskSuccessMetricsService = taskSuccessMetricsService; + this.taskErrorMetricsService = taskErrorMetricsService; + this.jsonMapper = jsonMapper; + this.tracer = tracer; + } + + @Override + public void onNext(ResultMetadataRequest request) { + this.taskId = UUID.fromString(request.getTaskId()); + log.info(MESG_START_GRPC_ENDPOINT, taskId, request.getStatus().name()); + var taskEntity = taskService.fetchTask(taskId); + this.executionStatus = taskEntity.getStatus(); + // Ignore result metadata if the Task has reached terminal status. + if (TERMINAL_TASK_STATUSES.contains(taskEntity.getStatus())) { + log.warn(MESG_RECEIVING_RESULT_AFTER_TERMINAL_STATUS, taskId, taskEntity.getStatus()); + log.info(MESG_END_GRPC_ENDPOINT, taskId); + return; + } + + var ncaId = taskEntity.getNcaId(); + tokenService.validateWorkerAccessAssertion(ncaId, taskId); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, request.getTaskId())); + + Optional percentComplete = request.hasPercentComplete() ? + Optional.of(request.getPercentComplete()) : Optional.empty(); + var errorMessage = StringUtils.EMPTY; + + var status = switch (request.getStatus()) { + case IN_PROGRESS -> { + log.debug(MESG_LOG_PERCENT_COMPLETE, taskId, percentComplete); + validateResultMetadata(taskId, request.getMetadata().getBody()); + var metaData = includePercentCompleteInMetadata(request); + taskSuccessMetricsService.recordTaskSuccess(ncaId); // Record for every result. + resultService.insertResult(taskEntity, request.getResultName(), metaData); + yield RUNNING; + } + case ERRORED -> { + var errorDetail = errorDetail(taskId, request); + taskService.updateTask(taskId, ERRORED, errorDetail); + taskErrorMetricsService.recordTaskError(ncaId); + errorMessage = request.getErrorDetails().getDetail(); + yield ERRORED; + } + case EXCEEDED_MAX_RUNTIME_DURATION -> { + var errorDetail = errorDetail(taskId, request); + taskService.updateTask(taskId, EXCEEDED_MAX_RUNTIME_DURATION, errorDetail); + taskErrorMetricsService.recordTaskError(ncaId); + errorMessage = request.getErrorDetails().getDetail(); + yield EXCEEDED_MAX_RUNTIME_DURATION; + } + case COMPLETED -> { + validateResultMetadata(taskId, request.getMetadata().getBody()); + var metaData = includePercentCompleteInMetadata(request); + resultService.insertResult(taskEntity, request.getResultName(), metaData); + taskSuccessMetricsService.recordTaskSuccess(ncaId); // Record when completed. + icmsService.terminateInstanceByTaskId(ncaId, taskId); + yield COMPLETED; + } + default -> { + var mesg = MESG_INVALID_STATUS.formatted(taskId, request.getStatus()); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + }; + + taskService.updateTask(taskId, status, Instant.now(), percentComplete); + if (taskEntity.getStatus() != status) { + var mesg = StringUtils.isBlank(errorMessage) ? + STATUS_CHANGE_EVENT_MESSAGE.formatted(taskEntity.getStatus(), status) : + STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(taskEntity.getStatus(), status, errorMessage); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + log.info(MESG_END_GRPC_ENDPOINT, taskId); + } + + @Override + public void onError(Throwable throwable) { + log.error(MESG_ERROR_IN_SEND_RESULT_METADATA, throwable); + responseObserver.onError(throwable); + } + + @Override + public void onCompleted() { + responseObserver.onNext(ResultMetadataResponse.newBuilder() + .setExecutionStatus(executionStatus.toString()) + .setTaskId(taskId.toString()) + .build()); + responseObserver.onCompleted(); + } + + private void validateResultMetadata(@NonNull UUID taskId, ByteString body) { + var metadata = body.toStringUtf8(); + + if (StringUtils.isBlank(metadata)) { + return; + } + + try { + jsonMapper.readValue(metadata, ObjectNode.class); + } catch (JacksonException e) { + var mesg = MESG_INVALID_METADATA.formatted(taskId, metadata); + log.error(mesg); + throw new IllegalArgumentException(mesg, e); + } + } + + @SneakyThrows + private String includePercentCompleteInMetadata(@NonNull ResultMetadataRequest request) { + var rawJson = request.getMetadata().getBody().toStringUtf8(); + if (request.hasPercentComplete()) { + // Utils Container can marshall a "null" value as result metadata. It gets + // deserialized as NullNode and cause ClassCastException when it is cast as + // ObjectNode. + var objectNode = StringUtils.isBlank(rawJson) || rawJson.equals("null") ? + jsonMapper.createObjectNode() : (ObjectNode) jsonMapper.readTree(rawJson); + objectNode.put("percentComplete", request.getPercentComplete()); + return jsonMapper.writeValueAsString(objectNode); + } + return rawJson; + } + + private HealthDto errorDetail( + UUID taskId, + ResultMetadataRequest request) { + var errorDetails = request.getErrorDetails(); + var mesg = MESG_TASK_ERROR.formatted(taskId, + errorDetails.getType(), + errorDetails.getStatus(), + errorDetails.getDetail()); + return icmsService.getHealthDto(taskId, request.getInstanceType(), mesg); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/AuthHeaderPassthroughServerHttpRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/AuthHeaderPassthroughServerHttpRequest.java new file mode 100644 index 000000000..46170543e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/AuthHeaderPassthroughServerHttpRequest.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.lang.reflect.Proxy; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; + +public class AuthHeaderPassthroughServerHttpRequest extends HttpServletRequestWrapper { + + private static final HttpServletRequest UNSUPPORTED_REQUEST = (HttpServletRequest) Proxy.newProxyInstance( + AuthHeaderPassthroughServerHttpRequest.class.getClassLoader(), + new Class[]{HttpServletRequest.class}, (proxy, method, args) -> { + throw new UnsupportedOperationException(method + " is not supported"); + }); + + private final BearerTokenAuthenticationToken authentication; + + public AuthHeaderPassthroughServerHttpRequest(BearerTokenAuthenticationToken authentication) { + super(UNSUPPORTED_REQUEST); + this.authentication = authentication; + } + + @Override + public String getHeader(String name) { + if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) { // Header names are case-insensitive + return "Bearer " + this.authentication.getToken(); + } + return null; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/SecurityExpression.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/SecurityExpression.java new file mode 100644 index 000000000..2b7eecae3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/grpc/auth/SecurityExpression.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc.auth; + +import org.springframework.security.access.expression.SecurityExpressionRoot; +import org.springframework.security.core.Authentication; + +public class SecurityExpression extends SecurityExpressionRoot { + + public SecurityExpression(Authentication authentication) { + if (authentication == null) { + throw new IllegalArgumentException("authentication cannot be null"); + } + + super(() -> authentication, null); + + if (!authentication.isAuthenticated()) { + throw new IllegalStateException( + "security expression only usable with authenticated principals"); + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/EventsByTaskRepository.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/EventsByTaskRepository.java new file mode 100644 index 000000000..b3630a755 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/EventsByTaskRepository.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.event; + +import com.nvidia.nvct.persistence.event.entity.EventByTaskEntity; +import com.nvidia.nvct.persistence.event.entity.EventByTaskKey; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.springframework.data.cassandra.repository.CassandraRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventsByTaskRepository + extends CassandraRepository { + + Optional getByKeyTaskIdAndKeyEventId(UUID taskId, UUID eventId); + + Stream findByKeyTaskId(UUID taskId); + + Slice findByKeyTaskId(UUID taskId, Pageable pageable); + + void deleteByKeyTaskId(UUID taskId); + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskEntity.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskEntity.java new file mode 100644 index 000000000..8c14b7759 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskEntity.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.event.entity; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(EventByTaskEntity.TABLE_NAME) +public class EventByTaskEntity { + + public static final String TABLE_NAME = "events_by_task"; + + public static final String COLUMN_TASK_ID = "task_id"; + public static final String COLUMN_EVENT_ID = "event_id"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_MESSAGE = "message"; + public static final String COLUMN_CREATED_AT = "created_at"; + + @NonNull + @PrimaryKey + private EventByTaskKey key; + + @NonNull + @Column(COLUMN_NCA_ID) + private String ncaId; + + @NonNull + @Column(COLUMN_MESSAGE) + private String message; + + @Column(COLUMN_CREATED_AT) + @Builder.Default + private Instant createdAt = Instant.now(); + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskKey.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskKey.java new file mode 100644 index 000000000..1d7f9bb55 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/event/entity/EventByTaskKey.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.event.entity; + + +import static com.nvidia.nvct.persistence.event.entity.EventByTaskEntity.COLUMN_EVENT_ID; +import static com.nvidia.nvct.persistence.event.entity.EventByTaskEntity.COLUMN_TASK_ID; + +import java.io.Serializable; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyClass; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +@PrimaryKeyClass +public class EventByTaskKey implements Serializable { + + @NonNull + @PrimaryKeyColumn(name = COLUMN_TASK_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private UUID taskId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_EVENT_ID, ordinal = 1, type = PrimaryKeyType.CLUSTERED) + private UUID eventId; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepository.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepository.java new file mode 100644 index 000000000..e770e77cc --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepository.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.result; + +import com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskKey; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.springframework.data.cassandra.repository.CassandraRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public interface ResultsByTaskRepository + extends CassandraRepository { + + Optional getByKeyTaskIdAndKeyResultId(UUID taskId, UUID resultId); + + Stream findByKeyTaskId(UUID taskId); + + Slice findByKeyTaskId(UUID taskId, Pageable pageable); + + void deleteByKeyTaskId(UUID taskId); + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskEntity.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskEntity.java new file mode 100644 index 000000000..3b9151510 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskEntity.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.result.entity; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(ResultByTaskEntity.TABLE_NAME) +public class ResultByTaskEntity { + + public static final String TABLE_NAME = "results_by_task"; + + public static final String COLUMN_TASK_ID = "task_id"; + public static final String COLUMN_RESULT_ID = "result_id"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_NAME = "name"; + public static final String COLUMN_METADATA = "metadata"; + public static final String COLUMN_CREATED_AT = "created_at"; + + @NonNull + @PrimaryKey + private ResultByTaskKey key; + + @NonNull + @Column(COLUMN_NCA_ID) + private String ncaId; + + @NonNull + @Column(COLUMN_NAME) + private String name; + + @NonNull + @Column(COLUMN_METADATA) + private String metadata; + + @Column(COLUMN_CREATED_AT) + @Builder.Default + private Instant createdAt = Instant.now(); + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskKey.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskKey.java new file mode 100644 index 000000000..b6c821c97 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/result/entity/ResultByTaskKey.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.result.entity; + + +import static com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity.COLUMN_RESULT_ID; +import static com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity.COLUMN_TASK_ID; + +import java.io.Serializable; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyClass; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +@PrimaryKeyClass +public class ResultByTaskKey implements Serializable { + + @NonNull + @PrimaryKeyColumn(name = COLUMN_TASK_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private UUID taskId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_RESULT_ID, ordinal = 1, type = PrimaryKeyType.CLUSTERED) + private UUID resultId; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/TasksRepository.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/TasksRepository.java new file mode 100644 index 000000000..17da3184b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/TasksRepository.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task; + +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.springframework.data.cassandra.repository.CassandraRepository; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TasksRepository extends CassandraRepository { + + Optional getByTaskId(UUID taskId); + + Slice findByNcaId(String ncaId, Pageable pageable); + + Slice findAllBy(Pageable pageable); + + Stream findByNcaId(String ncaId); + + @Query("SELECT COUNT(*) FROM tasks_v2 WHERE nca_id=:ncaId") + long countByNcaId(@Param("ncaId") String ncaId); +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ArtifactUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ArtifactUdt.java new file mode 100644 index 000000000..1cd94879e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ArtifactUdt.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +public interface ArtifactUdt { + String getName(); + + String getVersion(); + + String getUrl(); + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/GpuSpecUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/GpuSpecUdt.java new file mode 100644 index 000000000..cc5f79572 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/GpuSpecUdt.java @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.nvct.persistence.task.entity; + +import jakarta.annotation.Nullable; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; + +@Data +@Builder +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@NoArgsConstructor +@UserDefinedType(GpuSpecUdt.USER_DEFINED_TYPE_NAME) +public class GpuSpecUdt { + + public static final String USER_DEFINED_TYPE_NAME = "gpu_spec_udt"; + + @NonNull + @Column("instance_type") + private String instanceType; + + @NonNull + @Column("gpu") + private String gpu; + + @Nullable + @Column("backend") + private String backend; + + @Nullable + @Column("max_request_concurrency") + private Integer maxRequestConcurrency; + + @Nullable + @Column("configuration") + private String configuration; + + @Nullable + @Column("clusters") + private Set clusters; + + @Nullable + @Column("regions") + private Set regions; + + @Nullable + @Column("attributes") + private Set attributes; + + /** + * Serialized {@link com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto} JSON. + * Null for tasks created before helm validation policy support was added. + */ + @Nullable + @Column("helm_validation_policy") + private String helmValidationPolicy; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/HealthUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/HealthUdt.java new file mode 100644 index 000000000..45e15ddc7 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/HealthUdt.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import jakarta.annotation.Nonnull; +import jakarta.validation.constraints.NotBlank; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; + +@Data +@Builder +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@NoArgsConstructor +@UserDefinedType(HealthUdt.USER_DEFINED_TYPE_NAME) +public class HealthUdt { + public static final String USER_DEFINED_TYPE_NAME = "health_udt"; + + @Nonnull + @Column("sis_request_id") + private UUID legacyIcmsRequestId; + + @NotBlank + @Column("gpu") + private String gpu; + + @NotBlank + @Column("backend") + private String backend; + + @NotBlank + @Column("instance_type") + private String instanceType; + + @NotBlank + @Column("error") + private String error; + + @Override + public String toString() { + return "ICMS request-id " + legacyIcmsRequestId + "; " + + "GPU " + gpu + "; " + + "Instance Type " + instanceType + "; " + + "Backend " + backend + "; " + + "Error " + error; + + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ModelUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ModelUdt.java new file mode 100644 index 000000000..4d0d79383 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ModelUdt.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; + +@Data +@Builder +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@NoArgsConstructor +@UserDefinedType(ModelUdt.USER_DEFINED_TYPE_NAME) +public class ModelUdt implements ArtifactUdt { + public static final String USER_DEFINED_TYPE_NAME = "model_udt"; + + @Column("name") + private String name; + + @Column("version") + private String version; + + @Column("url") + private String url; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResourceUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResourceUdt.java new file mode 100644 index 000000000..46290a8f4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResourceUdt.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; + +@Data +@Builder +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@NoArgsConstructor +@UserDefinedType(ResourceUdt.USER_DEFINED_TYPE_NAME) +public class ResourceUdt implements ArtifactUdt { + public static final String USER_DEFINED_TYPE_NAME = "resource_udt"; + + @Column("name") + private String name; + + @Column("version") + private String version; + + @Column("url") + private String url; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResultHandlingStrategy.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResultHandlingStrategy.java new file mode 100644 index 000000000..99f3ccbd1 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/ResultHandlingStrategy.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import static java.lang.String.format; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum ResultHandlingStrategy { + + UPLOAD("UPLOAD"), + NONE("NONE"); + + private final String name; + + ResultHandlingStrategy(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static ResultHandlingStrategy fromText(@NonNull String val) { + return EnumSet.allOf(ResultHandlingStrategy.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalStateException(format("Unsupported enum %s.", val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskEntity.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskEntity.java new file mode 100644 index 000000000..98cd3ff49 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskEntity.java @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import jakarta.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.CassandraType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(TaskEntity.TABLE_NAME) +public class TaskEntity { + public static final String TABLE_NAME = "tasks_v2"; + + public static final String COLUMN_TASK_ID = "task_id"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_NAME = "name"; + public static final String COLUMN_STATUS = "status"; + public static final String COLUMN_HEALTH = "health"; + public static final String COLUMN_HEALTH_INFO = "health_info"; + public static final String COLUMN_PERCENT_COMPLETE = "percent_complete"; + public static final String COLUMN_LAST_HEARTBEAT_AT = "last_heartbeat_at"; + public static final String COLUMN_LAST_UPDATED_AT = "last_updated_at"; + public static final String COLUMN_CREATED_AT = "created_at"; + public static final String COLUMN_CONTAINER_IMAGE = "container_image"; + public static final String COLUMN_CONTAINER_ARGS = "container_args"; + public static final String COLUMN_CONTAINER_ENVIRONMENT = "container_environment"; + public static final String COLUMN_MODELS = "models"; + public static final String COLUMN_RESOURCES = "resources"; + public static final String COLUMN_DESCRIPTION = "description"; + public static final String COLUMN_TAGS = "tags"; + public static final String COLUMN_MAX_RUNTIME_DURATION = "max_runtime_duration"; + public static final String COLUMN_MAX_QUEUED_DURATION = "max_queued_duration"; + public static final String COLUMN_TERMINAL_GRACE_PERIOD_DURATION = + "terminal_grace_period_duration"; + public static final String COLUMN_RESULT_HANDLING_STRATEGY = "result_handling_strategy"; + public static final String COLUMN_RESULTS_LOCATION = "results_location"; + public static final String COLUMN_HELM_CHART = "helm_chart"; + public static final String COLUMN_TELEMETRIES = "telemetries"; + public static final String COLUMN_GPU_SPEC = "gpu_spec"; + public static final String COLUMN_HAS_SECRETS = "has_secrets"; + + @NonNull + @PrimaryKey(COLUMN_TASK_ID) + private UUID taskId; + + @NonNull + @Column(COLUMN_NCA_ID) + private String ncaId; + + @NonNull + @Column(COLUMN_NAME) + private String name; + + @Nullable + @Column(COLUMN_DESCRIPTION) + private String description; + + @Nullable + @Column(COLUMN_TAGS) + private Set tags; + + @Nullable + @Column(COLUMN_CONTAINER_IMAGE) + private String containerImage; + + @Nullable + @Column(COLUMN_CONTAINER_ARGS) + private String containerArgs; + + @Nullable + @Column(COLUMN_CONTAINER_ENVIRONMENT) + private String containerEnvironment; + + @Nullable + @Column(COLUMN_MODELS) + private Set models; + + @Nullable + @Column(COLUMN_RESOURCES) + private Set resources; + + @NonNull + @Column(COLUMN_GPU_SPEC) + private GpuSpecUdt gpuSpec; + + @Nullable + @Column(COLUMN_MAX_RUNTIME_DURATION) + @CassandraType(type = CassandraType.Name.DURATION) + private Duration maxRuntimeDuration; + + @NonNull + @Column(COLUMN_MAX_QUEUED_DURATION) + @CassandraType(type = CassandraType.Name.DURATION) + private Duration maxQueuedDuration; + + @NonNull + @Column(COLUMN_TERMINAL_GRACE_PERIOD_DURATION) + @CassandraType(type = CassandraType.Name.DURATION) + private Duration terminalGracePeriodDuration; + + @Column(COLUMN_RESULT_HANDLING_STRATEGY) + @Builder.Default + private ResultHandlingStrategy resultHandlingStrategy = ResultHandlingStrategy.UPLOAD; + + @Nullable + @Column(COLUMN_HELM_CHART) + private String helmChart; + + @Nullable + @Column(COLUMN_RESULTS_LOCATION) + private String resultsLocation; + + @NonNull + @Column(COLUMN_STATUS) + private TaskStatus status; + + @Nullable + @Column(COLUMN_TELEMETRIES) + private TelemetriesUdt telemetries; + + @Nullable + @Column(COLUMN_HEALTH) + private String health; + + @Nullable + @Column(COLUMN_HEALTH_INFO) + private HealthUdt legacyHealthInfo; + + @Nullable + @Column(COLUMN_PERCENT_COMPLETE) + private Integer percentComplete; + + @Nullable + @Column(COLUMN_LAST_HEARTBEAT_AT) + private Instant lastHeartbeatAt; + + @Nullable + @Column(COLUMN_LAST_UPDATED_AT) + private Instant lastUpdatedAt; + + @Column(COLUMN_CREATED_AT) + @Builder.Default + private Instant createdAt = Instant.now(); + + @Column(COLUMN_HAS_SECRETS) + @Builder.Default + private Boolean hasSecrets = Boolean.FALSE; + + // Custom getter for natural method name (Lombok generates isHasSecrets by default) + public boolean hasSecrets() { + return Objects.requireNonNullElse(hasSecrets, false); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskStatus.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskStatus.java new file mode 100644 index 000000000..4bd0a27b8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TaskStatus.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import static java.lang.String.format; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum TaskStatus { + + QUEUED("QUEUED"), + LAUNCHED("LAUNCHED"), + RUNNING("RUNNING"), + ERRORED("ERRORED"), + CANCELED("CANCELED"), + EXCEEDED_MAX_RUNTIME_DURATION("EXCEEDED_MAX_RUNTIME_DURATION"), + EXCEEDED_MAX_QUEUED_DURATION("EXCEEDED_MAX_QUEUED_DURATION"), + COMPLETED("COMPLETED"); + + private final String name; + + TaskStatus(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static TaskStatus fromText(@NonNull String val) { + return EnumSet.allOf(TaskStatus.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalStateException(format("Unsupported enum %s.", val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TelemetriesUdt.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TelemetriesUdt.java new file mode 100644 index 000000000..1c8234e5d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/persistence/task/entity/TelemetriesUdt.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task.entity; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; + +@Data +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@NoArgsConstructor +@Builder(toBuilder = true) +@UserDefinedType(TelemetriesUdt.USER_DEFINED_TYPE_NAME) +public class TelemetriesUdt { + public static final String USER_DEFINED_TYPE_NAME = "telemetries_udt"; + + @Column("logs_telemetry_id") + private UUID logsTelemetryId; + + @Column("metrics_telemetry_id") + private UUID metricsTelemetryId; + + @Column("traces_telemetry_id") + private UUID tracesTelemetryId; +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventController.java new file mode 100644 index 000000000..e56314789 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventController.java @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.event.dto.ListEventsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.ratelimit.RateLimiterService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Task Events", + description = """ + Defines Task Event endpoints. All tne endpoints defined in this API require + a bearer token appropriate scope in the HTTP Authorization header. + """ +) +public class EventController { + + private final AccountService accountService; + private final RateLimiterService rateLimiterService; + private final EventFacade eventFacade; + private final Tracer tracer; + + @GetMapping("/v1/nvct/tasks/{taskId}/events") + @Operation( + summary = "Get Events", + description = """ + Gets events associated with the specified task in the authenticated + NVIDIA Cloud Account. Requires a bearer token with 'list_events' scope + in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAnyAuthority('list_events', 'apikey:list_events')") + public ListEventsResponse getTaskEvents( + @Parameter(description = "Number of events to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + + rateLimiterService.verifyLimits(ncaId, taskId); + return eventFacade.getEvents(ncaId, taskId, limit, cursor, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventFacade.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventFacade.java new file mode 100644 index 000000000..2fbf8b31f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/EventFacade.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import static com.nvidia.nvct.service.task.TaskPredicateUtils.taskAccessMatch; + +import com.nvidia.nvct.rest.event.dto.ListEventsResponse; +import com.nvidia.nvct.service.event.EventService; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EventFacade { + + private final EventService eventService; + + public ListEventsResponse getEvents(String ncaId, + UUID taskId, + int limit, + String cursor, + Authentication authentication) { + var sliceContext = eventService.fetchEvents(ncaId, taskId, limit, cursor, + taskEntity -> taskAccessMatch(authentication, Optional.of(taskId))); + return new ListEventsResponse(sliceContext.events(), + sliceContext.limit(), + sliceContext.cursor()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/XAccountEventController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/XAccountEventController.java new file mode 100644 index 000000000..78fea4c18 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/XAccountEventController.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.event.dto.ListEventsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Cross-Account Event Support for NVIDIA Super Admins", + description = """ + Defines Event endpoints for NVIDIA Super Admins to work across accounts. All the + endpoints defined in this API require a bearer token with an appropriate + admin scope in the HTTP Authorization header. + """ +) +public class XAccountEventController { + + private static final String NCA_ID_DESCRIPTION = "NVIDIA Cloud Account(NCA) Id"; + + private final AccountService accountService; + private final EventFacade eventFacade; + private final Tracer tracer; + + @GetMapping("/v1/nvct/accounts/{ncaId}/tasks/{taskId}/events") + @Operation( + summary = "Get Events", + description = """ + Gets events associated with the specified task in the authenticated + NVIDIA Cloud Account. Requires a bearer token with 'admin:list_events' + scope in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:list_events')") + public ListEventsResponse getTaskEvents( + @Parameter(description = "Number of events to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return eventFacade.getEvents(ncaId, taskId, limit, cursor, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/EventDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/EventDto.java new file mode 100644 index 000000000..1ace4241d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/EventDto.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; + +@Builder(toBuilder = true) +@Schema(description = "Data Transfer Object(DTO) representing a Task Event") +public record EventDto( + @Schema(description = "Event id") + @NotNull UUID eventId, + + @Schema(description = "Task id") + @NotNull UUID taskId, + + @Schema(description = "NVIDIA Cloud Account Id") + @NotNull String ncaId, + + @Schema(description = "Event message") + @NotNull String message, + + @Schema(description = "Event creation timestamp") + @NotNull Instant createdAt) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/ListEventsResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/ListEventsResponse.java new file mode 100644 index 000000000..e12828c53 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/event/dto/ListEventsResponse.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Response body containing list of task events") +public record ListEventsResponse( + @Schema(description = "List of events") + @NotNull List events, + + @Schema(description = "Pagination limit - Not included in the response for the last slice") + Integer limit, + + @Schema(description = "Pagination cursor - Not included in the response for the last slice") + String cursor) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/XAccountMiscEndpointsController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/XAccountMiscEndpointsController.java new file mode 100644 index 000000000..ac37a8309 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/XAccountMiscEndpointsController.java @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.misc; + +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.misc.dto.GpuPlacementDto; +import com.nvidia.nvct.rest.misc.dto.GpuUsageDto; +import com.nvidia.nvct.rest.misc.dto.ListGpuUsageResponse; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Cross-Account Miscellaneous API for NVIDIA Super Admins", + description = """ + Defines miscellaneous endpoints for smooth service operation by NVIDIA Super + Admins such as SREs.""") +public class XAccountMiscEndpointsController { + + private static final String NCA_ID_DESCRIPTION = "NVIDIA Cloud Account Id"; + private static final EnumSet QUEUED_OR_LAUNCHED_OR_RUNNING = + EnumSet.of(TaskStatus.RUNNING, TaskStatus.QUEUED, TaskStatus.LAUNCHED); + private final AccountService accountService; + private final TaskService taskService; + private final IcmsClient icmsClient; + private final Tracer tracer; + + @GetMapping(value = "/v1/nvct/accounts/{ncaId}/usage/gpus") + @Operation( + summary = "GPU Max Usage", + description = """ + Provides the current max usage of GPUs in the specified NVIDIA Cloud Account + based on currently deployed tasks. Requires a bearer token with + 'admin:launch_task' scope in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:launch_task')") + public ListGpuUsageResponse getGpuUsage( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + var gpuToSpec = new HashMap(); + var gpuToCount = new HashMap(); + // usage per cluster + var gpuToClusterCount = new HashMap(); + // clusters where the gpu could be deployed + var gpuToClustersFiltered = new HashMap>(); + + var tasks = taskService.fetchTasksByAccount(ncaId) + .filter(task -> QUEUED_OR_LAUNCHED_OR_RUNNING.contains(task.getStatus())) + .collect(Collectors.toSet()); + var gpuToClusters = toGpuClusterMapping( + icmsClient.getClusters( + ncaId, getInstanceTypeUsage(tasks))); + + tasks.stream() + .map(TaskEntity::getGpuSpec) + .forEach(spec -> { + var instanceKey = spec.getGpu() + "/" + spec.getInstanceType(); + gpuToSpec.put(instanceKey, spec); + // we know there is only one instance per spec + gpuToCount.put(instanceKey, gpuToCount.getOrDefault(instanceKey, 0) + 1); + + var clusterResponses = + gpuToClusters.computeIfAbsent(instanceKey, k -> new HashSet<>()); + + Stream clusterResponseStream = + clusterResponses.stream(); + if (StringUtils.isNotBlank(spec.getBackend())) { + clusterResponseStream = clusterResponseStream.filter( + cluster -> spec.getBackend() + .equalsIgnoreCase( + convertGfnClusterGroupName( + cluster.getClusterGroupName()))); + } + if (!CollectionUtils.isEmpty(spec.getClusters())) { + clusterResponseStream = clusterResponseStream.filter( + cluster -> spec.getClusters() + .contains(cluster.getClusterName())); + } + if (!CollectionUtils.isEmpty(spec.getRegions())) { + clusterResponseStream = clusterResponseStream.filter( + cluster -> spec.getRegions().contains(cluster.getRegion())); + } + + clusterResponseStream.forEach(cluster -> { + var clusterKey = instanceKey + "/" + cluster.getClusterId(); + gpuToClusterCount.put(clusterKey, gpuToClusterCount.getOrDefault(clusterKey, 0) + 1); + gpuToClustersFiltered + .computeIfAbsent(instanceKey, k -> new HashSet<>()) + .add(cluster); + }); + }); + + // Build resulting dto + var gpus = new ArrayList(); + gpuToCount.keySet() + .forEach(instanceKey -> { + var gpu = gpuToSpec.get(instanceKey).getGpu(); + var instanceType = gpuToSpec.get(instanceKey).getInstanceType(); + var specCount = gpuToCount.get(instanceKey); + var clusters = gpuToClustersFiltered.computeIfAbsent( + instanceKey, k -> Collections.emptySet()); + var placements = new ArrayList(); + for (IcmsStubService.ClusterResponse cluster : clusters) { + var clusterKey = instanceKey + "/" + cluster.getClusterId(); + if (gpuToClusterCount.containsKey(clusterKey)) { + placements.add(toGpuPlacementDto( + cluster, gpuToClusterCount.get(clusterKey), + gpuToClusterCount.get(clusterKey))); + } + } + gpus.add(GpuUsageDto.builder() + .gpu(gpu) + .instanceType(instanceType) + .currentMaxUsage(specCount) + .currentMinUsage(specCount) + .placements(placements) + .build()); + }); + + return new ListGpuUsageResponse(gpus); + } + + /** + * Converts a ICMS Clusters response to a mapping: + * key: [gpu/instanceType] + * value: A Unique set of all clusters where this gpu/instance type is available for ncaId + * + * Filter out all clusters with status != READY + * @param clustersResponse icms /clusters response + * @return mapping + */ + private static Map> toGpuClusterMapping( + List clustersResponse) { + Map> result = new HashMap<>(); + clustersResponse + .stream() + .filter(clusterResponse -> + clusterResponse.getStatus().equalsIgnoreCase("READY")) + .forEach(clusterResponse -> + clusterResponse.getGpus().forEach(gpu -> { + var gpuName = gpu.getName(); + gpu.getInstanceTypes().forEach(instanceType -> { + var instanceTypeName = instanceType.getName(); + var key = gpuName + "/" + instanceTypeName; + Set setOfLocations = + result.computeIfAbsent(key, k -> new HashSet<>()); + setOfLocations.add(clusterResponse); + }); + + })); + return result; + } + + private static GpuPlacementDto toGpuPlacementDto( + IcmsStubService.ClusterResponse cluster, Integer currentMaxUsage, + Integer currentMinUsage) { + return GpuPlacementDto.builder() + .clusterId(cluster.getClusterId()) + .cluster(cluster.getClusterName()) + .clusterGroupId(cluster.getClusterGroupId()) + .clusterGroup(cluster.getClusterGroupName()) + .cloudProvider(cluster.getCloudProvider()) + .region(cluster.getRegion()) + .currentMaxUsage(currentMaxUsage) + .currentMinUsage(currentMinUsage) + .build(); + } + + // Due to different flows at ICMS for NVCA and GFN, in /cluster response GFN cluster group + // name is always GFN_REGION_TARGETING. We need to convert it to expected backend name GFN. + private static String convertGfnClusterGroupName(String clusterGroupName) { + if ("GFN_REGION_TARGETING".equalsIgnoreCase(clusterGroupName)) { + return "GFN"; + } + return clusterGroupName; + } + + // Determines if all the Tasks in the list are either strictly container-based or whether + // some are helm-based and some are container-based. + private static InstanceUsageTypeEnum getInstanceTypeUsage( + Set entities) { + if (entities.stream() + .noneMatch(entity -> StringUtils.isBlank(entity.getContainerImage()))) { + return InstanceUsageTypeEnum.CONTAINER; + } + return InstanceUsageTypeEnum.DEFAULT; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuPlacementDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuPlacementDto.java new file mode 100644 index 000000000..21f89c63b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuPlacementDto.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.misc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; + +@Builder +@Schema(description = "Data Transfer Object(DTO) representing GPU location, i.e. " + + "cluster, cluster group, region") +public record GpuPlacementDto( + @Schema(description = "Unique cluster id. Note: cluster name may not be unique.") + @NotBlank + String clusterId, + + @Schema(description = "Cluster name") + @NotBlank + String cluster, + + @Schema(description = "Unique cluster group id.") + @NotBlank + String clusterGroupId, + + @Schema(description = "Cluster group name") + @NotBlank + String clusterGroup, + + @Schema(description = "Cluster provider") + @NotBlank + String cloudProvider, + + @Schema(description = "Cluster region where gpu is located") + @NotBlank + String region, + + @Schema(description = "Max usage of gpu in this cluster") + @PositiveOrZero + int currentMaxUsage, + + @Schema(description = "Min usage of gpu in this cluster") + @PositiveOrZero + int currentMinUsage) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuUsageDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuUsageDto.java new file mode 100644 index 000000000..905f4fc2d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/GpuUsageDto.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.misc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Data Transfer Object(DTO) representing GPU usage") +public record GpuUsageDto( + @Schema(description = "GPU name") + @NotBlank String gpu, + @Schema(description = "GPU instance type") + @NotBlank String instanceType, + @Schema(description = "Max usage based on currently deployed Tasks") + @PositiveOrZero int currentMaxUsage, + @Schema(description = "Min usage based on currently deployed Tasks") + @PositiveOrZero int currentMinUsage, + @Schema(description = "GPU placement list") + @NotNull List placements) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/ListGpuUsageResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/ListGpuUsageResponse.java new file mode 100644 index 000000000..a9c8a1a16 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/misc/dto/ListGpuUsageResponse.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.misc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Response body containing current usage of GPUs in an account") +public record ListGpuUsageResponse( + @Schema(description = "List of DTOs with GPU usage information") + @NotNull List gpus) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultController.java new file mode 100644 index 000000000..ba817b37a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultController.java @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.result.dto.ListResultsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.ratelimit.RateLimiterService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Task Results", + description = """ + Defines Task Result endpoints. All the endpoints defined in this API require + a bearer token with appropriate scope in the HTTP Authorization + header. + """ +) +public class ResultController { + + private final AccountService accountService; + private final RateLimiterService rateLimiterService; + private final ResultFacade resultFacade; + private final Tracer tracer; + + @GetMapping("/v1/nvct/tasks/{taskId}/results") + @Operation( + summary = "Get Results", + description = """ + Gets results associated with the specified task in the authenticated + NVIDIA Cloud Account. Requires a bearer token with 'list_results' scope + in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAnyAuthority('list_results', 'apikey:list_results')") + public ListResultsResponse getTaskResults( + @Parameter(description = "Number of results to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId, taskId); + return resultFacade.getResults(ncaId, taskId, limit, cursor, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultFacade.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultFacade.java new file mode 100644 index 000000000..8d95e4a1e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/ResultFacade.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import static com.nvidia.nvct.service.task.TaskPredicateUtils.taskAccessMatch; + +import com.nvidia.nvct.rest.result.dto.ListResultsResponse; +import com.nvidia.nvct.service.result.ResultService; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ResultFacade { + + private final ResultService resultService; + + public ListResultsResponse getResults(String ncaId, + UUID taskId, + int limit, + String cursor, + Authentication authentication) { + var sliceContext = resultService.fetchResults(ncaId, taskId, limit, cursor, + taskEntity -> taskAccessMatch(authentication, Optional.of(taskEntity.getTaskId()))); + return new ListResultsResponse(sliceContext.results(), + sliceContext.limit(), + sliceContext.cursor()); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/XAccountResultController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/XAccountResultController.java new file mode 100644 index 000000000..4645e3d12 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/XAccountResultController.java @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.result.dto.ListResultsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Cross-Account Result Support for NVIDIA Super Admins", + description = """ + Defines Result endpoints for NVIDIA Super Admins to work across accounts. All the + endpoints defined in this API require a bearer token with an appropriate + admin scope in the HTTP Authorization header. + """ +) +public class XAccountResultController { + + private static final String NCA_ID_DESCRIPTION = "NVIDIA Cloud Account(NCA) Id"; + + private final AccountService accountService; + private final ResultFacade resultFacade; + private final Tracer tracer; + + @GetMapping("/v1/nvct/accounts/{ncaId}/tasks/{taskId}/results") + @Operation( + summary = "Get Results", + description = """ + Gets results associated with the specified task in the authenticated + NVIDIA Cloud Account. Requires a bearer token with 'admin:list_results' + scope in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:list_results')") + public ListResultsResponse getTaskResults( + @Parameter(description = "Number of results to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @PathVariable @Parameter(description = "Task id", required = true) UUID taskId, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return resultFacade.getResults(ncaId, taskId, limit, cursor, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ListResultsResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ListResultsResponse.java new file mode 100644 index 000000000..e9e50915d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ListResultsResponse.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Response body containing list of task results") +public record ListResultsResponse( + @Schema(description = "List of results") + @NotNull List results, + + @Schema(description = "Pagination limit - Not included in the response for the last slice") + Integer limit, + + @Schema(description = "Pagination cursor - Not included in the response for the last slice") + String cursor) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ResultDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ResultDto.java new file mode 100644 index 000000000..6c175d2b6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/result/dto/ResultDto.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import tools.jackson.databind.node.ObjectNode; + +@Builder(toBuilder = true) +@Schema(types = {"object"}, description = "Data Transfer Object(DTO) representing a Result") +public record ResultDto( + @Schema(description = "Result id") + @NotNull UUID resultId, + + @Schema(description = "Task id") + @NotNull UUID taskId, + + @Schema(description = "NVIDIA Cloud Account Id") + @NotNull String ncaId, + + @Schema(description = "Result name") + @NotNull String name, + + @Schema(description = "Result metadata", + types = {"object"}, + implementation = Object.class, + additionalProperties = Schema.AdditionalPropertiesValue.TRUE) + @NotNull ObjectNode metadata, + + @Schema(description = "Result creation timestamp") + @NotNull Instant createdAt) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementController.java new file mode 100644 index 000000000..c9650e12f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementController.java @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret; + +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.secret.dto.UpdateSecretsRequest; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.ratelimit.RateLimiterService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import java.util.UUID; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/v1/nvct/secrets", produces = APPLICATION_JSON_VALUE) +@Tag(name = "User Secret Management", + description = """ + Defines User Secret Management endpoints for Account Admins. All the endpoints + defined in this API require a bearer token with appropriate scope in the + HTTP Authorization header. + """ +) +public class SecretManagementController { + + private final SecretManagementFacade secretManagementFacade; + private final AccountService accountService; + private final RateLimiterService rateLimiterService; + private final Tracer tracer; + + @PutMapping(value = "tasks/{taskId}", + consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "Update user secrets for a task", + description = """ + Updates secrets for the specified task id. This endpoint requires a + bearer token 'update_secrets' scope in the HTTP Authorization header. + """, + responses = @ApiResponse(responseCode = "204") + ) + @PreAuthorize("hasAnyAuthority('update_secrets', 'apikey:update_secrets')") + public ResponseEntity updateSecrets( + @Parameter(description = "Task id", required = true) + @NotNull @PathVariable UUID taskId, + @Valid @NonNull @RequestBody UpdateSecretsRequest request, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId, taskId); + return secretManagementFacade.updateSecrets(ncaId, taskId, request.secrets(), authentication); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementFacade.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementFacade.java new file mode 100644 index 000000000..1c21909d6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/SecretManagementFacade.java @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret; + +import static com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy.UPLOAD; +import static com.nvidia.nvct.service.secret.SecretManagementService.getNgcApiKey; +import static com.nvidia.nvct.service.secret.SecretManagementService.hasDupeSecrets; +import static com.nvidia.nvct.service.task.TaskPredicateUtils.taskAccessMatch; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.ngc.NgcRegistryClient; +import com.nvidia.nvct.service.task.TaskService; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SecretManagementFacade { + + private static final String MESG_MISSING_SECRETS = + "Task id '%s': Missing secrets in the payload"; + private static final String MESG_DUPLICATE_SECRETS = + "Task id '%s': Duplicate secrets keys in the payload"; + private static final String MESG_INVALID_OPERATION = + "Task id '%s': Cannot update secrets for a Task that does not have any secrets"; + private static final String MESG_TERMINAL_TASK_SECRETS = + "Task id '%s': Cannot update secrets for a Task with terminal status '%s'"; + private static final String MESG_TASK_NOT_IN_ACCOUNT = + "Task id '%s': Not found in account '%s'"; + private static final String MESG_FORBIDDEN_TO_UPDATE_SECRETS = + "Task '%s': Forbidden to update secrets"; + + private final EssService essService; + private final NgcRegistryClient ngcRegistryClient; + private final TaskService taskService; + private final AccountService accountService; + + public ResponseEntity updateSecrets( + String ncaId, + UUID taskId, + Set secrets, + Authentication authentication) { + if (CollectionUtils.isEmpty(secrets)) { + var mesg = MESG_MISSING_SECRETS.formatted(taskId); + log.error(mesg); + throw new BadRequestException(mesg); + } + // Confirm that the account exists. + accountService.lookupAccountUsingNcaIdOrThrow(ncaId); + + var taskEntity = taskService.fetchTask(taskId); + + if (!taskAccessMatch(authentication, Optional.of(taskId))) { + var mesg = MESG_FORBIDDEN_TO_UPDATE_SECRETS.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + + // If a Task does not have any secrets, then we do not allow secrets to be updated. + if (!taskEntity.hasSecrets()) { + var mesg = MESG_INVALID_OPERATION.formatted(taskId); + log.error(mesg); + throw new BadRequestException(mesg); + } + + // Confirm Task belongs to the specified ncaId/account. + if (!taskEntity.getNcaId().equals(ncaId)) { + var mesg = MESG_TASK_NOT_IN_ACCOUNT.formatted(taskId, ncaId); + log.error(mesg); + throw new NotFoundException(mesg); + } + + // Cannot update secrets for a Task with terminal status. + var status = taskEntity.getStatus(); + if (TERMINAL_TASK_STATUSES.contains(status)) { + var mesg = MESG_TERMINAL_TASK_SECRETS.formatted(taskId, status); + log.error(mesg); + throw new BadRequestException(mesg); + } + + validateSecrets(taskEntity, secrets); + + var existingSecrets = essService.getSecrets(taskId) + .orElseGet(Set::of) + .stream() + .collect(Collectors.toMap(SecretDto::name, Function.identity())); + + // Get the existing secrets first and then merge them with the new secrets + // Preferring new secrets values if the secret name is the same. + var newSecrets = secrets.stream() + .collect(Collectors.toMap(SecretDto::name, Function.identity())); + var mergedSecrets = Stream.concat(existingSecrets.entrySet().stream(), + newSecrets.entrySet().stream()) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (oldValue, newValue) -> newValue)); + essService.saveSecrets(taskId, new HashSet<>(mergedSecrets.values())); + return ResponseEntity.noContent().build(); // Status 204. + } + + private void validateSecrets( + TaskEntity taskEntity, + Set secrets) { + var taskId = taskEntity.getTaskId(); + if (hasDupeSecrets(secrets)) { + var mesg = MESG_DUPLICATE_SECRETS.formatted(taskId); + log.error(mesg); + throw new BadRequestException(mesg); + } + + // If a new NGC_API_KEY is being provided when updating secrets, then make sure that + // it can be used to upload results/checkpoints at the specified resultsLocation by + // creating and deleting a dummy/empty model. If successful, then the new NGC_API_KEY + // is valid. Otherwise, we respond with a 400. + var ngcApiKey = getNgcApiKey(secrets); + if (ngcApiKey.isPresent()) { + var resultsLocation = taskEntity.getResultsLocation(); + var resultHandlingStrategy = taskEntity.getResultHandlingStrategy(); + + if (resultHandlingStrategy == UPLOAD && StringUtils.isNotBlank(resultsLocation)) { + // If resultHandlingStrategy is UPLOAD, then resultsLocation will not be blank. + ngcRegistryClient.validate(ngcApiKey.get(), resultsLocation); + } + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementController.java new file mode 100644 index 000000000..55f3d349f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementController.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret; + +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.secret.dto.UpdateSecretsRequest; +import com.nvidia.nvct.service.ratelimit.RateLimiterService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import java.util.UUID; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/v1/nvct/accounts/{ncaId}/secrets/", produces = APPLICATION_JSON_VALUE) +@Tag(name = "Cross-Account User Secret Management for NVIDIA Super Admins", + description = """ + Defines User Secret Management endpoints for NVIDIA Super Admins to work across + accounts. All the endpoints defined in this API require a bearer token + with appropriate admin scopes in the HTTP Authorization header.""" +) + +public class XAccountSecretManagementController { + + private static final String NCA_ID_DESCRIPTION = "NVIDIA Cloud Account Id"; + + private final SecretManagementFacade secretManagementFacade; + private final RateLimiterService rateLimiterService; + private final com.nvidia.nvct.service.account.AccountService accountService; + private final Tracer tracer; + + @PutMapping(value = "tasks/{taskId}", + consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "Update user secrets for a task", + description = """ + Updates secrets for the specified task id. This endpoint requires a bearer + token with 'admin:update_secrets' scope in the HTTP Authorization header. + """, + responses = @ApiResponse(responseCode = "204") + ) + @PreAuthorize("hasAuthority('admin:update_secrets')") + public ResponseEntity updateSecrets( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Task id", required = true) + @NotNull @PathVariable UUID taskId, + @Valid @NonNull @RequestBody UpdateSecretsRequest request, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + rateLimiterService.verifyLimits(ncaId, taskId); + return secretManagementFacade.updateSecrets(ncaId, taskId, request.secrets(), authentication); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/dto/UpdateSecretsRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/dto/UpdateSecretsRequest.java new file mode 100644 index 000000000..647bb531a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/secret/dto/UpdateSecretsRequest.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret.dto; + +import com.nvidia.nvct.rest.task.dto.SecretDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Set; +import lombok.Builder; + +@Builder +@Schema(description = "Request payload to update secrets") +public record UpdateSecretsRequest( + @Schema(description = "Secrets") + @NotNull @Valid Set secrets) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementController.java new file mode 100644 index 000000000..8ff345e7b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementController.java @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.task.dto.BulkTaskDetailsRequest; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListBasicTaskDetailsResponse; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.ratelimit.RateLimiterService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Task Management", + description = """ + Defines Task Management endpoints. All tne endpoints defined in this API require + a bearer token with appropriate scope in the HTTP Authorization header. + """ +) +public class TaskManagementController { + + private final AccountService accountService; + private final RateLimiterService rateLimiterService; + private final TaskManagementFacade taskManagementFacade; + private final Tracer tracer; + + @PostMapping(value = "/v1/nvct/tasks", consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "Create and Launch Task", + description = """ + Creates and launches a new task within the authenticated NVIDIA Cloud Account. + Requires a bearer token with 'launch_task' scope in the HTTP Authorization + header. + """ + ) + @PreAuthorize("hasAnyAuthority('launch_task', 'apikey:launch_task')") + public TaskResponse createAndLaunchTask( + @Valid @NotNull @RequestBody CreateTaskRequest createRequest, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId); + return taskManagementFacade.createAndLaunchTask(ncaId, createRequest, + httpServletRequest, authentication); + } + + @PostMapping(value = "/v1/nvct/tasks/bulk", consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "List Basic Details of Specific Tasks", + description = """ + Lists basic details such as status, etc. of specified Task Ids. All the + Tasks should belong to the authenticated NVIDIA Cloud Account. Requires + a bearer token 'list_tasks' scopes in the HTTP Authorization header.""" + ) + @PreAuthorize("hasAnyAuthority('list_tasks', 'apikey:list_tasks')") + public ListBasicTaskDetailsResponse listBasicDetailsOfSpecificTasks( + @Valid @NotNull @RequestBody BulkTaskDetailsRequest listBasicTasksRequest, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId); + return taskManagementFacade + .listBasicDetailsOfSpecificTasks(ncaId, listBasicTasksRequest, authentication); + } + + + @GetMapping("/v1/nvct/tasks") + @Operation( + summary = "List Tasks", + description = """ + Lists all the tasks associated with the authenticated NVIDIA Cloud Account. + Requires a bearer token with 'list_tasks' scopes in the HTTP Authorization + header.""" + ) + @PreAuthorize("hasAnyAuthority('list_tasks', 'apikey:list_tasks')") + public ListTasksResponse listTasks( + @Parameter(description = "Number of tasks to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Task status") + @RequestParam(required = false) TaskStatusEnum status, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId); + return taskManagementFacade.listTasks(ncaId, authentication, limit, status, cursor); + } + + @GetMapping("/v1/nvct/tasks/{taskId}") + @Operation( + summary = "Get Task Details", + description = """ + Gets details of specified task in the authenticated NVIDIA Cloud Account. + Requires a bearer token with 'task_details' scope in the HTTP Authorization + header. + """ + ) + @PreAuthorize("hasAnyAuthority('task_details', 'apikey:task_details')") + public TaskResponse getTaskDetails( + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + @Parameter(description = "Indicates whether to include secret names in the response") + @RequestParam(required = false, defaultValue = "true") boolean includeSecrets, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId, taskId); + return taskManagementFacade.getTaskDetails(ncaId, taskId, authentication, includeSecrets); + } + + @PostMapping("/v1/nvct/tasks/{taskId}/cancel") + @Operation( + summary = "Cancel Task", + description = """ + Cancels the specified task in the authenticated NVIDIA Cloud Account. Requires + a bearer token with 'cancel_task' scope in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAnyAuthority('cancel_task', 'apikey:cancel_task')") + public TaskResponse cancelTask( + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId, taskId); + return taskManagementFacade.cancelTask(ncaId, taskId, + httpServletRequest, authentication); + } + + @DeleteMapping("/v1/nvct/tasks/{taskId}") + @Operation( + summary = "Delete Task", + description = """ + Deletes the specified task in the authenticated NVIDIA Cloud Account. Requires + a bearer token with 'delete_task' scope in the HTTP Authorization header. + """, + responses = @ApiResponse(responseCode = "204") + ) + @PreAuthorize("hasAnyAuthority('delete_task', 'apikey:delete_task')") + public ResponseEntity deleteTask( + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var ncaId = accountService.getNcaId(authentication); + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + rateLimiterService.verifyLimits(ncaId, taskId); + return taskManagementFacade.deleteTask(ncaId, taskId, + httpServletRequest, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementFacade.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementFacade.java new file mode 100644 index 000000000..523ade16d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/TaskManagementFacade.java @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.service.task.TaskPredicateUtils.taskAccessMatch; + +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.nvct.rest.task.dto.BulkTaskDetailsRequest; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListBasicTaskDetailsResponse; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.task.TaskAuditService; +import com.nvidia.nvct.service.task.TaskManagementService; +import com.nvidia.nvct.util.NvctUtils; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskManagementFacade { + + private final TaskAuditService taskAuditService; + private final TaskManagementService taskManagementService; + + public TaskResponse createAndLaunchTask( + String ncaId, + CreateTaskRequest request, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var payloadBuilder = auditEventPayloadBuilder(httpServletRequest, authentication); + var dto = taskManagementService + .createAndLaunchTask(ncaId, + request, + () -> taskAccessMatch(authentication, Optional.empty()), + payloadBuilder); + return new TaskResponse(dto); + } + + public ListBasicTaskDetailsResponse listBasicDetailsOfSpecificTasks( + String ncaId, + BulkTaskDetailsRequest listBasicTasksRequest, + Authentication authentication) { + var taskIds = listBasicTasksRequest.taskIds(); + var dtos = taskIds.stream() + .map(taskId -> taskManagementService + .getBasicTaskDetails(ncaId, + taskId, + task -> taskAccessMatch(authentication, + Optional.of(task.getTaskId())))) + .toList(); + return new ListBasicTaskDetailsResponse(ncaId, dtos); + } + + public ListTasksResponse listTasks( + String ncaId, + Authentication authentication, + int limit, + TaskStatusEnum status, + String cursor) { + var sliceContext = taskManagementService + .listTasks(ncaId, + status, + limit, + cursor, + taskEntity -> taskAccessMatch(authentication, + Optional.of(taskEntity.getTaskId()))); + return new ListTasksResponse(sliceContext.tasks(), + sliceContext.limit(), + sliceContext.cursor()); + } + + public TaskResponse getTaskDetails(String ncaId, + UUID taskId, + Authentication authentication, + boolean includeSecrets) { + var dto = taskManagementService + .getTaskDetails(ncaId, + taskId, + includeSecrets, + taskEntity -> taskAccessMatch(authentication, + Optional.of(taskEntity.getTaskId()))); + return new TaskResponse(dto); + } + + public TaskResponse cancelTask(String ncaId, + UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var payloadBuilder = auditEventPayloadBuilder(httpServletRequest, authentication); + var dto = taskManagementService + .cancelTask(ncaId, + taskId, + taskEntity -> taskAccessMatch(authentication, + Optional.of(taskEntity.getTaskId())), + payloadBuilder); + return new TaskResponse(dto); + } + + public ResponseEntity deleteTask(String ncaId, + UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + var payloadBuilder = auditEventPayloadBuilder(httpServletRequest, authentication); + taskManagementService + .deleteTask(ncaId, + taskId, + taskEntity -> taskAccessMatch(authentication, + Optional.of(taskEntity.getTaskId())), + payloadBuilder); + return ResponseEntity.noContent().build(); // Status 204. + } + + private AuditEventPayload.Builder auditEventPayloadBuilder( + HttpServletRequest httpServletRequest, + Authentication authentication) { + var customProperties = NvctUtils.getCustomProperties(httpServletRequest); + return taskAuditService.auditEventPayloadBuilder(authentication, customProperties); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/XAccountTaskManagementController.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/XAccountTaskManagementController.java new file mode 100644 index 000000000..88ee16f0d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/XAccountTaskManagementController.java @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.rest.task.dto.BulkTaskDetailsRequest; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListBasicTaskDetailsResponse; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = APPLICATION_JSON_VALUE) +@Tag(name = "Cross-Account Task Management for NVIDIA Super Admins", + description = """ + Defines Task Management endpoints for NVIDIA Super Admins to work across accounts. + All tne endpoints defined in this API require a bearer token with an appropriate + admin scope in the HTTP Authorization header. + """ +) +public class XAccountTaskManagementController { + + private static final String NCA_ID_DESCRIPTION = "NVIDIA Cloud Account(NCA) Id"; + + private final AccountService accountService; + private final TaskManagementFacade taskManagementFacade; + private final Tracer tracer; + + @PostMapping(value = "/v1/nvct/accounts/{ncaId}/tasks", consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "Create and Launch Task", + description = """ + Creates and launches a new task within the authenticated NVIDIA Cloud Account. + Requires a bearer token with 'admin:launch_task' scope in the HTTP + Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:launch_task')") + public TaskResponse createAndLaunchTask( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Valid @NotNull @RequestBody CreateTaskRequest createRequest, + HttpServletRequest httpServletRequest, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade.createAndLaunchTask(ncaId, createRequest, + httpServletRequest, authentication); + } + + @PostMapping(value = "/v1/nvct/accounts/{ncaId}/tasks/bulk", + consumes = APPLICATION_JSON_VALUE) + @Operation( + summary = "List Basic Details of Specific Tasks", + description = """ + Lists basic details such as status, etc. of specified Task Ids. All the + Tasks should belong to the authenticated NVIDIA Cloud Account. Requires a + bearer token with 'admin:list_tasks' scopes in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:list_tasks')") + public ListBasicTaskDetailsResponse listBasicDetailsOfSpecificTasks( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Valid @NotNull @RequestBody BulkTaskDetailsRequest listBasicTasksRequest, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade + .listBasicDetailsOfSpecificTasks(ncaId, listBasicTasksRequest, authentication); + } + + @GetMapping("/v1/nvct/accounts/{ncaId}/tasks") + @Operation( + summary = "List Tasks", + description = """ + Lists all the tasks associated with the authenticated NVIDIA Cloud Account. + Requires a bearer token 'admin:list_tasks' scopes in the HTTP Authorization + header.""" + ) + @PreAuthorize("hasAuthority('admin:list_tasks')") + public ListTasksResponse listTasks( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Number of tasks to return in the response") + @RequestParam(required = false, defaultValue = DEFAULT_PAGINATION_LIMIT) + @Positive int limit, + @Parameter(description = "Task status") + @RequestParam(required = false) TaskStatusEnum status, + @Parameter(description = "Beginning of the pagination slice") + @RequestParam(required = false) String cursor, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade.listTasks(ncaId, authentication, limit, status, cursor); + } + + @GetMapping("/v1/nvct/accounts/{ncaId}/tasks/{taskId}") + @Operation( + summary = "Get Task Details", + description = """ + Gets details of specified task in the authenticated NVIDIA Cloud Account. + Requires a bearer token with 'admin:task_details' scope in the HTTP + Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:task_details')") + public TaskResponse getTaskDetails( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + @Parameter(description = "Indicates whether to include secret names in the response") + @RequestParam(required = false, defaultValue = "true") boolean includeSecrets, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade.getTaskDetails(ncaId, taskId, authentication, includeSecrets); + } + + @PostMapping("/v1/nvct/accounts/{ncaId}/tasks/{taskId}/cancel") + @Operation( + summary = "Cancel Task", + description = """ + Cancels the specified task in the authenticated NVIDIA Cloud Account. Requires + a bearer token with 'admin:cancel_task' scope in the HTTP Authorization header. + """ + ) + @PreAuthorize("hasAuthority('admin:cancel_task')") + public TaskResponse cancelTask( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade.cancelTask(ncaId, taskId, + httpServletRequest, authentication); + } + + @DeleteMapping("/v1/nvct/accounts/{ncaId}/tasks/{taskId}") + @Operation( + summary = "Delete Task", + description = """ + Deletes the specified task in the authenticated NVIDIA Cloud Account. Requires + a bearer token with 'admin:delete_task' scope in the HTTP Authorization header. + """, + responses = @ApiResponse(responseCode = "204") + ) + @PreAuthorize("hasAuthority('admin:delete_task')") + public ResponseEntity deleteTask( + @Parameter(description = NCA_ID_DESCRIPTION, required = true) + @PathVariable String ncaId, + @Parameter(description = "Task id", required = true) + @PathVariable("taskId") UUID taskId, + HttpServletRequest httpServletRequest, + Authentication authentication) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + accountService.assertAccountExistsOrThrow(ncaId); + accountService.assertAccountIdFromPathMatches(ncaId, authentication); + return taskManagementFacade.deleteTask(ncaId, taskId, + httpServletRequest, authentication); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ArtifactDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ArtifactDto.java new file mode 100644 index 000000000..792a15702 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ArtifactDto.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Data Transfer Object(DTO) representing an artifact") +public class ArtifactDto { + + @Schema(description = "Artifact name") + @NotBlank + @NonNull + private String name; + + @Schema(description = "Artifact version") + @NotBlank + @NonNull + private String version; + + @Schema(description = "Artifact URI") + @NotNull + @NonNull + private URI uri; +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BasicTaskDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BasicTaskDto.java new file mode 100644 index 000000000..0848dda9a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BasicTaskDto.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.Builder; + +@Builder(toBuilder = true) +@Schema(description = "Data Transfer Object(DTO) representing a Task with fewer details") +public record BasicTaskDto( + @Schema(description = "Unique Task id") + @NotNull UUID id, + + @Schema(description = "Task name") + @NotNull String name, + + @Schema(description = "Task status") + @NotNull TaskStatusEnum status) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BulkTaskDetailsRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BulkTaskDetailsRequest.java new file mode 100644 index 000000000..9b99616c9 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/BulkTaskDetailsRequest.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.Set; +import java.util.UUID; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Builder(toBuilder = true) +@Schema(description = "Request payload to fetch details such as status about specific Tasks") +public record BulkTaskDetailsRequest( + @Schema(description = "Task Ids") + @NonNull @NotEmpty @Valid Set taskIds) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ContainerEnvironmentEntryDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ContainerEnvironmentEntryDto.java new file mode 100644 index 000000000..96be173d6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ContainerEnvironmentEntryDto.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Data Transfer Object(DTO) representing a container environment entry") +public class ContainerEnvironmentEntryDto { + private static final String KEY_REGEX = "^[a-z0-9A-Z][a-z0-9A-Z\\_]*$"; + + + @Schema(description = "Container environment key") + @Pattern(regexp = KEY_REGEX, + message = "Invalid environment key: Must conform to regex " + KEY_REGEX) + @NotBlank + @NonNull + private String key; + + @Schema(description = "Container environment value") + @NotBlank + @NonNull + private String value; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/CreateTaskRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/CreateTaskRequest.java new file mode 100644 index 000000000..04cc457a3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/CreateTaskRequest.java @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static com.nvidia.nvct.util.NvctConstants.MAX_DESCRIPTION_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAGS_COUNT; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NAME_REGEX; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.nvidia.nvct.service.telemetry.dto.TelemetriesDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.validator.constraints.Length; + +@Slf4j +@Builder(toBuilder = true) +@Schema(types = {"object"}, description = "Request payload to create a Task") +public record CreateTaskRequest( + @Schema(description = "Task name must start with lowercase/uppercase/digit and can " + + "only contain lowercase, uppercase, digit, hyphen, and underscore characters.") + @Pattern(regexp = NAME_REGEX, + message = "Invalid task name: Must conform to regex " + NAME_REGEX) + @Size(min = 1, max = 128, message = "Invalid task name: must be 1 - 128 characters long") + @NonNull @NotBlank String name, + + @Schema(description = "GPU, instance-type, and backend details for launching a Task.") + @NotNull @Valid GpuSpecificationDto gpuSpecification, + + @Schema(description = "Task container image") + @Nullable URI containerImage, + + @Schema(description = "Args to be passed when launching the container.") + @Nullable String containerArgs, + + @Schema(description = "Environment settings for launching the container.") + @Nullable List<@Valid ContainerEnvironmentEntryDto> containerEnvironment, + + @Schema(description = "Optional set of models") + @Nullable Set<@Valid ArtifactDto> models, + + @Schema(description = "Optional set of resources") + @Nullable Set<@Valid ArtifactDto> resources, + + @Schema(description = "Optional set of tags") + @Nullable @Valid + @Size(max = MAX_TAGS_COUNT, message = "Maximum number of tags of " + MAX_TAGS_COUNT + + " is exceeded.") + Set<@Length(max = MAX_TAG_LENGTH, message = "Maximum tag length of " + MAX_TAG_LENGTH + + " is exceeded.") + @Pattern(regexp = "[a-zA-Z0-9\\-_:=]+") String> tags, + + @Schema(description = "Optional Task description") + @Nullable + @Length(max = MAX_DESCRIPTION_LENGTH, message = "Maximum description length of " + + MAX_DESCRIPTION_LENGTH + " is exceeded.") + String description, + + @Schema(description = "Optional max duration for which the Task should run. " + + "Must be specified when launching Task on 'GFN'. Must be less than PT8H when " + + "launching Task on 'GFN'.", + type = "string", + format = "duration", + example = "PT12H30M") + @Nullable Duration maxRuntimeDuration, + + @Schema(description = "Optional max duration for which the Task should be queued.", + defaultValue = "PT72H", + type = "string", + format = "duration", + example = "PT4H30M45S") + @Nullable Duration maxQueuedDuration, + + @Schema(description = "Optional grace period after which the Task should be terminated.", + defaultValue = "PT1H", + type = "string", + format = "duration", + example = "PT1H30M20S") + @Nullable Duration terminationGracePeriodDuration, + + @Schema(description = "Optional Task result handling strategy", + defaultValue = "UPLOAD") + @Nullable ResultHandlingStrategyEnum resultHandlingStrategy, + + @Schema(description = "Optional result path in NGC Model Registry for the generated " + + "results. Must be specified when resultHandlingStrategy is UPLOAD and the " + + "format should be -- //.") + @Nullable String resultsLocation, + + @Schema(description = "Optional Helm Chart") + @Nullable URI helmChart, + + @Schema(description = "Optional telemetry configuration for logs, metrics, and traces.") + @Nullable @ValidTelemetries TelemetriesDto telemetries, + + @Schema(description = "Optional set of secrets. If resultHandlingStrategy is UPLOAD, " + + "then user must specify NGC_API_KEY secret with write-privileges for NVCT to " + + "upload results/checkpoints to the NGC Private Registry at a path specified " + + "using resultPath property.") + @Nullable Set<@Valid SecretDto> secrets) { + + @Documented + @Target(FIELD) + @Retention(RUNTIME) + @Constraint(validatedBy = TelemetriesValidator.class) + @interface ValidTelemetries { + String message() default "Invalid request: Issues with the telemetries"; + + Class[] groups() default {}; + + Class[] payload() default {}; + } + + private static class TelemetriesValidator + implements ConstraintValidator { + private static final String MESG_INVALID_TELEMETRY_REQUEST = + "Invalid request: telemetries object must have at least one UUID specified."; + + @Override + public boolean isValid( + TelemetriesDto telemetries, + ConstraintValidatorContext constraintValidatorContext) { + if (telemetries == null) { + return true; + } + + if (telemetries.logsTelemetryId() == null && + telemetries.metricsTelemetryId() == null && + telemetries.tracesTelemetryId() == null) { + log.error(MESG_INVALID_TELEMETRY_REQUEST); + return false; + } + return true; + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/GpuSpecificationDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/GpuSpecificationDto.java new file mode 100644 index 000000000..e853d3f39 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/GpuSpecificationDto.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.util.Set; +import lombok.Builder; +import tools.jackson.databind.node.ObjectNode; + +@Builder +@Schema(types = {"object"}, description = "Data Transfer Object(DTO) representing GPU specification for a Task.") +public record GpuSpecificationDto( + @Schema(description = "GPU name from the cluster") + @NotBlank String gpu, + + @Schema(description = "Backend/CSP where the GPU powered instance will be launched") + @Nullable String backend, + + @Schema(description = """ + Specific clusters within instance or worker node powered by the selected + instance-type to launch the Task. + """) + @Nullable Set clusters, + + @Schema(description = "Instance type, based on GPU, assigned to a Worker") + @NotBlank String instanceType, + + @Schema(description = "Optional configuration field typically used with Helm Charts " + + "to substitute placeholders in values.yaml", + types = {"object"}, + implementation = Object.class, + additionalProperties = Schema.AdditionalPropertiesValue.TRUE) + @Nullable ObjectNode configuration, + + @Schema(description = "Helm validation policy cluster attributes") + @Nullable @Valid HelmValidationPolicyDto helmValidationPolicy) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HealthDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HealthDto.java new file mode 100644 index 000000000..db8201810 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HealthDto.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +@Schema(types = {"object"}, description = "Data Transfer Object(DTO) representing instance health") +public record HealthDto( + @Schema(description = "GPU Type as per SDD") + @NotBlank String gpu, + + @Schema(description = "Backend/CSP where the GPU powered instance will be launched") + @NotBlank String backend, + + @Schema(description = "Instance type") + @Nullable String instanceType, + + @Schema(description = "Deployment error") + @NotBlank String error) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HelmValidationPolicyDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HelmValidationPolicyDto.java new file mode 100644 index 000000000..9d74e6665 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/HelmValidationPolicyDto.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(types = {"object"}, description = "Data Transfer Object(DTO) representing Helm validation policy") +public record HelmValidationPolicyDto ( + @Schema(description = "Helm validation policy name.") + @NotNull + ValidationPolicyNameEnum name, + + @Schema(description = """ + An API Group in Kubernetes is a collection of related functionality. + When present, must contain at least one entry; each entry must be a valid + KubernetesType (group, version, kind). + """) + @Valid @Nullable @Size(min = 1) + List<@NotNull KubernetesType> extraKubernetesTypes) { + + @Builder + @Schema(types = {"object"}) + public record KubernetesType( + @Schema(description = "Name of API Group") + @NotBlank + String group, + + @Schema(description = "Version of API Group") + @NotBlank + String version, + + @Schema(description = "API Group resource or Kind") + @NotBlank + String kind) { + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceDto.java new file mode 100644 index 000000000..f15e5014d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceDto.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Data Transfer Object(DTO) representing an instance") +public class InstanceDto { + + @Schema(description = "Unique id of the instance") + private String instanceId; + + @Schema(description = "Task executing on the instance") + private UUID taskId; + + @Schema(description = "GPU instance-type powering the instance") + private String instanceType; + + @Schema(description = "Instance state") + private InstanceStateEnum instanceState; + + @Schema(description = "ICMS request-id used to launch this instance") + private UUID icmsRequestId; + + @Schema(description = "NVIDIA Cloud Account Id that owns the Task running on the instance") + private String ncaId; + + @Schema(description = "GPU name powering the instance") + private String gpu; + + @Schema(description = "Backend where the instance is running") + private String backend; + + @Schema(description = "Location such as zone name or region where the instance is running") + private String location; + + @Schema(description = "Instance creation timestamp") + private Instant instanceCreatedAt; + + @Schema(description = "Instance's last updated timestamp") + private Instant instanceUpdatedAt; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceStateEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceStateEnum.java new file mode 100644 index 000000000..08196c167 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceStateEnum.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static java.lang.String.format; + +import java.util.EnumSet; + +public enum InstanceStateEnum { + ACTIVE("ACTIVE"), + STARTING("STARTING"), + RUNNING("RUNNING"), + TERMINATED("TERMINATED"), + PREEMPTED("PREEMPTED"), + DELETED("DELETED"); + + private final String name; + + InstanceStateEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static InstanceStateEnum fromText(String val) { + return EnumSet.allOf(InstanceStateEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalStateException(format("Unsupported enum %s.", val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceUsageTypeEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceUsageTypeEnum.java new file mode 100644 index 000000000..a3601380f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/InstanceUsageTypeEnum.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + + +// Used to validate the instance-types on which the Task will be launched. This enum +// is used as value for instanceTypeUsage=CONTAINER|DEFAULT query parameter with +// ICMS endpoint. For container-based Tasks, instanceTypeUsage=CONTAINER is used. For +// helm-based Tasks, instanceTypeUsage=DEFAULT is used. +public enum InstanceUsageTypeEnum { + CONTAINER("CONTAINER"), + DEFAULT("DEFAULT"); + + private final String value; + + InstanceUsageTypeEnum(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListBasicTaskDetailsResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListBasicTaskDetailsResponse.java new file mode 100644 index 000000000..0d4304a29 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListBasicTaskDetailsResponse.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Response body containing details such as status for list of specific tasks") +public record ListBasicTaskDetailsResponse( + @Schema(description = "NVIDIA Cloud Account id of the tasks with concise response") + @NotBlank String ncaId, + @Schema(description = "List of tasks with few basic properties included in the response") + @NotNull List tasks) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListTasksResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListTasksResponse.java new file mode 100644 index 000000000..5e5189398 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ListTasksResponse.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Response body containing list of tasks") +public record ListTasksResponse ( + @Schema(description = "List of tasks") + @NotNull List tasks, + + @Schema(description = "Pagination limit - Not included in the response for the last slice") + Integer limit, + + @Schema(description = "Pagination cursor - Not included in the response for the last slice") + String cursor) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ResultHandlingStrategyEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ResultHandlingStrategyEnum.java new file mode 100644 index 000000000..b92339361 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ResultHandlingStrategyEnum.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static java.lang.String.format; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum ResultHandlingStrategyEnum { + + UPLOAD("UPLOAD"), + NONE("NONE"); + + private final String name; + + ResultHandlingStrategyEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static ResultHandlingStrategyEnum fromText(@NonNull String val) { + return EnumSet.allOf(ResultHandlingStrategyEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(format("Unsupported enum '%s'", + val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/SecretDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/SecretDto.java new file mode 100644 index 000000000..d618f3241 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/SecretDto.java @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_NAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_VALUE_LENGTH; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import tools.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Builder +@Schema(description = "Data Transfer Object(DTO) representing secret name/value pair") +public record SecretDto( + @Schema(description = "Secret name", + requiredMode = Schema.RequiredMode.REQUIRED) + @Pattern(regexp = SECRET_NAME_REGEX, + message = "Invalid secret name: Must conform to regex " + SECRET_NAME_REGEX) + @Size(min = 1, max = MAX_SECRET_NAME_LENGTH, + message = "Invalid secret name: must be 1 - " + MAX_SECRET_NAME_LENGTH + " chars long") + @NonNull @NotBlank String name, + + @Schema(description = "Secret value must be a string or JSON object and 1 - " + + MAX_SECRET_VALUE_LENGTH + " chars long", + types = {"string", "object"}, + implementation = Object.class, + requiredMode = Schema.RequiredMode.REQUIRED) + @NonNull @ValidSecretValueLength JsonNode value) { + + private static final String MESG_INVALID_SECRET_VALUE = + "Invalid secret value specified"; + private static final String MESG_INVALID_SECRET_VALUE_LENGTH = + "Secret value's length must be 1 - " + MAX_SECRET_VALUE_LENGTH + " chars long"; + + private static final String SECRET_NAME_REGEX = "^[a-z0-9A-Z][a-z0-9A-Z\\_\\.\\-]*$"; + + @Documented + @Target(FIELD) + @Retention(RUNTIME) + @Constraint(validatedBy = SecretValueLengthValidator.class) + @interface ValidSecretValueLength { + String message() default MESG_INVALID_SECRET_VALUE; + + Class[] groups() default {}; + + Class[] payload() default {}; + } + + private static class SecretValueLengthValidator + implements + ConstraintValidator { + + @Override + public boolean isValid( + JsonNode jsonNode, + ConstraintValidatorContext constraintValidatorContext) { + var value = jsonNode.isString() ? jsonNode.asString() : jsonNode.toString(); + var length = value != null ? value.trim().length() : 0; + if (length == 0 || length > MAX_SECRET_VALUE_LENGTH) { + log.error(MESG_INVALID_SECRET_VALUE_LENGTH); + return false; + } + + return true; + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskDto.java new file mode 100644 index 000000000..8b164ed86 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskDto.java @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAGS_COUNT; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.nvidia.nvct.service.telemetry.dto.TelemetriesDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import lombok.Builder; + +@Builder(toBuilder = true) +@Schema(description = "Data Transfer Object(DTO) representing a Task") +public record TaskDto( + @Schema(description = "Unique Task id") + @NotNull UUID id, + + @Schema(description = "NVIDIA Cloud Account Id") + @NotNull String ncaId, + + @Schema(description = "Task name") + @NotNull String name, + + @Schema(description = "Task status") + @NotNull TaskStatusEnum status, + + @Schema(description = "Task GPU Specification") + @NotNull GpuSpecificationDto gpuSpecification, + + @Schema(description = "Task container") + @NotNull URI containerImage, + + @Schema(description = "Args used to launch the container") + @Nullable String containerArgs, + + @Schema(description = "Environment settings used to launch the container") + @Nullable + List containerEnvironment, + + @Schema(description = "Set of models") + @Nullable + Set models, + + @Schema(description = "Set of resources") + @Nullable Set resources, + + @Schema(description = "Set of tags. Maximum allowed number of tags per " + + "Task is " + MAX_TAGS_COUNT + ". Maximum length of each tag is " + + MAX_TAG_LENGTH + " chars.") + @Nullable Set tags, + + @Schema(description = "Task description") + @Nullable String description, + + @Schema(description = "Results handling strategy") + @Nullable ResultHandlingStrategyEnum resultHandlingStrategy, + + @Schema(description = "Results location") + @Nullable String resultsLocation, + + @Schema(description = "Maximum runtime duration", + type = "string", + format = "duration", + example = "PT12H30M") + @JsonFormat(shape = STRING) + @Nullable Duration maxRuntimeDuration, + + @Schema(description = "Maximum queued duration", + defaultValue = "PT72H", + type = "string", + format = "duration", + example = "PT4H30M45S") + @JsonFormat(shape = STRING) + @NotNull Duration maxQueuedDuration, + + @Schema(description = "Termination grace period duration", + defaultValue = "PT1H", + type = "string", + format = "duration", + example = "PT1H30M20S") + @JsonFormat(shape = STRING) + @NotNull Duration terminationGracePeriodDuration, + + @Schema(description = "Optional Helm Chart") + @Nullable URI helmChart, + + @Schema(description = "Task health") + @Nullable HealthDto healthInfo, + + @Schema(description = "Task secret keys") + @Nullable Set secrets, + + @Schema(description = "Percentage complete") + @Nullable Integer percentComplete, + + @Schema(description = "Last heartbeat received timestamp") + @Nullable Instant lastHeartbeatAt, + + @Schema(description = "Last updated timestamp") + @Nullable Instant lastUpdatedAt, + + @Schema(description = "Optional telemetry configuration") + @Nullable TelemetriesDto telemetries, + + @Schema(description = "Task creation timestamp") + @NotNull Instant createdAt, + + @Schema(description = "List of instances") + @Nullable List instances) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskResponse.java new file mode 100644 index 000000000..7fc8d1402 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskResponse.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@Schema(description = "Response body with Task details") +public record TaskResponse( + @Schema(description = "Task details") + @NotNull TaskDto task) { + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskStatusEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskStatusEnum.java new file mode 100644 index 000000000..bdd57fdac --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/TaskStatusEnum.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import static java.lang.String.format; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum TaskStatusEnum { + + QUEUED("QUEUED"), + LAUNCHED("LAUNCHED"), + RUNNING("RUNNING"), + ERRORED("ERRORED"), + CANCELED("CANCELED"), + EXCEEDED_MAX_RUNTIME_DURATION("EXCEEDED_MAX_RUNTIME_DURATION"), + EXCEEDED_MAX_QUEUED_DURATION("EXCEEDED_MAX_QUEUED_DURATION"), + COMPLETED("COMPLETED"); + + private final String name; + + TaskStatusEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static TaskStatusEnum fromText(@NonNull String val) { + return EnumSet.allOf(TaskStatusEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(format("Unsupported enum '%s'", + val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ValidationPolicyNameEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ValidationPolicyNameEnum.java new file mode 100644 index 000000000..22366e318 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/rest/task/dto/ValidationPolicyNameEnum.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Validation Policy Names enumerator for Helm validation. + */ +@Schema(description = "Validation policy names for Helm validation") +public enum ValidationPolicyNameEnum { + DEFAULT("Default"), + UNRESTRICTED("Unrestricted"); + + private final String name; + + ValidationPolicyNameEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/AccountService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/AccountService.java new file mode 100644 index 000000000..9e4766101 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/AccountService.java @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.account; + +import static com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.POLICY_RESULT_ATTRIBUTE; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UnprocessableEntityException; +import com.nvidia.nvct.service.account.dto.AccountDto; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult; +import com.nvidia.nvct.service.nvcf.NvcfClient; +import com.nvidia.nvct.service.telemetry.dto.TelemetryDto; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountService { + + private static final String MESG_UNSUPPORTED_AUTHENTICATION_TYPE = + "Unsupported Authentication class type '%s' or PolicyResult object was not found."; + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MESG_ACCOUNT_NOT_FOUND = + "Account '%s': Does not exist"; + private static final String MESG_ACCOUNT_ID_MISMATCH = + "Account id mismatch. Expected: '%s', Got: '%s'"; + + private final NvcfClient nvcfClient; + + public String getNcaId(Authentication authentication) { + // JWT + if (authentication instanceof JwtAuthenticationToken) { + var clientId = authentication.getName(); + var dto = nvcfClient.getClient(clientId); + return dto.ncaId(); + } + + // Api-Key + if (authentication.getPrincipal() instanceof DefaultOAuth2AuthenticatedPrincipal principal + && principal.getAttributes() != null + && principal.getAttributes() + .get(POLICY_RESULT_ATTRIBUTE) instanceof ApiKeyValidationResult policyResult) { + return policyResult.ncaId(); + } + + throw new UnprocessableEntityException(MESG_UNSUPPORTED_AUTHENTICATION_TYPE + .formatted(authentication.getClass())); + } + + public AccountDto getAccount(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return nvcfClient.getAccount(ncaId); + } + + public String getAccountName(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var account = getAccount(ncaId); + return account.name(); + } + + public AccountDto lookupAccountUsingNcaIdOrThrow(String ncaId) { + try { + return getAccount(ncaId); + } catch (WebClientResponseException.NotFound ex) { + var mesg = MESG_ACCOUNT_NOT_FOUND.formatted(ncaId) + " - " + ex.getMessage(); + log.error(mesg); + throw new NotFoundException(mesg, ex); + } + } + + public Map getAccountTelemetryMap(String ncaId) { + var accountDto = getAccount(ncaId); + + if (CollectionUtils.isEmpty(accountDto.telemetries())) { + return Collections.emptyMap(); + } + + return accountDto.telemetries().stream() + .collect(Collectors.toMap(TelemetryDto::telemetryId, Function.identity())); + } + + public void invalidateCacheForSpecificAccount(String ncaId) { + nvcfClient.invalidateCacheForSpecificAccount(ncaId); + } + + public void assertAccountExistsOrThrow(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // Verify account exists by trying to fetch it from NVCF + try { + nvcfClient.getAccount(ncaId); + } catch (WebClientResponseException.NotFound ex) { + var mesg = MESG_ACCOUNT_NOT_FOUND.formatted(ncaId); + log.error(mesg); + throw new NotFoundException(mesg, ex); + } + } + + // Used only for super admin endpoints to ensure that the NCA Id specified in the path + // matches the corresponding property in the auth token. We do not allow api-keys with admin: + // scopes as api-keys are locked to the account and cannot be used across accounts. This is + // different from JWTs with admin: scopes which can be used across different accounts. The + // reason we chose to lock down api-keys to a specific account is because they are long-lived + // when compared to JWTs which are ephemeral and short-lived. + public void assertAccountIdFromPathMatches( + String ncaId, // Value of the path variable in super admin endpoints + Authentication authentication) { + // Api-key + if (authentication.getPrincipal() instanceof DefaultOAuth2AuthenticatedPrincipal principal + && principal.getAttributes() != null + && principal.getAttributes() + .get(POLICY_RESULT_ATTRIBUTE) instanceof ApiKeyValidationResult policyResult) { + // Check if the nca_id in the api-key matches the value from the path variable. + if (!policyResult.ncaId().equals(ncaId)) { + var mesg = MESG_ACCOUNT_ID_MISMATCH.formatted(policyResult.ncaId(), ncaId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + return; + } + + // JWT: No-op + if (authentication instanceof JwtAuthenticationToken) { + // No need to further check for any match for JWTs for super admin endpoints. + return; + } + + throw new UnprocessableEntityException(MESG_UNSUPPORTED_AUTHENTICATION_TYPE + .formatted(authentication.getClass())); + } + + @VisibleForTesting + public void invalidateCache() { + nvcfClient.invalidateCache(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/AccountDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/AccountDto.java new file mode 100644 index 000000000..537528f39 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/AccountDto.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.account.dto; + +import com.nvidia.nvct.service.telemetry.dto.TelemetryDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import lombok.Builder; + + +@Builder +@Schema(description = "Data Transfer Object(DTO) representing an account") +public record AccountDto( + + @Schema(description = "NVIDIA Cloud Account id") + @NotNull String ncaId, + + @Schema(description = "Client Ids associated with the NVIDIA Cloud Account") + @Nullable Set clientIds, + + @Schema(description = "Account/Org name") + @NotNull String name, + + @Schema(description = "Account Telemetry configurations") + @Nullable List telemetries, + + @Schema(description = "Registry credentials associated with the account") + @Nullable List registryCredentials, + + @Schema(description = "Maximum number of tasks allowed for Account") + @NotNull Integer maxTasksAllowed, + + @Schema(description = "Last time the account was updated.") + @Nullable Instant lastUpdatedAt) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/RegistryCredentialDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/RegistryCredentialDto.java new file mode 100644 index 000000000..ad99283ea --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/account/dto/RegistryCredentialDto.java @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.account.dto; + +import static com.nvidia.nvct.util.NvctConstants.HOSTNAME_REGEX; +import static com.nvidia.nvct.util.NvctConstants.MAX_HOSTNAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAGS_COUNT; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.TAG_REGEX; + +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.Set; +import lombok.Builder; +import org.hibernate.validator.constraints.Length; + +@Builder(toBuilder = true) +@Schema(description = "DTO of a registry credential") +public record RegistryCredentialDto( + @Schema(description = "Registry hostname") + @Pattern(regexp = HOSTNAME_REGEX, + message = "Invalid hostname: Must conform to regex " + HOSTNAME_REGEX) + @Size(min = 1, max = MAX_HOSTNAME_LENGTH, + message = "Invalid hostname: Must be 1 - " + MAX_HOSTNAME_LENGTH + " chars long") + @NotBlank String registryHostname, + + @Schema(description = "Registry credential - secret value must be base64 encoded " + + "string in username:password format") + @NotNull SecretDto secret, + + @Schema(description = "Artifact types") + @NotNull @NotEmpty Set artifactTypes, + + @Nullable + @Schema(description = "Optional set of tags") + @Valid + @Size(max = MAX_TAGS_COUNT, + message = "Maximum number of tags of " + MAX_TAGS_COUNT + " is exceeded.") + Set<@Length(max = MAX_TAG_LENGTH, + message = "Maximum tag length of " + MAX_TAG_LENGTH + " is exceeded.") + @Pattern(regexp = TAG_REGEX) String> tags, + + @Nullable + @Schema(description = "Optional registry credential description") + String description) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResult.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResult.java new file mode 100644 index 000000000..4e989b1c0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResult.java @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.google.common.annotations.VisibleForTesting; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.util.StringUtils; + +/** + * Represents the result of API Key validation. + * + * @param allowed indicates whether the current request should be allowed to proceed + * @param ncaId NVIDIA Cloud Account(NCA) id + * @param ownerId for Service Keys, this parameter will be NCA Id; for Personal Keys, + * this parameter will be OIDC Id + * @param policy resource types and scopes + */ +public record ApiKeyValidationResult(@JsonProperty("allowed") boolean allowed, + @JsonProperty("ncaId") String ncaId, + @JsonProperty("ownerId") String ownerId, + @JsonProperty("policy") Policy policy) { + + public static final String TASK_ACCESS_ATTRIBUTE = "task_access"; + public static final String POLICY_RESULT_ATTRIBUTE = "policy_result"; + + public ApiKeyValidationResult( + boolean allowed, + String ncaId, + String ownerId, + Policy policy) { + this.allowed = allowed; + this.ncaId = ncaId; + this.ownerId = ownerId; + this.policy = policy; + } + + public record Resource(@JsonProperty("type") String type, @JsonProperty("id") String id) { + + } + + public record Policy( + @JsonProperty("resources") @JsonSetter(nulls = Nulls.AS_EMPTY) List resources, + @JsonProperty("scopes") @JsonSetter(nulls = Nulls.AS_EMPTY) List scopes, + @JsonProperty("product") String product) { + + } + + public boolean valid() { + return allowed && StringUtils.hasText(ncaId) && StringUtils.hasText(ownerId) && + policy != null; + } + + @JsonIgnore + public OAuth2AuthenticatedPrincipal getOAuth2Principal() { + Map resourcesAttribute = Map.of( + TASK_ACCESS_ATTRIBUTE, allAllowedTasks(policy.resources), + POLICY_RESULT_ATTRIBUTE, this); + var scopes = policy.scopes.stream() + .map(scope -> (GrantedAuthority) new SimpleGrantedAuthority("apikey:" + scope)) + .toList(); + return new DefaultOAuth2AuthenticatedPrincipal(ownerId, resourcesAttribute, scopes); + } + + public record ApiKeyTaskAccess(Set allowedTaskIds, + boolean privateTasksAllowed) { + + public boolean hasResourcesScopedForTask(UUID taskId) { + return allowedTaskIds.contains(taskId); + } + } + + @VisibleForTesting + static ApiKeyTaskAccess allAllowedTasks(List resources) { + Set allowedTaskIds = new HashSet<>(); + boolean privateTasksAllowed = false; + + for (var resource : resources) { + if ("account-tasks".equals(resource.type()) && "*".equals(resource.id())) { + privateTasksAllowed = true; + } + if (!"task".equals(resource.type())) { + continue; + } + var resourceId = resource.id(); + if (resourceId == null) { + continue; + } + + try { + var resourceTaskId = UUID.fromString(resourceId); + allowedTaskIds.add(resourceTaskId); + } catch (Exception e) { + // continue + } + } + return new ApiKeyTaskAccess(allowedTaskIds, privateTasksAllowed); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysClient.java new file mode 100644 index 000000000..d7b1277c0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysClient.java @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientApiKeysProperties; +import com.nvidia.nvct.service.apikeys.dto.ApiKeyValidationRequest; +import com.nvidia.nvct.service.apikeys.dto.ApiKeyValidationResponse; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +@Service +@RefreshScope +@Slf4j +public class ApiKeysClient { + + private static final String CLIENT_REGISTRATION_ID = "api-keys"; + + private static final RetryBackoffSpec RETRY_SPEC = Retry.backoff(2, Duration.ofMillis(200)) + .jitter(0.75) + .doBeforeRetry(retrySignal -> log.info("before retrying call")) + .doAfterRetry(retrySignal -> log.info("after retrying call")) + // retry only on 500 upstream + .filter(UpstreamException.class::isInstance) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + log.error("External Service failed to process after max retries"); + return new UpstreamException( + "Failed to get response from external system after retries."); + }); + + private final WebClient webClient; + private final JsonMapper jsonMapper; + private final String evaluationUri; + private final String requestPropertyName; + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public ApiKeysClient( + @Value("${nvct.api-keys.base-url}") String baseUrl, + @Value("${nvct.api-keys.evaluation-uri:/v1/namespaces/nvct/evaluations/apikey.allow}") + String evaluationUri, + @Value("${nvct.api-keys.request-property-name:apiKey}") + String requestPropertyName, + @Value("${spring.security.oauth2.client.registration.api-keys.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.api-keys.client-secret}") + String clientSecret, + @Value("${spring.security.oauth2.client.registration.api-keys.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.api-keys.token-uri}") String tokenUri, + Optional staticClientApiKeysProperties, + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + JsonMapper jsonMapper) { + this.evaluationUri = evaluationUri; + this.requestPropertyName = requestPropertyName; + this.jsonMapper = jsonMapper; + var authFilter = staticClientApiKeysProperties.map(properties -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(properties::getToken)) + .orElseGet(() -> oauthFilter(clientId, clientSecret, scope, tokenUri)); + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .filter(authFilter) + .build(); + } + + private static ServerOAuth2AuthorizedClientExchangeFilterFunction oauthFilter( + String clientId, String clientSecret, String scope, String tokenUri) { + var scopes = StringUtils.isBlank(scope) ? List.of() : + Arrays.stream(scope.split(",")).map(String::trim).toList(); + var clientRegistration = ClientRegistration.withRegistrationId(CLIENT_REGISTRATION_ID) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientId(clientId) + .clientSecret(clientSecret) + .scope(scopes) + .tokenUri(tokenUri) + .build(); + var clientRegistrationRepository = + new InMemoryReactiveClientRegistrationRepository(clientRegistration); + var authorizedClientService = + new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrationRepository); + var authorizedClientManager = + new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientService); + var filter = + new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); + filter.setDefaultClientRegistrationId(CLIENT_REGISTRATION_ID); + return filter; + } + + public ApiKeyValidationResult fetchApiKeyValidationResult(String apiKey) { + return webClient + .post() + .uri(evaluationUri) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(ApiKeyValidationRequest.builder() + .jsonField(requestPropertyName, apiKey) + .build()) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> { + log.error("4xx error from ApiKeys Svc: {}", response.statusCode()); + return response.createException(); + }) + .onStatus(HttpStatusCode::is5xxServerError, response -> { + log.error("Error response code from ApiKeys Svc: {}", response.statusCode()); + return Mono.error(new UpstreamException("ApiKeys Service returned 5xx error")); + }) + .bodyToMono(ApiKeyValidationResponse.class) + .retryWhen(RETRY_SPEC) + .switchIfEmpty(Mono.error(() -> new UpstreamException("No response from ApiKeys Svc"))) + .map(apiKeysResponse -> jsonMapper.convertValue(apiKeysResponse.getResult(), + ApiKeyValidationResult.class)) + .filter(ApiKeyValidationResult::valid) + .switchIfEmpty(Mono.error(() -> new ForbiddenException("Authorization failed"))) + .block(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysService.java new file mode 100644 index 000000000..91eed1af6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/ApiKeysService.java @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys; + +import static com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.POLICY_RESULT_ATTRIBUTE; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.ApiKeyTaskAccess; +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClientRequestException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiKeysService { + private static final String MESG_POLICY_RESULT_FROM_BACKUP_CACHE = + "Returning ApiKeyValidationResult from backup cache as ApiKeys is not reachable - '{}'"; + private static final String MESG_POLICY_RESULT_NOT_IN_BACKUP_CACHE = + "ApiKeys is not reachable and ApiKeyValidationResult is not in backup cache - '{}'"; + private static final String MESG_API_KEY_VALIDATION_RESULT = + "Api Key Validation Result: '{}'"; + + private final ApiKeysClient apiKeysClient; + private final LoadingCache apiKeysCache = Caffeine.newBuilder() + .maximumSize(512).expireAfterWrite(Duration.ofMinutes(1)) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchApiKeyValidationResult); + private final Cache apiKeysBackupCache = Caffeine.newBuilder() + .maximumSize(512).expireAfterWrite(Duration.ofMinutes(60)) + .scheduler(Scheduler.systemScheduler()) + .build(); + + private ApiKeyValidationResult fetchApiKeyValidationResult(String apiKey) { + try { + var result = apiKeysClient.fetchApiKeyValidationResult(apiKey); + log.debug(MESG_API_KEY_VALIDATION_RESULT, result); + apiKeysBackupCache.put(apiKey, result); + return result; + } catch (WebClientRequestException | UpstreamException ex) { + // WebClientRequestException is thrown when external service(such as Api Keys) is not + // reachable. NVCT should use the backup cache only when Api Keys is not reachable. For + // other exceptions, backup cache should not be used. + return fetchApiKeyValidationResultFromBackupCache(apiKey, ex); + } + } + + public ApiKeyValidationResult resolveNCAIdFromApiKey(String apiKey) { + return apiKeysCache.get(apiKey); + } + + @VisibleForTesting + public void invalidateCache() { + apiKeysCache.invalidateAll(); + apiKeysBackupCache.invalidateAll(); + } + + @VisibleForTesting + public void invalidatePrimaryCache() { + apiKeysCache.invalidateAll(); + } + + public static Optional isApiKeyAuth(Authentication authentication) { + if (!(authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal)) { + return Optional.empty(); + } + if (principal.getAttribute( + ApiKeyValidationResult.TASK_ACCESS_ATTRIBUTE) instanceof ApiKeyTaskAccess access) { + return Optional.of(access); + } + return Optional.empty(); + } + + public Optional getApiKeyValidationResult( + Authentication authentication) { + if (!(authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal)) { + return Optional.empty(); + } + if (principal.getAttribute( + POLICY_RESULT_ATTRIBUTE) instanceof ApiKeyValidationResult policyResult) { + return Optional.of(policyResult); + } + return Optional.empty(); + } + + private ApiKeyValidationResult fetchApiKeyValidationResultFromBackupCache( + String apiKey, + RuntimeException ex) { + var policyResult = apiKeysBackupCache.getIfPresent(apiKey); + if (policyResult == null) { + log.error(MESG_POLICY_RESULT_NOT_IN_BACKUP_CACHE, ex.getMessage()); + throw ex; + } + log.info(MESG_POLICY_RESULT_FROM_BACKUP_CACHE, ex.getMessage()); + return policyResult; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationRequest.java new file mode 100644 index 000000000..5b325aff9 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationRequest.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys.dto; + +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Singular; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Jacksonized +@Builder +public class ApiKeyValidationRequest { + + @Singular("jsonField") + Map input; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationResponse.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationResponse.java new file mode 100644 index 000000000..e26833436 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/apikeys/dto/ApiKeyValidationResponse.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ApiKeyValidationResponse { + + private String namespace; + + @JsonProperty("rule_name") + private String ruleName; + + private Object result; + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/ClientService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/ClientService.java new file mode 100644 index 000000000..c45f615c0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/ClientService.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.client; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.service.client.dto.ClientDto; +import com.nvidia.nvct.service.nvcf.NvcfClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClientService { + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + + private final NvcfClient nvcfClient; + + public ClientDto getClient(String clientId) { + if (StringUtils.isBlank(clientId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("clientId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return nvcfClient.getClient(clientId); + } + + @VisibleForTesting + public void invalidateCache() { + nvcfClient.invalidateCache(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/dto/ClientDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/dto/ClientDto.java new file mode 100644 index 000000000..fd6135643 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/client/dto/ClientDto.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.client.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@Schema(description = "Data Transfer Object(DTO) representing a client") +public record ClientDto( + @Schema(description = "Client Id") + @Nullable String clientId, + + @Schema(description = "Associated NVIDIA Cloud Account id") + @NotNull String ncaId, + + @Schema(description = "Name of the associated NVIDIA Cloud Account") + @NotNull String name) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssClient.java new file mode 100644 index 000000000..5c19472f7 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssClient.java @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ess; + +import static com.nvidia.nvct.util.NvctConstants.ESS_NAMESPACE; + +import tools.jackson.databind.JsonNode; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientEssProperties; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.service.ess.EssStubService.SaveSecretsRequest; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import jakarta.annotation.Nonnull; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Slf4j +@Service +@RefreshScope +public class EssClient { + + private static final String MESG_NO_SECRETS_TO_SAVE = + "Task '%s': No user secrets specified to save"; + private static final String MESG_MISSING_RESPONSE_BODY = + "Task '%s': ESS '%s' - Response body cannot be null"; + private static final String MESG_MISSING_FETCH_SECRETS_RESPONSE_BODY = + "Secret Path '%s': ESS '%s' - Response body cannot be null"; + private static final String MESG_ESS_DISABLED = "ESS interaction is disabled"; + + public static final String CLIENT_REGISTRATION_ID = "ess"; + private final EssStubService essStubService; + private final boolean enabled; + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public EssClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + ManagedHttpResources essHttpResources, + @Value("${nvct.ess.base-url}") String baseUrl, + @Value("${nvct.ess.enabled:true}") boolean enabled, + @Value("${spring.security.oauth2.client.registration.ess.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.ess.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.ess.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.ess.token-uri}") String tokenUri, + Optional staticClientEssProperties) { + var authFilter = oauthFilter(staticClientEssProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .clientConnector(essHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("ESS")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.essStubService = factory.createClient(EssStubService.class); + this.enabled = enabled; + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientEssProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientEssProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + @Nonnull + public UUID saveSecrets(UUID taskId, Set secrets) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return UUID.randomUUID(); + } + + if (CollectionUtils.isEmpty(secrets)) { + // Shouldn't have reached here if there are no secrets in the request payload. + var mesg = MESG_NO_SECRETS_TO_SAVE.formatted(taskId); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var data = secrets.stream().collect(Collectors.toMap(SecretDto::name, SecretDto::value)); + var response = essStubService.saveSecrets(taskId.toString(), ESS_NAMESPACE, + new SaveSecretsRequest(data)); + return Optional.ofNullable(response) + .map(body -> body.getData().getVersion()) + .orElseThrow(() -> { + var mesg = MESG_MISSING_RESPONSE_BODY + .formatted(taskId, "Save Secrets"); + log.error(mesg); + return new UpstreamException(mesg); + }); + } + + public Optional> getSecretNames(UUID taskId) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return Optional.empty(); + } + + return fetchSecrets(taskId).map(Map::keySet); + } + + public void deleteSecrets(UUID taskId) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return; + } + + essStubService.deleteSecrets(taskId.toString(), ESS_NAMESPACE); + } + + public Optional> fetchSecrets(UUID taskId) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return Optional.empty(); + } + + try { + var response = essStubService.fetchSecrets(taskId.toString(), + "fetch_secret", + ESS_NAMESPACE); + return Optional.ofNullable(response) + .map(body -> Optional.ofNullable(body.getData().getData())) + .orElseThrow(() -> { + var mesg = MESG_MISSING_RESPONSE_BODY + .formatted(taskId, "Fetch Secrets"); + log.error(mesg); + return new UpstreamException(mesg); + }); + } catch (NotFoundException ex) { + return Optional.empty(); + } + } + + public void deleteSecretsPath(UUID taskId) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return; + } + essStubService.deleteSecretsPath(taskId.toString(), ESS_NAMESPACE); + } + + public Optional> fetchTelemetrySecret(String ncaId, UUID telemetryId) { + if (!enabled) { + log.debug(MESG_ESS_DISABLED); + return Optional.empty(); + } + + try { + var response = essStubService.fetchTelemetrySecret(ncaId, + telemetryId.toString(), + "fetch_secret", + ESS_NAMESPACE); + return Optional.ofNullable(response) + .map(body -> Optional.ofNullable(body.getData().getData())) + .orElseThrow(() -> { + var path = "accounts/%s/telemetries/%s".formatted(ncaId, telemetryId); + var mesg = MESG_MISSING_FETCH_SECRETS_RESPONSE_BODY + .formatted(path, "Fetch Secrets"); + log.error(mesg); + return new UpstreamException(mesg); + }); + } catch (NotFoundException ex) { + return Optional.empty(); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssService.java new file mode 100644 index 000000000..f1946b57f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssService.java @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ess; + +import com.nvidia.nvct.rest.task.dto.SecretDto; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EssService { + + private final EssClient essClient; + + public UUID saveSecrets(UUID taskId, Set secrets) { + return essClient.saveSecrets(taskId, secrets); + } + + public Optional> getSecretNames(UUID taskId) { + return essClient.getSecretNames(taskId); + } + + public Optional> getSecrets(UUID taskId) { + var secretDtos = essClient.fetchSecrets(taskId) + .map(secrets -> secrets.entrySet() + .stream() + .map(entry -> SecretDto.builder() + .name(entry.getKey()) + .value(entry.getValue()) + .build()) + .collect(Collectors.toSet())) + .orElse(null); + return Optional.ofNullable(secretDtos); + } + + public void deleteSecrets(UUID taskId) { + essClient.deleteSecrets(taskId); + } + + public void deleteSecretsPath(UUID taskId) { + essClient.deleteSecretsPath(taskId); + } + + public boolean telemetrySecretExist(String ncaId, UUID telemetryId) { + var existingSecrets = essClient.fetchTelemetrySecret(ncaId, telemetryId); + return existingSecrets.isPresent() && !existingSecrets.get().isEmpty(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssStubService.java new file mode 100644 index 000000000..d901cbe4c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ess/EssStubService.java @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ess; + +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.databind.JsonNode; +import com.google.common.annotations.VisibleForTesting; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.DeleteExchange; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PutExchange; + +public interface EssStubService { + + @PutExchange("v1/tasks/{taskId}/secrets") + SaveSecretsResponse saveSecrets(@PathVariable String taskId, + @RequestHeader("X-ESS-NAMESPACE") String namespace, + @RequestBody SaveSecretsRequest payload); + + @GetExchange("v1/tasks/{taskId}/secrets") + FetchSecretsResponse fetchSecrets(@PathVariable String taskId, + @RequestParam("query_type") String queryType, + @RequestHeader("X-ESS-NAMESPACE") String namespace); + + @GetExchange("v1/accounts/{ncaId}/telemetries/{telemetryId}") + FetchSecretsResponse fetchTelemetrySecret(@PathVariable String ncaId, + @PathVariable String telemetryId, + @RequestParam("query_type") String queryType, + @RequestHeader("X-ESS-NAMESPACE") String namespace); + + @DeleteExchange("v1/tasks/{taskId}/secrets") + void deleteSecrets(@PathVariable String taskId, + @RequestHeader("X-ESS-NAMESPACE") String namespace); + + @DeleteExchange("v1/tasks/{taskId}") + void deleteSecretsPath(@PathVariable String taskId, + @RequestHeader("X-ESS-NAMESPACE") String namespace); + + + @Value + @Jacksonized + @Builder + class SaveSecretsRequest { + @NonNull + Map data; + } + + @Value + @Jacksonized + @Builder + class SaveSecretsResponse { + @NonNull + SaveSecretsData data; + + @Value + @Jacksonized + @Builder + public static class SaveSecretsData { + @JsonProperty("created_time") + Instant createdTime; + UUID version; + } + } + + @Value + @Jacksonized + @Builder + @VisibleForTesting + class FetchSecretsResponse { + @NonNull + FetchSecretData data; + + @Value + @Jacksonized + @Builder + public static class FetchSecretData { + @NonNull + Map data; // Object will be a Map when response is deserialized + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/event/EventService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/event/EventService.java new file mode 100644 index 000000000..08eaa4e99 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/event/EventService.java @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.event; + +import static com.datastax.oss.driver.api.core.data.ByteUtils.fromHexString; +import static com.datastax.oss.driver.api.core.data.ByteUtils.toHexString; +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.MESG_INVALID_CURSOR; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.persistence.event.EventsByTaskRepository; +import com.nvidia.nvct.persistence.event.entity.EventByTaskEntity; +import com.nvidia.nvct.persistence.event.entity.EventByTaskKey; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.event.dto.EventDto; +import com.nvidia.nvct.service.task.TaskService; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EventService { + public static final String STATUS_CHANGE_EVENT_MESSAGE = + "Changing status from '%s' to '%s'"; + public static final String STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR = + "Changing status from '%s' to '%s' with error '%s'"; + + // Pattern to match status change event messages such as above. + private static final Pattern STATUS_CHANGE_PATTERN = + Pattern.compile("Changing status from '[^']*' to '([^']+)'"); + + private static final String MESG_CANNOT_BE_NULL = "Parameter '%s' cannot be null"; + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + + private static final String MESG_TASK_EVENT_OPERATION = + "Task id '{}': {}"; + private static final String MESG_FAILED_TO_PARSE_EVENT_MESSAGE = + "Task id '{}': Could not parse status '{}' from event message: {}"; + private static final String MESG_ERROR_RETRIEVING_TERMINAL_STATUS_FROM_EVENT = + "Task id '{}': Error retrieving terminal status from events: {}"; + private static final String MESG_FORBIDDEN_TO_LIST_EVENTS = + "Task '%s': Forbidden to list events"; + + private final EventsByTaskRepository eventsByTaskRepository; + private final TaskService taskService; + + @Builder + public record EventsSliceContext(List events, String cursor, Integer limit) { } + + public EventsSliceContext fetchEvents(String ncaId, UUID taskId, int limit, String cursor, + Predicate taskAccessMatch) { + var taskEntity = taskService.validateAccount(ncaId, taskId); + if (!taskAccessMatch.test(taskEntity)) { + var mesg = MESG_FORBIDDEN_TO_LIST_EVENTS.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + + Slice pagedResult; + try { + var byteBuffer = cursor == null ? null : fromHexString(cursor); + var pageable = PageRequest.of(0, limit); + var pageRequest = CassandraPageRequest.of(pageable, byteBuffer); + pagedResult = eventsByTaskRepository.findByKeyTaskId(taskId, pageRequest); + } catch (Exception e) { + var mesg = MESG_INVALID_CURSOR.formatted(cursor); + log.error(mesg); + throw new BadRequestException(mesg, e); + } + var dtos = pagedResult.getContent().stream() + .map(EventService::toEventDto) + .toList(); + var builder = EventsSliceContext.builder().events(dtos); + if (pagedResult.hasNext()) { + var pagingState = ((CassandraPageRequest) pagedResult.getPageable()).getPagingState(); + builder.cursor(toHexString(pagingState)); + builder.limit(limit); + } + log.debug(MESG_TASK_EVENT_OPERATION, taskId, "Fetched events"); + return builder.build(); + } + + @VisibleForTesting + public List fetchEvents(String ncaId, UUID taskId) { + var events = fetchEvents(ncaId, taskId, Integer.parseInt(DEFAULT_PAGINATION_LIMIT), null, + taskEntity -> true); + return events.events(); + } + + public boolean deleteEvents(String ncaId, UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // If the Task was deleted but for some reason events were not deleted, then don't + // throw an exception and continue deleting events. + taskService.lookupTask(taskId).ifPresent(task -> taskService.validateAccount(ncaId, task)); + eventsByTaskRepository.deleteByKeyTaskId(taskId); + log.info(MESG_TASK_EVENT_OPERATION, taskId, "Deleted task events"); + return true; + } + + public EventByTaskEntity insertEvent(String ncaId, UUID taskId, String message) { + taskService.validateAccount(ncaId, taskId); + if (StringUtils.isBlank(message)) { + var mesg = MESG_BLANK_PARAMETER.formatted("message"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var eventEntity = EventByTaskEntity.builder() + .key(EventByTaskKey.builder().taskId(taskId).eventId(UUID.randomUUID()).build()) + .createdAt(Instant.now()) + .ncaId(ncaId) + .message(message) + .build(); + eventEntity = eventsByTaskRepository.save(eventEntity); + log.info(MESG_TASK_EVENT_OPERATION, taskId, "Inserted event"); + return eventEntity; + } + + // Used by cleanup subroutine that executes periodically. There is no validation performed + // to match the NCA Id as there could be scenario where the Task entry is deleted but the + // events were not deleted. If the validation kicks in, then it will result in + // NotFoundException and we end up with partially cleaned Task. + public void cleanEvents(UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + eventsByTaskRepository.deleteByKeyTaskId(taskId); + } + + // Retrieves task events and checks if the latest event contains a terminal status transition. + // Returns the terminal status if found, otherwise returns empty. + public Optional getTerminalStatusFromLatestEvent(UUID taskId) { + try { + // Fetch all events for this task and get the latest one by creation time. + var latestEventOpt = eventsByTaskRepository + .findByKeyTaskId(taskId) + .max(Comparator.comparing(EventByTaskEntity::getCreatedAt)); + + return latestEventOpt.flatMap(EventService::parseEventMessage); + } catch (Exception e) { + log.error(MESG_ERROR_RETRIEVING_TERMINAL_STATUS_FROM_EVENT, taskId, e.getMessage(), e); + return Optional.empty(); + } + } + + // ### TODO: Parse the event message to extract the target status. This is temporary. + // We should enhance the events_by_task table to have a separate field for the + // new/target status. + private static Optional parseEventMessage(EventByTaskEntity taskEvent) { + var message = taskEvent.getMessage(); + var taskId = taskEvent.getKey().getTaskId(); + + var matcher = STATUS_CHANGE_PATTERN.matcher(message); + if (matcher.find()) { + var rawStatus = matcher.group(1); + try { + var status = TaskStatus.fromText(rawStatus); + if (TERMINAL_TASK_STATUSES.contains(status)) { // Check if terminal status + return Optional.of(status); + } + } catch (IllegalStateException e) { + log.warn(MESG_FAILED_TO_PARSE_EVENT_MESSAGE, taskId, rawStatus, e.getMessage()); + } + } + + return Optional.empty(); + } + + private static EventDto toEventDto(EventByTaskEntity entity) { + return EventDto.builder() + .eventId(entity.getKey().getEventId()) + .taskId(entity.getKey().getTaskId()) + .ncaId(entity.getNcaId()) + .message(entity.getMessage()) + .createdAt(entity.getCreatedAt()) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClient.java new file mode 100644 index 000000000..e259704ad --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClient.java @@ -0,0 +1,530 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_CONTAINER_ENV; +import static com.nvidia.nvct.util.NvctConstants.MAX_BUFFER_LIMIT; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.collect.Lists; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientIcmsProperties; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.icms.IcmsStubService.DescribeInstancesResponse; +import com.nvidia.nvct.service.icms.IcmsStubService.DescribeInstancesResponse.Instance; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest; +import com.nvidia.nvct.service.registry.RegistryArtifactValidationService; +import com.nvidia.nvct.service.registry.RegistryCredentialService; +import com.nvidia.nvct.service.telemetry.TelemetryService; +import com.nvidia.nvct.service.token.TokenService; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Slf4j +@Service +@RefreshScope +public class IcmsClient { + + private static final String MESG_NO_REQUEST_ID_FROM_ICMS = + "Task id '%s': No request-id returned from ICMS"; + private static final String MESG_INSTANCE_TYPE_NOT_AVAILABLE = + "Task id '%s': Instance-type not available for Backend '%s' GPU '%s'"; + private static final String MESG_MISSING_DELETABLE_INSTANCES = + "Task id '{}': No deletable instances"; + private static final String MESG_MISSING_IDS_OF_EXTRA_INSTANCES = + "Task id '{}': No instance ids to delete from full list '{}'"; + private static final String MESG_DELETING_EXTRA_INSTANCES = + "Task id '{}': Deleting extra instances '{}' from full list '{}'"; + private static final String MESG_DELETED_EXTRA_INSTANCES = + "Task id '{}': Deleted extra instances '{}' from full list '{}'"; + private static final String MESG_INVALID_CACHE_HANDLE = + "Task id '%s': Empty cache handle."; + private static final String MSEG_INSTANCE_NOT_FOUND = "Instance id '%s' not found"; + private static final String MESG_ERROR_RESPONSE_STATUS = + "Upstream ICMS responded with status code '%d' - %s"; + private static final String MESG_FETCH_CLUSTERS = + "Account '{}': Fetching Clusters from ICMS for instance type '{}'"; + private static final String MESG_REMOTE_CONFIG_REFRESH = + "Remote config refresh observed: nvct.sidecars.init-container = {}"; + private static final String MESG_NO_ICMS_WORKLOAD = + "Account '{}': No ICMS workload found for taskId '{}', treating terminate as idempotent"; + + private static final int BATCH_SIZE = 32; + public static final String CLIENT_REGISTRATION_ID = "icms"; + + private final IcmsStubService icmsStubService; + private final String selfFqdn; + private final String globalFqdnGrpc; + private final URI tracingUrl; + private final AccountService accountService; + private final String tracingAccessToken; + private final String initContainer; + private final String otelContainer; + private final String utilsContainer; + private final String essAgentContainer; + private final String essFqdn; + private final String otelCollectorContainer; + private final TokenService tokenService; + private final TelemetryService telemetryService; + private final RegistryArtifactValidationService registryArtifactValidationService; + private final RegistryCredentialService registryCredentialService; + private final LoadingCache> clustersCache; + + private record IcmsCacheKey(String ncaId, InstanceUsageTypeEnum instanceTypeUsage) { + } + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public IcmsClient( + @Value("${nvct.icms.base-url}") String baseUrl, + @Value("${nvct.sidecars.tracing-key}") String tracingAccessToken, + @Value("${nvct.sidecars.init-container}") String initContainer, + @Value("${nvct.sidecars.otel-container}") String otelContainer, + @Value("${nvct.sidecars.utils-container}") String utilsContainer, + @Value("${nvct.sidecars.ess-agent-container}") String essAgentContainer, + @Value("${nvct.ess.base-url}") String essFqdn, + @Value("${nvct.sidecars.otel-collector-container}") String otelCollectorContainer, + @Value("${nvct.fqdn}") String selfFqdn, + @Value("${nvct.global-fqdn-grpc}") String globalFqdnGrpc, + @Value("${spring.security.oauth2.client.registration.icms.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.icms.client-secret}") + String clientSecret, + @Value("${spring.security.oauth2.client.registration.icms.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.icms.token-uri}") String tokenUri, + @Value("${management.opentelemetry.tracing.export.otlp.endpoint}") String otlpTracingEndpoint, + AccountService accountService, + TokenService tokenService, + TelemetryService telemetryService, + RegistryArtifactValidationService registryArtifactValidationService, + RegistryCredentialService registryCredentialService, + Optional staticClientIcmsProperties, + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + ManagedHttpResources icmsHttpResources) { + this.tracingAccessToken = tracingAccessToken; + this.initContainer = initContainer; + this.otelContainer = otelContainer; + this.utilsContainer = utilsContainer; + this.essAgentContainer = essAgentContainer; + this.essFqdn = essFqdn; + this.otelCollectorContainer = otelCollectorContainer; + this.selfFqdn = selfFqdn; + this.globalFqdnGrpc = globalFqdnGrpc; + this.accountService = accountService; + this.tokenService = tokenService; + this.telemetryService = telemetryService; + this.registryArtifactValidationService = registryArtifactValidationService; + this.registryCredentialService = registryCredentialService; + + var authFilter = oauthFilter(staticClientIcmsProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_BUFFER_LIMIT)) + .clientConnector(icmsHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("ICMS")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.icmsStubService = factory.createClient(IcmsStubService.class); + this.tracingUrl = URI.create(otlpTracingEndpoint); + this.clustersCache = Caffeine.newBuilder() + .maximumSize(64) + .expireAfterWrite(Duration.ofMinutes(15)) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchClusters); + } + + // Temporary verification hook for NVCF-10266 remote-config rollout (v2). + // Remove after sign-off — the actuator env source check is the durable contract. + @EventListener(RefreshScopeRefreshedEvent.class) + public void logRemoteConfigRefresh() { + log.info(MESG_REMOTE_CONFIG_REFRESH, initContainer); + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientIcmsProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientIcmsProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + /** + * @param task the task that the instance being created will run + * @return list of ICMS request ids and count of instances associated with that request + */ + public UUID createInstance( + TaskEntity task, + GpuSpecUdt gpuSpec, + String artifactCacheHandle, + @NotNull Long artifactSize) { + if (StringUtils.isBlank(artifactCacheHandle)) { + var mesg = MESG_INVALID_CACHE_HANDLE.formatted(task.getTaskId()); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + var telemetries = base64EncodeTelemetryDetails(task); + var env = getEnvironment(task); + var policy = getHelmValidationPolicy(task); + return scheduleSingleInstanceType(task, + env, + gpuSpec, + artifactCacheHandle, + artifactSize, + telemetries, + policy); + } + + public List getInstancesByTaskId( + String ncaId, + UUID deploymentId) { + try { + var response = icmsStubService.terminateInstancesByTaskId( + ncaId, deploymentId, false, true, false); + if (response == null || response.getInstances() == null) { + return List.of(); + } + return response.getInstances(); + } catch (NotFoundException ex) { + return List.of(); + } + } + + // Includes terminated instances and instances whose acknowledgement has expired. + public List getAllInstancesByTaskId( + String ncaId, + UUID deploymentId) { + try { + var response = icmsStubService.terminateInstancesByTaskId( + ncaId, deploymentId, true, true, true); + if (response == null || response.getInstances() == null) { + return List.of(); + } + return response.getInstances(); + } catch (NotFoundException ex) { + return List.of(); + } + } + + public List getClusters( + String ncaId, InstanceUsageTypeEnum usageType) { + return clustersCache.get(new IcmsCacheKey(ncaId, usageType)); + } + + public void deleteInstances(List instanceIds) { + Lists.partition(instanceIds, BATCH_SIZE) + .stream() + .forEach(this::deleteInstancesUnBatched); + } + + public void deleteExtraInstances( + TaskEntity task, int extraInstancesToDelete, + List deletableInstances) { + var taskId = task.getTaskId(); + + if (CollectionUtils.isEmpty(deletableInstances)) { + log.warn(MESG_MISSING_DELETABLE_INSTANCES, taskId); + return; + } + + var allDeletableInstanceIds = deletableInstances.stream() + .filter(instance -> instance.getInstanceId() != null) + .map(InstanceRequest::getInstanceId) + .toList(); + + // Target oldest instances for deletion. All the instances will be in either "running" + // or "starting" state. + var targetInstanceIds = deletableInstances.stream() + .filter(instance -> instance.getInstanceId() != null) + .sorted(Comparator.comparing(InstanceRequest::getCreateTime)) + .limit(extraInstancesToDelete) + .map(InstanceRequest::getInstanceId) + .toList(); + if (CollectionUtils.isEmpty(targetInstanceIds)) { + log.warn(MESG_MISSING_IDS_OF_EXTRA_INSTANCES, taskId, + allDeletableInstanceIds); + return; + } + log.info(MESG_DELETING_EXTRA_INSTANCES, taskId, targetInstanceIds, + allDeletableInstanceIds); + deleteInstances(targetInstanceIds); + log.info(MESG_DELETED_EXTRA_INSTANCES, taskId, targetInstanceIds, + allDeletableInstanceIds); + } + + public void terminateInstanceByTaskId(String ncaId, UUID taskId) { + try { + icmsStubService.terminateInstancesByTaskId(ncaId, taskId); + } catch (NotFoundException ex) { + log.info(MESG_NO_ICMS_WORKLOAD, ncaId, taskId); + } + } + + public Instance getInstanceById(String instanceId) { + var response = icmsStubService.describeInstances(Set.of(instanceId)); + return Optional.ofNullable(response) + .map(DescribeInstancesResponse::getInstances) + .stream() + .flatMap(Collection::stream) + .filter(instance -> instance.getInstanceId().equals(instanceId)) + .findFirst() + .orElseThrow( + () -> new NotFoundException(MSEG_INSTANCE_NOT_FOUND.formatted(instanceId))); + } + + private List fetchClusters(IcmsCacheKey cacheKey) { + log.info(MESG_FETCH_CLUSTERS, cacheKey.ncaId(), cacheKey.instanceTypeUsage()); + return icmsStubService.getClusters(cacheKey.ncaId(), cacheKey.instanceTypeUsage()); + } + + private UUID scheduleSingleInstanceType( + TaskEntity task, + String env, + GpuSpecUdt gpuSpec, + String cacheHandle, + @NonNull Long cacheSize, + String telemetries, + String helmValidationPolicy) { + var taskId = task.getTaskId(); + var taskName = task.getName(); + var backend = gpuSpec.getBackend(); + var clusters = gpuSpec.getClusters(); + var ncaId = task.getNcaId(); + var gpu = gpuSpec.getGpu(); + var instanceType = gpuSpec.getInstanceType(); + if (isBlank(instanceType)) { + var mesg = MESG_INSTANCE_TYPE_NOT_AVAILABLE.formatted(taskId, backend, gpu); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var ownerNcaId = task.getNcaId(); + var maxRuntimeDuration = getMaxRuntimeDuration(task); + var maxQueuedDuration = task.getMaxQueuedDuration().toString(); + var terminationGracePeriodDuration = + task.getTerminalGracePeriodDuration().toString(); + var resultHandlingStrategy = task.getResultHandlingStrategy().toString(); + var instanceCount = 1; + var configuration = isNotBlank(task.getHelmChart()) ? getConfiguration(gpuSpec) : null; + var response = icmsStubService.createInstance(backend, + clusters, + gpu, + instanceType, + ncaId, + accountService.getAccountName(ncaId), + instanceCount, + getTaskContainer(task), + getHelmChart(task), + configuration, + env, + null, + cacheSize != 0, + cacheHandle, + cacheSize != 0 ? cacheSize : null, + ownerNcaId, + taskId, + taskName, + maxRuntimeDuration, + maxQueuedDuration, + terminationGracePeriodDuration, + resultHandlingStrategy, + telemetries, + // taskId will play as deploymentId and + // gpuSpecId + taskId, + taskId, + helmValidationPolicy); + return Optional.of(response) + .map(IcmsStubService.CreateInstancesResponse::getRequestId) + .orElseThrow(() -> new UpstreamException(MESG_NO_REQUEST_ID_FROM_ICMS + .formatted(taskId))); + } + + private String getEnvironment(TaskEntity task) { + var taskId = task.getTaskId(); + var ncaId = task.getNcaId(); + var nvctWorkerToken = tokenService.issueWorkerAccessAssertion(ncaId, taskId); + var containerArgs = task.getContainerArgs(); + var args = isNotBlank(containerArgs) ? containerArgs : StringUtils.EMPTY; + var containerEnv = task.getContainerEnvironment(); + var cenv = isNotBlank(containerEnv) ? containerEnv : DEFAULT_CONTAINER_ENV; + var terminalGracePeriodDuration = task.getTerminalGracePeriodDuration(); + var taskSecretsPresent = task.hasSecrets(); + var secretsAssertionToken = tokenService.issueSecretsAssertion(task); + var sidecarRegistryCredentialEncoded = registryCredentialService + .getBase64EncodedSidecarRegistryImagePullSecret(task); + var containerRegistryCredentialsEncoded = + validateAndGetContainerRegistryImagePullSecrets(task); + var helmRegistryCredentialsEncoded = + validateAndGetHelmRegistryImagePullSecrets(task); + var env = Stream.of( + Pair.of("NCA_ID", task.getNcaId()), + Pair.of("ACCOUNT_NAME", accountService.getAccountName(ncaId)), + Pair.of("NVCT_WORKER_TOKEN", nvctWorkerToken), + Pair.of("NVCT_FQDN", selfFqdn), + Pair.of("NVCT_FQDN_GRPC", globalFqdnGrpc), + Pair.of("INIT_CONTAINER", initContainer), + Pair.of("OTEL_CONTAINER", otelContainer), + Pair.of("UTILS_CONTAINER", utilsContainer), + Pair.of("ESS_AGENT_CONTAINER", essAgentContainer), + Pair.of("ESS_FQDN", essFqdn), + Pair.of("OTEL_EXPORTER_OTLP_ENDPOINT", tracingUrl.toString()), + Pair.of("TASK_TAGS", getTaskTags(task)), + Pair.of("TASK_ID", task.getTaskId()), + Pair.of("TASK_NAME", task.getName()), + Pair.of("TASK_CONTAINER", getTaskContainer(task)), + Pair.of("TASK_CONTAINER_ARGS", args), + Pair.of("TASK_CONTAINER_ENV", cenv), + Pair.of("TERMINATION_GRACE_PERIOD", terminalGracePeriodDuration), + Pair.of("RESULTS_LOCATION", getResultsLocation(task)), + Pair.of("TRACING_ACCESS_TOKEN", tracingAccessToken), + Pair.of("BYOO_OTEL_COLLECTOR_CONTAINER", otelCollectorContainer), + Pair.of("TASK_SECRETS_PRESENT", taskSecretsPresent), + Pair.of("CONTAINER_REGISTRIES_CREDENTIALS", containerRegistryCredentialsEncoded), + Pair.of("HELM_REGISTRIES_CREDENTIALS", helmRegistryCredentialsEncoded), + Pair.of("SECRETS_ASSERTION_TOKEN", secretsAssertionToken), + Pair.of("SIDECAR_REGISTRY_CREDENTIAL", sidecarRegistryCredentialEncoded)) + .map(pair -> pair.getFirst() + "=" + pair.getSecond()) + .collect(Collectors.joining("\n")); + return Base64.getEncoder().encodeToString(env.getBytes(StandardCharsets.UTF_8)); + } + + + private static String getTaskTags(TaskEntity task) { + var tags = task.getTags(); + if (tags == null || tags.isEmpty()) { + return ""; + } + return String.join(",", tags); + } + + private String getTaskContainer(TaskEntity task) { + var containerImage = task.getContainerImage(); + return isBlank(containerImage) ? EMPTY : containerImage; + } + + private String getHelmChart(TaskEntity task) { + var helmChart = task.getHelmChart(); + return isBlank(helmChart) ? EMPTY : helmChart; + } + + private String getConfiguration(GpuSpecUdt spec) { + var configuration = spec.getConfiguration(); + return isBlank(configuration) ? EMPTY : configuration; + } + + private String getResultsLocation(TaskEntity task) { + var resultsLocation = task.getResultsLocation(); + return isBlank(resultsLocation) ? EMPTY : resultsLocation; + } + + private String getMaxRuntimeDuration(TaskEntity task) { + var maxRuntimeDuration = task.getMaxRuntimeDuration(); + return (maxRuntimeDuration != null) ? maxRuntimeDuration.toString() : EMPTY; + } + + private void deleteInstancesUnBatched(List instanceIds) { + if (instanceIds.isEmpty()) { + return; + } + icmsStubService.deleteInstances(instanceIds); + } + + private String base64EncodeTelemetryDetails(TaskEntity entity) { + var telemetriesUdt = entity.getTelemetries(); + if (telemetriesUdt == null) { + return StringUtils.EMPTY; + } + + var ncaId = entity.getNcaId(); + return telemetryService.base64Encode(ncaId, telemetriesUdt); + } + + private String validateAndGetContainerRegistryImagePullSecrets(TaskEntity task) { + registryArtifactValidationService.validateContainerRegistryCredentialsExist(task); + return registryCredentialService + .getBase64EncodedContainerRegistryImagePullSecrets(task); + } + + private String validateAndGetHelmRegistryImagePullSecrets(TaskEntity task) { + registryArtifactValidationService.validateHelmRegistryCredentialsExist(task); + return registryCredentialService + .getBase64EncodedHelmRegistryImagePullSecrets(task); + } + + private static String getHelmValidationPolicy(TaskEntity task) { + if (task.getGpuSpec() != null) { + var policy = task.getGpuSpec().getHelmValidationPolicy(); + if (StringUtils.isNotBlank(policy)) { + return Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8)); + } + } + + return null; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClient.java new file mode 100644 index 000000000..9cb1ca62e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClient.java @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import static com.nvidia.nvct.util.NvctConstants.MAX_BUFFER_LIMIT; +import static org.apache.commons.lang3.StringUtils.isBlank; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientIcmsProperties; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse.ClusterGroup; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse.ClusterGroup.Gpu; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse.ClusterGroup.Gpu.InstanceType; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + + +@Slf4j +@Service +@RefreshScope +public class IcmsClusterGroupClient { + + private static final String CLIENT_REGISTRATION_ID = "icms"; + + private static final String MESG_INVALID_GPU = + "Cluster Group '%s': Invalid GPU '%s' specified"; + private static final String MESG_INVALID_GET_INSTANCE_TYPE_ARGUMENT = + "Invalid argument specified for getting default instance type"; + private static final String MESG_INVALID_CLUSTER_GROUP = + "Invalid Backend or Cluster-Group '%s' specified"; + private static final String MESG_MISSING_GPUS = + "ClusterGroup '%s': Missing GPUs in ICMS response"; + private static final String MESG_MISSING_INSTANCE_TYPES = + "ClusterGroup '%s', GPU '%s': Missing instance-types in ICMS response"; + private static final String MESG_MISSING_DEFAULT_INSTANCE_TYPE = + "Cluster Group '%s', GPU '%s': Missing default instance-type"; + private static final String MESG_MISSING_CLUSTER_GROUPS = + "Account '%s': Missing cluster-groups in successful ICSM response"; + private static final String MESG_DEFAULT_INSTANCE_TYPE_DETAILS = + "ClusterGroup: '{}', GPU: '{}', Default InstanceType: '{}'"; + private static final String MESG_FETCH_CLUSTER_GROUPS = + "Account '{}': Fetching Cluster Groups from ICMS for instance type '{}'"; + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + + private final IcmsStubService icmsStubService; + private final LoadingCache> clusterGroupCache; + + private record IcmsCacheKey(String ncaId, InstanceUsageTypeEnum instanceTypeUsage) { + } + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public IcmsClusterGroupClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + ManagedHttpResources icmsHttpResources, + @Value("${nvct.icms.base-url}") String baseUrl, + @Value("${spring.security.oauth2.client.registration.icms.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.icms.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.icms.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.icms.token-uri}") String tokenUri, + Optional staticClientIcmsProperties) { + + var authFilter = oauthFilter(staticClientIcmsProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_BUFFER_LIMIT)) + .clientConnector(icmsHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("ICMS")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.icmsStubService = factory.createClient(IcmsStubService.class); + this.clusterGroupCache = Caffeine.newBuilder() + .maximumSize(64) + .expireAfterWrite(Duration.ofMinutes(15)) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchClusterGroups); + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientIcmsProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientIcmsProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + public String getDefaultInstanceType(String ncaId, InstanceUsageTypeEnum instanceUsage, + String clusterGroupName, String gpuName) { + if (isBlank(ncaId) || isBlank(clusterGroupName) || isBlank(gpuName)) { + log.error(MESG_INVALID_GET_INSTANCE_TYPE_ARGUMENT); + throw new IllegalArgumentException(MESG_INVALID_GET_INSTANCE_TYPE_ARGUMENT); + } + + var clusterGroups = getClusterGroups(ncaId, instanceUsage); + var targetClusterGroup = targetClusterGroup(ncaId, clusterGroupName, clusterGroups); + var targetGpu = targetGpu(clusterGroupName, gpuName, targetClusterGroup.getGpus()); + var defaultInstanceType = defaultInstanceType(clusterGroupName, gpuName, + targetGpu.getInstanceTypes()); + log.info(MESG_DEFAULT_INSTANCE_TYPE_DETAILS, + targetClusterGroup.getName(), targetGpu.getName(), defaultInstanceType.getName()); + return defaultInstanceType.getName(); // Return name -- not the value. + } + + @VisibleForTesting + public void clearClusterGroupCache() { + clusterGroupCache.invalidateAll(); + } + + public List getClusterGroups(String ncaId, InstanceUsageTypeEnum instanceUsage) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return clusterGroupCache.get(new IcmsCacheKey(ncaId, instanceUsage)); + } + + private List fetchClusterGroups(IcmsCacheKey cacheKey) { + log.info(MESG_FETCH_CLUSTER_GROUPS, cacheKey.ncaId(), cacheKey.instanceTypeUsage()); + var response = icmsStubService.getClusterGroups( + cacheKey.ncaId(), cacheKey.instanceTypeUsage()); + + verifyClusterGroupsResponse(cacheKey.ncaId(), response); + Objects.requireNonNull(response); + return response.getClusterGroups(); + } + + private static void verifyClusterGroupsResponse( + String ncaId, + ClusterGroupsResponse response) { + if ((response == null) || CollectionUtils.isEmpty(response.getClusterGroups())) { + var mesg = MESG_MISSING_CLUSTER_GROUPS.formatted(ncaId); + log.error(mesg); + throw new UpstreamException(mesg); + } + } + + private static ClusterGroup targetClusterGroup( + String ncaId, + String clusterGroupName, + List clusterGroups) { + if (CollectionUtils.isEmpty(clusterGroups)) { + var mesg = MESG_MISSING_CLUSTER_GROUPS.formatted(ncaId); + log.error(mesg); + throw new UpstreamException(mesg); + } + + return clusterGroups.stream() + .filter(cg -> cg.getName().equals(clusterGroupName)) + .findFirst() + .orElseThrow(() -> { + var mesg = MESG_INVALID_CLUSTER_GROUP.formatted(clusterGroupName); + log.error(mesg); + return new BadRequestException(mesg); + }); + } + + private static Gpu targetGpu(String clusterGroupName, String gpuName, List gpus) { + if (CollectionUtils.isEmpty(gpus)) { + var mesg = MESG_MISSING_GPUS.formatted(clusterGroupName); + log.error(mesg); + throw new UpstreamException(mesg); + } + + return gpus.stream() + .filter(gpu -> gpu.getName().equals(gpuName)) + .findFirst() + .orElseThrow(() -> { + var mesg = MESG_INVALID_GPU.formatted(clusterGroupName, gpuName); + log.error(mesg); + return new BadRequestException(mesg); + }); + } + + private static InstanceType defaultInstanceType( + String clusterGroupName, + String gpuName, + List instanceTypes) { + if (CollectionUtils.isEmpty(instanceTypes)) { + var mesg = MESG_MISSING_INSTANCE_TYPES.formatted(clusterGroupName, gpuName); + log.error(mesg); + throw new UpstreamException(mesg); + } + + return instanceTypes.stream() + .filter(InstanceType::isDefaultInstanceType) + .findFirst() + .orElseThrow(() -> { + var mesg = MESG_MISSING_DEFAULT_INSTANCE_TYPE + .formatted(clusterGroupName, gpuName); + log.error(mesg); + return new UpstreamException(mesg); + }); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsService.java new file mode 100644 index 000000000..f422ebe0c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsService.java @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.service.registry.RegistryArtifactService; +import com.nvidia.nvct.service.task.TaskService; +import java.util.Objects; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class IcmsService { + + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MESG_CANNOT_BE_NULL = "Parameter '%s' cannot be null"; + private static final String MESG_INVALID_INSTANCE_TYPE = + "Task id '%s': Invalid instance-type '%s'"; + + private static final String MESG_REQUESTED_INSTANCE = + "Task id '{}', ICMS requestId '{}', GPU '{}': Requested '1' instance"; + private static final String MESG_TASK_ICMS_OPERATION = + "Task id '{}': {}"; + + private final IcmsClient icmsClient; + private final boolean allocatorEnabled; + private final RegistryArtifactService artifactService; + private final TaskService taskService; + + public IcmsService( + IcmsClient icmsClient, + @Value("${nvct.icms.allocator.enabled:true}") boolean allocatorEnabled, + RegistryArtifactService artifactService, + TaskService taskService) { + this.icmsClient = icmsClient; + this.allocatorEnabled = allocatorEnabled; + this.artifactService = artifactService; + this.taskService = taskService; + } + + public UUID scheduleInstance(TaskEntity task) { + if (!allocatorEnabled) { + return null; + } + Objects.requireNonNull(task, () -> MESG_CANNOT_BE_NULL.formatted("task")); + + var taskId = task.getTaskId(); + var gpuSpec = task.getGpuSpec(); + var gpu = gpuSpec.getGpu(); + var ncaId = task.getNcaId(); + var cacheSize = artifactService.getSize(ncaId, taskId); + var cacheHandle = artifactService.getCacheHandle(ncaId, taskId); + var requestId = icmsClient.createInstance(task, gpuSpec, cacheHandle, + cacheSize); + log.info(MESG_REQUESTED_INSTANCE, taskId, requestId, gpu); + return requestId; + } + + public boolean terminateInstanceByTaskId(String ncaId, UUID taskId) { + icmsClient.terminateInstanceByTaskId(ncaId, taskId); + log.info(MESG_TASK_ICMS_OPERATION, taskId, "Terminated ICMS instances by task id."); + return true; + } + + public HealthDto getHealthDto( + UUID taskId, + String instanceType, + String error) { + if (StringUtils.isBlank(instanceType)) { + var mesg = MESG_BLANK_PARAMETER.formatted("instanceType"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + if (StringUtils.isBlank(error)) { + var mesg = MESG_BLANK_PARAMETER.formatted("error"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var task = taskService.fetchTask(taskId); + var gpuSpec = task.getGpuSpec(); + if (!instanceType.equals(gpuSpec.getInstanceType())) { + // ### Temporarily return health info even if the passed in instanceType does + // not match the one in the Task definition. This is happening because NVCA is + // sending an incorrect instanceType to Utils Container. Till NVCA fixes + // the issue, NVCT API will just log a warning and NOT throw an exception if + // instanceType does not match. + var mesg = MESG_INVALID_INSTANCE_TYPE.formatted(taskId, instanceType); + log.warn(mesg); + } + + return HealthDto.builder() + .gpu(gpuSpec.getGpu()) + .instanceType(instanceType) + .backend(gpuSpec.getBackend()) + .error(error) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsStubService.java new file mode 100644 index 000000000..70376b516 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/icms/IcmsStubService.java @@ -0,0 +1,384 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.HealthInfo; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.InstanceState; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.Placement; +import jakarta.annotation.Nullable; +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.DeleteExchange; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; + +public interface IcmsStubService { + + @Value + @Jacksonized + @Builder + class CreateInstancesResponse { + UUID requestId; + } + + @PostExchange(value = "/v1/si?Action=RequestInstances", + accept = "application/json", + contentType = "application/x-www-form-urlencoded") + CreateInstancesResponse createInstance( + @RequestParam(value = "LaunchSpecification.Backend", required = false) + String backend, + @RequestParam(value = "LaunchSpecification.Clusters", required = false) + Set clusters, + @RequestParam("LaunchSpecification.Gpu") + String gpu, + @RequestParam("LaunchSpecification.InstanceType") + String instanceType, + @RequestParam("LaunchSpecification.NcaId") + String ncaId, + @RequestParam("TaskDetails.AccountName") + String accountName, + @RequestParam("InstanceCount") + int instanceCount, + @RequestParam("LaunchSpecification.ContainerImage") + String containerImage, + @RequestParam("LaunchSpecification.HelmChart") + String helmChart, + @RequestParam(value = "LaunchSpecification.Configuration", required = false) + String configuration, + @RequestParam("LaunchSpecification.Environment") + String environment, + @RequestParam(value = "LaunchSpecification.ArtifactUrl", required = false) + URI artifactUrl, + @RequestParam("LaunchSpecification.CacheArtifacts") + boolean cacheArtifacts, + @RequestParam("LaunchSpecification.CacheHandle") + String cacheHandle, + @RequestParam(value = "LaunchSpecification.CacheSize", required = false) + Long cacheSize, + @RequestParam("TaskDetails.OwnerNcaId") + String ownerNcaId, + @RequestParam("TaskDetails.TaskId") + UUID taskId, + @RequestParam("TaskDetails.TaskName") + String taskName, + @RequestParam("LaunchSpecification.MaxRuntimeDuration") + String maxRuntimeDuration, + @RequestParam("LaunchSpecification.MaxQueuedDuration") + String maxQueuedDuration, + @RequestParam("LaunchSpecification.TerminationGracePeriodDuration") + String terminationGracePeriodDuration, + @RequestParam("LaunchSpecification.ResultHandlingStrategy") + String resultHandlingStrategy, + @RequestParam("LaunchSpecification.Telemetries") + String telemetries, + @RequestParam("LaunchSpecification.DeploymentId") + UUID deploymentId, + @RequestParam("LaunchSpecification.GpuSpecificationId") + UUID gpuSpecificationId, + @RequestParam(value = "LaunchSpecification.HelmValidationPolicy", required = false) + String helmValidationPolicy); + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class GetInstancesResponse { + + List instanceRequests; + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class InstanceRequest { + + Instant createTime; + String instanceId; + LaunchSpecification launchSpecification; + String launchedAvailabilityZone; + UUID instanceRequestId; + State state; + Status status; + String instanceInterruptionBehavior; + String cloudProvider; + @Nullable + InstanceState instanceState; + @Nullable + HealthInfo healthInfo; + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class LaunchSpecification { + + String instanceType; + String gpu; + String containerImage; + Placement placement; + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class Placement { + + String availabilityZone; + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class Status { + + String code; + String message; + Instant updateTime; + } + + @RequiredArgsConstructor + public enum State { + OPEN, ACTIVE, CLOSED, CANCELED, FAILED; + + public boolean isActive() { + return this == ACTIVE; + } + + @JsonCreator + public static State fromString(String state) { + return StringUtils.isBlank(state) ? null : State.valueOf(state.toUpperCase()); + } + + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class InstanceState { + + int code; + String name; + + public boolean isStartingOrRunning() { + return "starting".equals(name) || "running".equals(name); + } + + public boolean isRunning() { + return "running".equals(name); + } + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class HealthInfo { + String errorLog; + } + } + } + + @DeleteExchange("/v1/si?Action=TerminateInstances") + GetInstancesResponse deleteInstances( + @RequestParam("InstanceId") List instanceIds); + + @DeleteExchange("/v1/si/accounts/{ncaId}/workloads/{workloadId}") + void terminateInstancesByTaskId( + @PathVariable("ncaId") String ncaId, + @PathVariable("workloadId") UUID taskId); + + @Value + @Jacksonized + @Builder + class ClusterGroupsResponse { + List clusterGroups; + + @Value + @Jacksonized + @Builder + public static class ClusterGroup { + UUID id; + String name; + String ncaId; + List authorizedNcaIds; + List gpus; + List clusters; + + @Value + @Jacksonized + @Builder + public static class Gpu { + String name; + List instanceTypes; + + @Value + @Jacksonized + @Builder + public static class InstanceType { + String name; + String description; + @JsonProperty("default") + boolean defaultInstanceType; + } + } + + @Value + @Jacksonized + @Builder + public static class Cluster { + String k8sVersion; + String id; + String name; + } + } + } + + @GetExchange("/v1/si/accounts/{ncaId}/clusterGroups") + ClusterGroupsResponse getClusterGroups( + @PathVariable("ncaId") String ncaId, + @RequestParam("instanceTypeUsage") InstanceUsageTypeEnum instanceUsage); + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class DescribeInstancesResponse { + + List instances; + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class Instance { + + String instanceId; + Set instanceIps; + } + } + + @GetExchange("/v1/si?Action=DescribeInstances") + DescribeInstancesResponse describeInstances(@RequestParam("InstanceId") Set instanceIds); + + @Value + @Jacksonized + @Builder + class InstanceTypeDetails { + String name; + String value; + String description; + int cpuCores; + String systemMemory; + String gpuMemory; + int gpuCount = 1; + Set clusters; + Set regions; + Set attributes; + String gpuName; + Boolean defaultable; + String cpuArch; + String os; + String driverVersion; + String storage; + } + + @Value + @Jacksonized + @Builder + class ClusterResponse { + String clusterName; + String clusterGroupName; + String ncaId; + Set authorizedNCAIds; + String cloudProvider; + String region; + Set gpus; + String clusterId; + String clusterGroupId; + String status; + + @Value + @Jacksonized + @Builder + public static class GpuV4 { + String name; + int capacity; + Set instanceTypes; + } + } + + @GetExchange( + "/v1/si/accounts/{ncaId}/clusters?includeAuthorizedClusters=true&includeGfnInAuthorizedClusters=true") + List getClusters( + @PathVariable("ncaId") String ncaId, + @RequestParam("instanceTypeUsage") InstanceUsageTypeEnum instanceUsage); + + @Value + @Jacksonized + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + class Instances { + List Instances; + } + + @Value + @Jacksonized + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + class Instance { + Instant createTime; + String imageId; + String containerImage; + String instanceId; + String cloudProvider; + String instanceType; + Placement placement; + InstanceState state; + HealthInfo healthInfo; + String launchRequestId; + Set instanceIps; + String capacityType; + UUID deploymentId; + UUID gpuSpecificationId; + } + + @GetExchange("/v1/si/accounts/{ncaId}/deployments/{deploymentId}/instances") + Instances terminateInstancesByTaskId( + @PathVariable("ncaId") String ncaId, + @PathVariable("deploymentId") UUID taskId, + @RequestParam("IncludeTerminated") boolean includeTerminated, + @RequestParam("UseConciseName") boolean useConciseName, + @RequestParam("ExpiredAckedInstances") boolean expiredAckedInstances); +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/instance/InstanceService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/instance/InstanceService.java new file mode 100644 index 000000000..5c46cff36 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/instance/InstanceService.java @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.instance; + +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; + +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.InstanceDto; +import com.nvidia.nvct.rest.task.dto.InstanceStateEnum; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService.Instance; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.InstanceState; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class InstanceService { + + private static final String MESG_INSTANCE_BY_ACC_TASK_STATUS = + "{} instances for account '{}' and task '{}'"; + private static final String MESG_UNKNOWN_INSTANCE_STATE = + "ICMS Request Id '{}': Unknown InstanceState '{}'"; + private static final String MESG_NULL_INSTANCE_STATE = + "ICMS Request Id '{}': Null InstanceState"; + private static final String MESG_BLANK_INSTANCE_STATE_NAME = + "ICMS Request Id '{}': InstanceState name is blank"; + private static final String MESG_TERMINAL_STATUS_AND_INSTANCE_STATE = + "Task id '{}': Terminal status '{}' and Instance state '{}'"; + + private final IcmsClient icmsClient; + + public Optional> getInstances(TaskEntity taskEntity) { + return getInstancesByTaskId(taskEntity); + } + + public Optional> getInstancesByTaskId(TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + + log.debug(MESG_INSTANCE_BY_ACC_TASK_STATUS, "Retrieving", ncaId, taskId); + + var instances = icmsClient.getInstancesByTaskId(ncaId, taskId); + + log.debug(MESG_INSTANCE_BY_ACC_TASK_STATUS, "Retrieved", ncaId, taskId); + var instancesWithIds = instances.stream() + .filter(instance -> StringUtils.isNotBlank(instance.getInstanceId())) + .toList(); + if (instancesWithIds.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(instancesWithIds.stream() + .map(instance -> InstanceDto.builder() + .instanceId(instance.getInstanceId()) + .taskId(taskId) + .instanceType(instance.getInstanceType()) + .instanceState(getInstanceState(instance, taskEntity)) + .icmsRequestId(UUID.fromString(instance.getLaunchRequestId())) + .ncaId(ncaId) + .gpu(taskEntity.getGpuSpec().getGpu()) + .backend(instance.getCloudProvider()) + .location(instance.getPlacement().getAvailabilityZone()) + .instanceCreatedAt(instance.getCreateTime()) + // TODO right now ICMS instance object does not have updatedAt. At the + // legacy flow we populated instanceUpdatedAt in DTO with a value when + // request was updated last time, not instance. ICMS should provide actual + // value related to instance, not to request. + .instanceUpdatedAt(instance.getCreateTime()) + .build()) + .toList()); + } + + private InstanceStateEnum getInstanceState( + Instance instance, + TaskEntity taskEntity) { + return getInstanceState( + instance.getState(), instance.getLaunchRequestId(), taskEntity); + } + + private InstanceStateEnum getInstanceState( + InstanceState instanceState, + Object icmsRequestId, + TaskEntity taskEntity) { + InstanceStateEnum state; + + if (instanceState == null) { + log.info(MESG_NULL_INSTANCE_STATE, icmsRequestId); + state = null; + } else { + var instanceStateName = instanceState.getName(); + if (StringUtils.isBlank(instanceStateName)) { + log.info(MESG_BLANK_INSTANCE_STATE_NAME, icmsRequestId); + state = null; + } else { + state = switch (instanceStateName.toUpperCase()) { + case "STARTING" -> InstanceStateEnum.STARTING; + case "RUNNING" -> InstanceStateEnum.RUNNING; + case "TERMINATED" -> InstanceStateEnum.TERMINATED; + default -> { + log.info(MESG_UNKNOWN_INSTANCE_STATE, icmsRequestId, instanceStateName); + yield null; + } + }; + } + } + + var status = taskEntity.getStatus(); + if (state != null && TERMINAL_TASK_STATUSES.contains(status)) { + // Return null as instance state when the Task has reached terminal status to + // avoid confusion in the response. Log the actual instance state. + log.info(MESG_TERMINAL_STATUS_AND_INSTANCE_STATE, + taskEntity.getTaskId(), status, state); + state = null; + } + return state; + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskErrorMetricsService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskErrorMetricsService.java new file mode 100644 index 000000000..f8bdc9045 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskErrorMetricsService.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.metrics; + +import static com.nvidia.nvct.util.NvctConstants.METER_TASK_ERROR; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.TAG_ERROR_SOURCE; +import static com.nvidia.nvct.util.NvctConstants.TAG_NCA_ID; +import static java.time.Duration.ofMinutes; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.annotation.Observed; +import io.micrometer.tracing.Tracer; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class TaskErrorMetricsService { + + private static final String NVCT_API_ERROR_SOURCE = "nvct-api"; + + private final LoadingCache taskErrorCounters; + private final Tracer tracer; + + public TaskErrorMetricsService(MeterRegistry meterRegistry, Tracer tracer) { + this.tracer = tracer; + this.taskErrorCounters = Caffeine.newBuilder() + .expireAfter(new TaskMetricsExpirationPolicy(ofMinutes(45))) + .scheduler(Scheduler.systemScheduler()) + .evictionListener((key, counter, cause) -> { + if (counter instanceof Meter m) { + meterRegistry.remove(m); + } + }) + .build(ncaId -> Counter.builder(METER_TASK_ERROR) + .tag(TAG_NCA_ID, ncaId) + .tag(TAG_ERROR_SOURCE, NVCT_API_ERROR_SOURCE) + .register(meterRegistry)); + } + + // Invoked everytime a Task(belonging to the specified account) errors. + @Observed(name = "task.error", contextualName = "record-task-error") + public void recordTaskError(String ncaId) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + taskErrorCounters.get(ncaId).increment(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskMetricsExpirationPolicy.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskMetricsExpirationPolicy.java new file mode 100644 index 000000000..4b25d3223 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskMetricsExpirationPolicy.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.metrics; + +import com.github.benmanes.caffeine.cache.Expiry; +import io.micrometer.core.instrument.Meter; +import jakarta.validation.constraints.PositiveOrZero; +import java.time.Duration; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class TaskMetricsExpirationPolicy implements Expiry { + @NonNull + private final Duration timeToLive; + + @Override + public long expireAfterCreate(String key, Meter value, long currentTime) { + return timeToLive.toNanos(); + } + + @Override + public long expireAfterUpdate(String key, Meter value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, Meter value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskRunningMetricsService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskRunningMetricsService.java new file mode 100644 index 000000000..f910b5acd --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskRunningMetricsService.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.metrics; + +import static com.nvidia.nvct.util.NvctConstants.METER_TASK_RUNNING; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_ACCOUNT_NAME; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.TAG_ORG_NAME; +import static java.time.Duration.ofMinutes; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.annotation.Observed; +import io.micrometer.tracing.Tracer; +import jakarta.validation.constraints.PositiveOrZero; +import java.time.Duration; +import java.util.Map; +import lombok.Builder; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class TaskRunningMetricsService { + + private final TaskService taskService; + private final Tracer tracer; + + @Builder + public record TaskRunningKey(String ncaId, String accountName) { } + + private final Cache taskRunningValues = Caffeine.newBuilder() + .expireAfterAccess(ofMinutes(45)) + .scheduler(Scheduler.systemScheduler()) + .build(); + + private final LoadingCache taskRunningGauges; + + public TaskRunningMetricsService(MeterRegistry meterRegistry, TaskService taskService, Tracer tracer) { + this.taskService = taskService; + this.tracer = tracer; + this.taskRunningGauges = Caffeine.newBuilder() + .expireAfter(new TaskRunningExpirationPolicy(ofMinutes(45))) + .scheduler(Scheduler.systemScheduler()) + .evictionListener((key, gauge, cause) -> { + if (gauge instanceof Meter m) { + meterRegistry.remove(m); + } + }) + .build(key -> Gauge.builder(METER_TASK_RUNNING, + () -> taskRunningValues.getIfPresent(key)) + .tag(TAG_NCA_ID, key.ncaId()) + .tag(TAG_ORG_NAME, key.accountName()) + .register(meterRegistry)); + } + + /** + * Records count of tasks with RUNNING status for the specified account. + *

+ * Micrometer Gauge has async nature. On building the Gauge meter, we have to provide a + * lambda/supplier to return actual values to be recorded. We have a cache to store real + * values. The meter will get the value from the cache. We store the meter in another + * async loading cache. The meter is created on the first request to async loading cache. + * Therefore, we need to call meter cache on each invocation to make sure the metric is + * created/populated. + *

+ * @param ncaId NVIDIA Cloud Account id + * @param accountName Account name -- Can be NGC Org Name + */ + @Observed(name = "task.running", contextualName = "record-running-task") + public void recordRunningTask(String ncaId, String accountName) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId, + SPAN_TAG_ACCOUNT_NAME, accountName)); + var taskRunningKey = new TaskRunningKey(ncaId, accountName); + updateValuesCacheWithTaskCountByNcaId(taskRunningKey); + taskRunningGauges.get(taskRunningKey); + } + + private void updateValuesCacheWithTaskCountByNcaId(TaskRunningKey key) { + var count = taskRunningValues.getIfPresent(key); + if (count == null) { + // Get the count from the DB if the cache doesn't have it. + count = taskService.countByNcaId(key.ncaId()); + taskRunningValues.put(key, count); + return; + } + count++; + taskRunningValues.put(key, count); + } + + @RequiredArgsConstructor + private static class TaskRunningExpirationPolicy + implements Expiry { + + @NonNull + private final Duration timeToLive; + + @Override + public long expireAfterCreate( + TaskRunningKey key, Gauge value, long currentTime) { + return timeToLive.toNanos(); + } + + @Override + public long expireAfterUpdate( + TaskRunningKey key, Gauge value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead( + TaskRunningKey key, Gauge value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskSuccessMetricsService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskSuccessMetricsService.java new file mode 100644 index 000000000..01f4654d0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/metrics/TaskSuccessMetricsService.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.metrics; + +import static com.nvidia.nvct.util.NvctConstants.METER_TASK_SUCCESS; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.TAG_NCA_ID; +import static java.time.Duration.ofMinutes; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.annotation.Observed; +import io.micrometer.tracing.Tracer; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class TaskSuccessMetricsService { + + private final LoadingCache taskSuccessGauges; + private final Tracer tracer; + + public TaskSuccessMetricsService(MeterRegistry meterRegistry, Tracer tracer) { + this.tracer = tracer; + this.taskSuccessGauges = Caffeine.newBuilder() + .expireAfter(new TaskMetricsExpirationPolicy(ofMinutes(45))) + .scheduler(Scheduler.systemScheduler()) + .evictionListener((functionId, gauge, cause) -> { + if (gauge instanceof Meter m) { + meterRegistry.remove(m); + } + }) + .build(ncaId -> Counter.builder(METER_TASK_SUCCESS) + .tag(TAG_NCA_ID, ncaId) + .register(meterRegistry)); + } + + // Invoked everytime a Task(belonging to the specified account) generates a result/checkpoint + // and when completed. + @Observed(name = "task.success", contextualName = "record-task-success") + public void recordTaskSuccess(String ncaId) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, ncaId)); + taskSuccessGauges.get(ncaId).increment(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryClient.java new file mode 100644 index 000000000..a6d751351 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryClient.java @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ngc; + +import tools.jackson.databind.JsonNode; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.nvct.service.ngc.dto.CreateModelRequest; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Service +@RefreshScope +@Slf4j +public class NgcRegistryClient { + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MESG_INVALID_RESULTS_LOCATION = + "Invalid request: resultsLocation '%s' should be either in 'orgName/modelName' or " + + "'orgName/teamName/modelName' format"; + private static final int NGC_MODEL_NAME_MAX_LENGTH = 64; + private static final String MESG_MODEL_NAME_TOO_LONG = + "Invalid request: model name '%s' in resultsLocation exceeds the maximum allowed " + + "length of " + NGC_MODEL_NAME_MAX_LENGTH + " characters"; + private static final String MESG_CREATED_AND_DELETED_MODEL = + "Successfully created and deleted model '{}' using the NGC_API_KEY"; + private static final String MESG_REGISTRY_RESPONSE_STATUS = + "Cannot create results/checkpoints using the specified secret NGC_API_KEY in the " + + "org/team specified in the 'resultsLocation' - NGC Registry response " + + "status code '%d' "; + + public static final String CLIENT_REGISTRATION_ID = "ngc-registry"; + private static final String BEARER_TOKEN = "Bearer %s"; + + private final NgcRegistryStubService ngcRegistryStubService; + + private record ModelDetails(String orgName, String teamName, String modelName) { } + + public NgcRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + ManagedHttpResources ngcRegistryHttpResources, + @Value("${nvct.registries.recognized.model.ngc.hostname}") String hostname) { + var baseUrl = hostname.toLowerCase().startsWith("http") + ? hostname : "https://" + hostname; // For tests. + var webClient = webClientBuilder + .baseUrl(baseUrl.replace("-ngc", "")) + .clientConnector(ngcRegistryHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("NGC")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.ngcRegistryStubService = factory.createClient(NgcRegistryStubService.class); + } + + // Validate the NGC_API_KEY by creating the model and then deleting the model specified in + // the resultsLocation under the specified org / team. + public void validate(JsonNode ngcApiKey, String resultsLocation) { + var value = ngcApiKey.isString() ? ngcApiKey.asString() : ngcApiKey.toString(); + if (StringUtils.isBlank(value)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ngcApiKey"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + if (StringUtils.isBlank(resultsLocation)) { + var mesg = MESG_BLANK_PARAMETER.formatted("resultsLocation"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var modelDetails = getModelDetails(resultsLocation); + var payload = CreateModelRequest.builder() + .name(modelDetails.modelName()) + .displayName(modelDetails.modelName()) + .precision(StringUtils.EMPTY) + .framework(StringUtils.EMPTY) + .build(); + var bearerToken = BEARER_TOKEN.formatted(value); + if (StringUtils.isBlank(modelDetails.teamName())) { + ngcRegistryStubService.createOrgModel(bearerToken, + modelDetails.orgName(), + payload); + ngcRegistryStubService.deleteOrgModel(bearerToken, modelDetails.orgName(), + modelDetails.modelName()); + } else { + ngcRegistryStubService.createTeamModel(bearerToken, + modelDetails.orgName(), + modelDetails.teamName(), + payload); + ngcRegistryStubService.deleteTeamModel(bearerToken, modelDetails.orgName(), + modelDetails.teamName(), + modelDetails.modelName()); + } + log.info(MESG_CREATED_AND_DELETED_MODEL, modelDetails.modelName()); + } + + private static ModelDetails getModelDetails(String resultsLocation) { + int slashCount = StringUtils.countMatches(resultsLocation, '/'); + if ((slashCount == 0) || (slashCount > 2)) { + var mesg = MESG_INVALID_RESULTS_LOCATION.formatted(resultsLocation); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var orgName = resultsLocation.substring(0, resultsLocation.indexOf('/')); + String teamName = null; + var modelName = ""; + + if (slashCount == 1) { + modelName = resultsLocation.substring(orgName.length() + 1); + } else { + var index = resultsLocation.lastIndexOf('/'); + teamName = resultsLocation.substring(orgName.length() + 1, index); + modelName = resultsLocation.substring(index + 1); + } + + // Validate that model name doesn't exceed NGC's maximum limit + if (modelName.length() > NGC_MODEL_NAME_MAX_LENGTH) { + var mesg = MESG_MODEL_NAME_TOO_LONG.formatted(modelName); + log.error(mesg); + throw new BadRequestException(mesg); + } + + // Model name length can only be 64 characters. Since we are appending a full UUID, + // truncate the model name if needed to ensure the final name fits within the limit. + var uuid = UUID.randomUUID().toString(); + // Reserve space for dash and UUID + var maxModelNameLength = NGC_MODEL_NAME_MAX_LENGTH - uuid.length() - 1; + if (modelName.length() > maxModelNameLength) { + modelName = modelName.substring(0, maxModelNameLength); + } + + // Create a unique model name so that there is no conflict with existing ones when + // we try to create a new model to validate the NGC_API_KEY. This newly created model + // will be deleted immediately. + return new ModelDetails(orgName, teamName, modelName + "-" + uuid); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryStubService.java new file mode 100644 index 000000000..ca1d50652 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/NgcRegistryStubService.java @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ngc; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.nvct.service.ngc.dto.CreateModelRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.DeleteExchange; +import org.springframework.web.service.annotation.PostExchange; + +// Used to validate the NGC_API_KEY and the org/team names that are provided +// during Task creation so that we can fail fast. We don't care about the +// response from these endpoints. We just care about the response status code +// and the error message, if any. +// +// There is no interceptor involved in this case as NGC_API_KEY provided by +// the user during Task creation serves as the Bearer token. +public interface NgcRegistryStubService { + + @PostExchange(url = "/v2/org/{orgName}/models", contentType = APPLICATION_JSON_VALUE) + ResponseEntity createOrgModel( + @RequestHeader(name = AUTHORIZATION) String authorization, + @PathVariable String orgName, + @RequestBody CreateModelRequest payload); + + @PostExchange(url = "/v2/org/{orgName}/team/{teamName}/models", + contentType = APPLICATION_JSON_VALUE) + ResponseEntity createTeamModel( + @RequestHeader(name = AUTHORIZATION) String authorization, + @PathVariable String orgName, + @PathVariable String teamName, + @RequestBody CreateModelRequest payload); + + @DeleteExchange("/v2/org/{orgName}/models/{modelName}") + ResponseEntity deleteOrgModel( + @RequestHeader(name = AUTHORIZATION) String authorization, + @PathVariable String orgName, + @PathVariable String modelName); + + @DeleteExchange("/v2/org/{orgName}/team/{teamName}/models/{modelName}") + ResponseEntity deleteTeamModel( + @RequestHeader(name = AUTHORIZATION) String authorization, + @PathVariable String orgName, + @PathVariable String teamName, + @PathVariable String modelName); +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/dto/CreateModelRequest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/dto/CreateModelRequest.java new file mode 100644 index 000000000..1d3042983 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ngc/dto/CreateModelRequest.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ngc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Builder(toBuilder = true) +@Schema(description = "Request payload to create a model in NGC Model Registry") +public record CreateModelRequest( + @NonNull @NotBlank String name, + @NonNull @NotBlank String displayName, + @NonNull String precision, + @NonNull String framework) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfClient.java new file mode 100644 index 000000000..5d3ba3386 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfClient.java @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.nvcf; + +import static com.nvidia.nvct.util.NvctConstants.MAX_BUFFER_LIMIT; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientNvcfProperties; +import com.nvidia.nvct.service.account.dto.AccountDto; +import com.nvidia.nvct.service.client.dto.ClientDto; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.time.Duration; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Service +@RefreshScope +@Slf4j +public class NvcfClient { + + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + public static final String CLIENT_REGISTRATION_ID = "nvcf"; + + private final NvcfStubService nvcfStubService; + private final LoadingCache cachedNvcfAccounts; + private final LoadingCache cachedNvcfClients; + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public NvcfClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + ManagedHttpResources nvcfHttpResources, + @Value("${nvct.nvcf.base-url}") String baseUrl, + @Value("${spring.security.oauth2.client.registration.nvcf.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.nvcf.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.nvcf.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.nvcf.token-uri}") String tokenUri, + @Value("${nvct.nvcf.cache-ttl:PT15M}") Duration cacheTtl, + Optional staticClientNvcfProperties) { + this.cachedNvcfAccounts = Caffeine.newBuilder() + .maximumSize(512) + .scheduler(Scheduler.systemScheduler()) + .expireAfterWrite(cacheTtl) + .build(this::fetchAccount); + this.cachedNvcfClients = Caffeine.newBuilder() + .maximumSize(512) + .scheduler(Scheduler.systemScheduler()) + .expireAfterWrite(cacheTtl) + .build(this::fetchClient); + + var authFilter = oauthFilter(staticClientNvcfProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_BUFFER_LIMIT)) + .clientConnector(nvcfHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("NVCF")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.nvcfStubService = factory.createClient(NvcfStubService.class); + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientNvcfProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientNvcfProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + public AccountDto getAccount(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return cachedNvcfAccounts.get(ncaId); + } + + public ClientDto getClient(String clientId) { + if (StringUtils.isBlank(clientId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("clientId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return cachedNvcfClients.get(clientId); + } + + public void invalidateCache() { + cachedNvcfClients.invalidateAll(); + cachedNvcfAccounts.invalidateAll(); + } + + public void invalidateCacheForSpecificAccount(String ncaId) { + var account = cachedNvcfAccounts.get(ncaId); + if (account == null) { + return; + } + cachedNvcfAccounts.invalidate(ncaId); + } + + private AccountDto fetchAccount(String ncaId) { + var response = nvcfStubService.fetchAccount(ncaId); + if (response == null) { + throw new UpstreamException("No response from NVCF"); + } + var result = response.getAccount(); + if (result == null) { + throw new UnauthorizedException("Unknown NCA Id: " + ncaId); + } + return result; + } + + private ClientDto fetchClient(String clientId) { + var response = nvcfStubService.fetchClient(clientId); + if (response == null) { + throw new UpstreamException("No response from NVCF"); + } + var result = response.getClient(); + if (result == null) { + throw new UnauthorizedException("Unknown Client Id: " + clientId); + } + return result; + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfStubService.java new file mode 100644 index 000000000..b816b1be2 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/nvcf/NvcfStubService.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.nvcf; + +import com.nvidia.nvct.service.account.dto.AccountDto; +import com.nvidia.nvct.service.client.dto.ClientDto; +import lombok.Builder; +import lombok.extern.jackson.Jacksonized; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.service.annotation.GetExchange; + +public interface NvcfStubService { + + @GetExchange("/v2/nvcf/accounts/{ncaId}") + NvcfAccountResponse fetchAccount(@PathVariable String ncaId); + + @GetExchange("/v2/nvcf/clients/{clientId}") + NvcfClientResponse fetchClient(@PathVariable String clientId); + + @lombok.Value + @Jacksonized + @Builder + class NvcfAccountResponse { + AccountDto account; + } + + @lombok.Value + @Jacksonized + @Builder + class NvcfClientResponse { + ClientDto client; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/BucketRateLimiter.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/BucketRateLimiter.java new file mode 100644 index 000000000..a177a8210 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/BucketRateLimiter.java @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ratelimit; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +@Slf4j +public class BucketRateLimiter { + + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MSG_ACCOUNT_PREFIX = "Account '%s'"; + private static final String MSG_TASK_PREFIX = "Task id '%s'"; + private static final String MSG_TOO_MANY_REQUESTS = + "%s: Too many requests. Slow down."; + private static final String MSG_CONSUMED_TOKEN = + "{}: consumed-tokens '{}', remaining-tokens '{}'"; + private static final String MSG_NEW_BUCKET = + "{}: Creating a new bucket with bandwidth '{}' per second"; + private static final String MESG_CANNOT_BE_NULL = "Parameter '%s' cannot be null"; + + private final LoadingCache bucketMap; + + private record CacheKey(String id, long bandwidthPerSecond, String msgPrefix) { + + } + + BucketRateLimiter() { + bucketMap = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(10)) + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(BucketRateLimiter::newBucket); + } + + public void accountLimit(String ncaId, long allowedInvocations) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + verifyCall(ncaId, allowedInvocations, String.format(MSG_ACCOUNT_PREFIX, ncaId)); + } + + public void taskLimit(UUID taskId, long allowedInvocations) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + verifyCall(taskId.toString(), allowedInvocations, + String.format(MSG_TASK_PREFIX, taskId)); + } + + private void verifyCall(String id, long bandwidthPerSecond, String msgPrefix) { + final Bucket bucket = bucketMap.get(new CacheKey(id, bandwidthPerSecond, msgPrefix)); + final ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(1); + final boolean consumed = consumptionProbe.isConsumed(); + log.debug(MSG_CONSUMED_TOKEN, msgPrefix, consumed, consumptionProbe.getRemainingTokens()); + if (!consumed) { + var msg = String.format(MSG_TOO_MANY_REQUESTS, msgPrefix); + log.error(msg); + throw new TooManyRequestsException(msg); + } + } + + private static Bucket newBucket(CacheKey key) { + log.debug(MSG_NEW_BUCKET, key.msgPrefix, key.bandwidthPerSecond); + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(key.bandwidthPerSecond) + .refillGreedy(key.bandwidthPerSecond, Duration.ofSeconds(1)) + .build()) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/RateLimiterService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/RateLimiterService.java new file mode 100644 index 000000000..896f5388f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/ratelimit/RateLimiterService.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ratelimit; + +import com.nvidia.nvct.configuration.ratelimit.AccountRateLimiterProperties; +import com.nvidia.nvct.configuration.ratelimit.TaskRateLimiterProperties; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class RateLimiterService { + + private final AccountRateLimiterProperties accountProperties; + private final TaskRateLimiterProperties taskProperties; + private final BucketRateLimiter bucketRateLimiter; + + RateLimiterService(AccountRateLimiterProperties accountProperties, + TaskRateLimiterProperties taskProperties) { + this.accountProperties = accountProperties; + this.taskProperties = taskProperties; + bucketRateLimiter = new BucketRateLimiter(); + } + + public void verifyLimits(String ncaId, UUID taskId) { + verifyLimits(ncaId); + verifyLimits(taskId); + } + + public void verifyLimits(String ncaId) { + var defaultCappingProps = accountProperties.getDefaultRateCappingProperties(); + var rateCappingProps = accountProperties.getOverridesMap() + .getOrDefault(ncaId, defaultCappingProps); + var invocationsPerSecond = rateCappingProps.getAllowedInvocationsPerSecond(); + bucketRateLimiter.accountLimit(ncaId, invocationsPerSecond); + } + + public void verifyLimits(UUID taskId) { + var defaultCappingProps = taskProperties.getDefaultRateCappingProperties(); + var rateCappingProps = taskProperties.getTaskOverridesMap() + .getOrDefault(taskId, defaultCappingProps); + var invocationsPerSecond = rateCappingProps.getAllowedInvocationsPerSecond(); + bucketRateLimiter.taskLimit(taskId, invocationsPerSecond); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactMapperService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactMapperService.java new file mode 100644 index 000000000..3a7bad5a0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactMapperService.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.nvct.persistence.task.entity.ModelUdt; +import com.nvidia.nvct.persistence.task.entity.ResourceUdt; +import java.util.List; +import java.util.Set; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class RegistryArtifactMapperService { + + public static ArtifactDetails toArtifactDetailsFromResourceUdt(ResourceUdt artifactUdt) { + return new ArtifactDetails(artifactUdt.getName(), artifactUdt.getVersion(), + artifactUdt.getUrl()); + } + + public static List toArtifactDetailsFromResourceUdts( + Set resourceUdts) { + return resourceUdts.stream() + .map(RegistryArtifactMapperService::toArtifactDetailsFromResourceUdt) + .toList(); + } + + public static ArtifactDetails toArtifactDetailsFromModelUdt(ModelUdt modelUdt) { + return new ArtifactDetails(modelUdt.getName(), modelUdt.getVersion(), + modelUdt.getUrl()); + } + + public static List toArtifactDetailsFromModelUdt(Set modelUdts) { + return modelUdts.stream() + .map(RegistryArtifactMapperService::toArtifactDetailsFromModelUdt) + .toList(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactService.java new file mode 100644 index 000000000..f0566166b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactService.java @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.MODEL; +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.RESOURCE; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import com.nvidia.nvct.persistence.task.entity.ModelUdt; +import com.nvidia.nvct.persistence.task.entity.ResourceUdt; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.service.task.TaskService; +import java.time.Duration; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@AllArgsConstructor +public class RegistryArtifactService { + public static final Duration ICMS_MAX_REQUEST_DURATION = Duration.ofMinutes(32); + + private static final int CACHE_HANDLE_LENGTH = 32; + private static final String NO_ARTIFACT_TRIMMED_SHA256 = "e3b0c44298fc1c149afbf4c8996fb924"; + private static final List ARTIFACT_TYPES = List.of(MODEL, RESOURCE); + + private static final String MESG_COMMON_ERROR_PREFIX = + "Account '%s', Task '%s': "; + private static final String MESG_WRONG_IMPLEMENTATION_GET_ARTIFACTS_SIZE = + "Only resource or model registries support 'getArtifactSize' method. " + + "Input registry type is %s"; + private static final String MESG_WRONG_IMPLEMENTATION_FETCH_ARTIFACTS = + "Only resource or model registries support 'fetchArtifacts' method. " + + "Input registry type is %s"; + + private final RegistryCredentialService registryCredentialService; + private final ModelRegistryService modelRegistryService; + private final ResourceRegistryService resourceRegistryService; + private final RegistryArtifactValidationService registryArtifactValidationService; + private final TaskService taskService; + + public record ArtifactCacheKey(String ncaId, UUID taskId) { + } + + private final LoadingCache> artifactsPresignedUrlsCache = + Caffeine.newBuilder() + .maximumSize(512) + .scheduler(Scheduler.systemScheduler()) + .expireAfterWrite(ICMS_MAX_REQUEST_DURATION) + .build(this::fetchPresignedUrls); + + private final LoadingCache artifactsSizeCache = + Caffeine.newBuilder() + .maximumSize(512) + .scheduler(Scheduler.systemScheduler()) + .expireAfterWrite(ICMS_MAX_REQUEST_DURATION) + .build(this::fetchSize); + + private final LoadingCache artifactsCacheHandleCache = + Caffeine.newBuilder() + .maximumSize(512) + .scheduler(Scheduler.systemScheduler()) + .expireAfterWrite(ICMS_MAX_REQUEST_DURATION) + .build(this::generateCacheHandle); + + + public List getPresignedUrls(String ncaId, UUID taskId) { + return artifactsPresignedUrlsCache.get(new ArtifactCacheKey(ncaId, taskId)); + } + + public long getSize(String ncaId, UUID taskId) { + return artifactsSizeCache.get(new ArtifactCacheKey(ncaId, taskId)); + } + + public String getCacheHandle(String ncaId, UUID taskId) { + return artifactsCacheHandleCache.get(new ArtifactCacheKey(ncaId, taskId)); + } + + public void validateArtifacts(TaskEntity taskEntity) { + registryArtifactValidationService.validateArtifacts(taskEntity); + } + + @VisibleForTesting + public void invalidateCache() { + artifactsPresignedUrlsCache.invalidateAll(); + artifactsSizeCache.invalidateAll(); + artifactsCacheHandleCache.invalidateAll(); + } + + private String generateCacheHandle(ArtifactCacheKey cacheKey) { + var taskId = cacheKey.taskId; + Comparator modelUdtComparator = Comparator.comparing(ModelUdt::getUrl); + Comparator resourceUdtComparator = Comparator.comparing(ResourceUdt::getUrl); + var taskEntity = taskService.fetchTask(taskId); + var models = CollectionUtils.isEmpty(taskEntity.getModels()) + ? Set.of() : taskEntity.getModels(); + var resources = CollectionUtils.isEmpty(taskEntity.getResources()) + ? Set.of() : taskEntity.getResources(); + var hashes = Stream.concat(models.stream().sorted(modelUdtComparator), + resources.stream().sorted(resourceUdtComparator)) + .map(artifactUdt -> DigestUtils.sha256(artifactUdt.getUrl())) + .toList(); + if (hashes.isEmpty()) { + return NO_ARTIFACT_TRIMMED_SHA256; + } + var messageDigest = DigestUtils.getSha256Digest(); + for (var hash : hashes) { + messageDigest = DigestUtils.updateDigest(messageDigest, hash); + } + return DigestUtils.sha256Hex(messageDigest.digest()).substring(0, CACHE_HANDLE_LENGTH); + } + + private long getArtifactSize(TaskEntity taskEntity) { + return ARTIFACT_TYPES.stream() + .map(registryType -> fetchSize(taskEntity, registryType)) + .mapToLong(Long::longValue) + .sum(); + } + + private long fetchSize(ArtifactCacheKey cacheKey) { + var taskEntity = taskService.fetchTask(cacheKey.taskId); + return getArtifactSize(taskEntity); + } + + private long fetchSize(TaskEntity taskEntity, ArtifactTypeEnum registryType) { + var taskId = taskEntity.getTaskId(); + var ncaId = taskEntity.getNcaId(); + + try { + if (MODEL == registryType) { + if (CollectionUtils.isEmpty(taskEntity.getModels())) { + return 0L; + } + var secrets = + registryCredentialService.getModelRegistryCredentialsValue(taskEntity); + var models = RegistryArtifactMapperService.toArtifactDetailsFromModelUdt( + taskEntity.getModels()); + return modelRegistryService.fetchSize(models, secrets); + } else if (RESOURCE == registryType) { + if (CollectionUtils.isEmpty(taskEntity.getResources())) { + return 0L; + } + var secrets = + registryCredentialService.getResourceRegistryCredentialsValue(taskEntity); + var resources = RegistryArtifactMapperService.toArtifactDetailsFromResourceUdts( + taskEntity.getResources()); + return resourceRegistryService.fetchSize(resources, secrets); + } else { + var errMsg = + MESG_WRONG_IMPLEMENTATION_GET_ARTIFACTS_SIZE.formatted(registryType); + log.error(errMsg); + throw new IllegalStateException(errMsg); + } + } catch (IllegalStateException | IllegalArgumentException e) { + var enrichedErrorMsg = + MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId) + e.getMessage(); + log.error(enrichedErrorMsg); + throw new BadRequestException(enrichedErrorMsg, e); + } catch (Exception e) { + var enrichedErrorMsg = + MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId) + e.getMessage(); + log.error(enrichedErrorMsg); + throw e; + } + } + + private List fetchArtifacts(TaskEntity taskEntity) { + return ARTIFACT_TYPES.stream() + .flatMap(artifactType -> fetchArtifacts(taskEntity, artifactType).stream()) + .toList(); + } + + private List fetchArtifacts(TaskEntity taskEntity, ArtifactTypeEnum artifactType) { + var taskId = taskEntity.getTaskId(); + var ncaId = taskEntity.getNcaId(); + try { + if (MODEL == artifactType) { + if (CollectionUtils.isEmpty(taskEntity.getModels())) { + return Collections.emptyList(); + } + var secrets = + registryCredentialService.getModelRegistryCredentialsValue(taskEntity); + var models = RegistryArtifactMapperService.toArtifactDetailsFromModelUdt( + taskEntity.getModels()); + return modelRegistryService.fetchArtifact(models, secrets); + } else if (RESOURCE == artifactType) { + if (CollectionUtils.isEmpty(taskEntity.getResources())) { + return Collections.emptyList(); + } + var secrets = + registryCredentialService.getResourceRegistryCredentialsValue(taskEntity); + var resources = RegistryArtifactMapperService + .toArtifactDetailsFromResourceUdts(taskEntity.getResources()); + return resourceRegistryService.fetchArtifact(resources, secrets); + } else { + throw new IllegalStateException( + MESG_WRONG_IMPLEMENTATION_FETCH_ARTIFACTS.formatted(artifactType)); + } + } catch (IllegalStateException | IllegalArgumentException e) { + var enrichedErrorMsg = + MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId) + e.getMessage(); + log.error(enrichedErrorMsg); + throw new BadRequestException(enrichedErrorMsg, e); + } catch (Exception e) { + var enrichedErrorMsg = + MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId) + e.getMessage(); + log.error(enrichedErrorMsg); + throw e; + } + } + + private List fetchPresignedUrls(ArtifactCacheKey cacheKey) { + var taskEntity = taskService.fetchTask(cacheKey.taskId); + return fetchArtifacts(taskEntity); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationService.java new file mode 100644 index 000000000..c31ebdc62 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationService.java @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.CONTAINER; +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.HELM; +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.MODEL; +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.RESOURCE; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistryService; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistryService; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.service.account.dto.RegistryCredentialDto; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@RefreshScope +@Service +public class RegistryArtifactValidationService { + private static final String MESG_COMMON_ERROR_PREFIX = + "Account '%s', Task '%s': "; + private static final String MESG_WRONG_IMPLEMENTATION_VALIDATE_ARTIFACTS = + "Only container, helm, resource or model registry support 'validateArtifact'" + + " method. Input registry type is %s"; + private static final String MESG_ARTIFACT_INVALID = + "Task '%s': Invalid artifact provided"; + private static final String MESG_ARTIFACT_VALIDATION = + "Task '{}': {} artifacts"; + private static final String MESG_LOG_ARTIFACT_VALIDATION = + "Artifact validation is enabled but exception is not being thrown for " + + "invalid artifact so that Task creation can proceed"; + private static final String MESG_MISSING_REGISTRY_CREDENTIALS = + "Missing %s registry credential for hostname '%s'"; + + private final RegistryCredentialService registryCredentialService; + private final RegistryValidationService registryValidationService; + private final ModelRegistryService modelRegistryService; + private final ResourceRegistryService resourceRegistryService; + private final HelmRegistryService helmRegistryService; + private final ContainerRegistryService containerRegistryService; + private final String exceptionHandlingDuringArtifactValidation; + + public RegistryArtifactValidationService( + RegistryCredentialService registryCredentialService, + RegistryValidationService registryValidationService, + ModelRegistryService modelRegistryService, + ResourceRegistryService resourceRegistryService, + HelmRegistryService helmRegistryService, + ContainerRegistryService containerRegistryService, + @Value("${nvct.registries.artifact-validation.exception-handling:throw}") // throw, log + String exceptionHandlingDuringArtifactValidation) { + this.registryCredentialService = registryCredentialService; + this.registryValidationService = registryValidationService; + this.modelRegistryService = modelRegistryService; + this.resourceRegistryService = resourceRegistryService; + this.helmRegistryService = helmRegistryService; + this.containerRegistryService = containerRegistryService; + this.exceptionHandlingDuringArtifactValidation = exceptionHandlingDuringArtifactValidation; + } + + public void validateArtifacts(TaskEntity taskEntity) { + try { + log.info(MESG_ARTIFACT_VALIDATION, taskEntity.getTaskId(), "Validating"); + validateCredentialsExist(taskEntity); + validateArtifacts(taskEntity, EnumSet.of( + MODEL, + RESOURCE, + HELM, + CONTAINER)); + log.info(MESG_ARTIFACT_VALIDATION, taskEntity.getTaskId(), "Validated"); + } catch (BadRequestException | NotFoundException | UnauthorizedException | + ForbiddenException | TooManyRequestsException e) { + var taskId = taskEntity.getTaskId(); + var errMsg = MESG_ARTIFACT_INVALID.formatted(taskId); + log.error(errMsg, e); + if (exceptionHandlingDuringArtifactValidation.equals("throw")) { + throw e; + } else { + // Just log a warning and let the request continue so that function creation + // or deployment can proceed. + log.warn(MESG_LOG_ARTIFACT_VALIDATION); + } + } + } + + public void validateArtifacts(TaskEntity taskEntity, + Set artifactTypes) { + artifactTypes.forEach(artifactType -> validateArtifacts(taskEntity, artifactType)); + } + + private void validateArtifacts(TaskEntity taskEntity, ArtifactTypeEnum artifactType) { + var taskId = taskEntity.getTaskId(); + var ncaId = taskEntity.getNcaId(); + + try { + if (MODEL == artifactType) { + if (CollectionUtils.isEmpty(taskEntity.getModels())) { + return; + } + var secrets = registryCredentialService + .getModelRegistryCredentialsValue(taskEntity); + var modelArtifactDetails = RegistryArtifactMapperService + .toArtifactDetailsFromModelUdt(taskEntity.getModels()); + modelRegistryService.validateArtifacts(modelArtifactDetails, secrets); + } else if (RESOURCE == artifactType) { + if (CollectionUtils.isEmpty(taskEntity.getResources())) { + return; + } + var secrets = registryCredentialService + .getResourceRegistryCredentialsValue(taskEntity); + var resourceArtifactDetails = RegistryArtifactMapperService + .toArtifactDetailsFromResourceUdts(taskEntity.getResources()); + resourceRegistryService.validateArtifacts(resourceArtifactDetails, secrets); + } else if (HELM == artifactType) { + if (Strings.isBlank(taskEntity.getHelmChart())) { + return; + } + var secrets = registryCredentialService.getHelmRegistryCredentialsValue(taskEntity); + helmRegistryService.validateArtifact(taskEntity.getHelmChart(), secrets); + } else if (CONTAINER == artifactType) { + if (Strings.isBlank(taskEntity.getContainerImage())) { + return; + } + var secrets = registryCredentialService + .getContainerRegistryCredentialsValue(taskEntity); + containerRegistryService.validateArtifact(taskEntity.getContainerImage(), secrets); + } else { + var mesg = MESG_WRONG_IMPLEMENTATION_VALIDATE_ARTIFACTS.formatted(artifactType); + log.error(mesg); + throw new IllegalStateException(mesg); + } + } catch (IllegalStateException | IllegalArgumentException e) { + var commonPrefix = MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId); + var enrichedErrorMsg = + e.getMessage() != null && e.getMessage().startsWith(commonPrefix) ? + e.getMessage() : commonPrefix + e.getMessage(); + log.error(enrichedErrorMsg); + throw new BadRequestException(enrichedErrorMsg, e); + } catch (Exception e) { + var commonPrefix = MESG_COMMON_ERROR_PREFIX.formatted(ncaId, taskId); + var enrichedErrorMsg = + e.getMessage() != null && e.getMessage().startsWith(commonPrefix) ? + e.getMessage() : commonPrefix + e.getMessage(); + log.error(enrichedErrorMsg); + throw e; + } + } + + public void validateContainerRegistryCredentialsExist(TaskEntity task) { + if (Strings.isBlank(task.getContainerImage())) return; + var creds = registryCredentialService.getContainerRegistryCredentials(task); + var hostname = registryCredentialService.getRegistryHostname(task.getContainerImage()); + validateCredentialsExist(CONTAINER, hostname, creds); + } + + public void validateHelmRegistryCredentialsExist(TaskEntity task) { + if (Strings.isBlank(task.getHelmChart())) return; + var creds = registryCredentialService.getHelmRegistryCredentials(task); + var hostname = registryCredentialService.getHelmRegistryHostname(task.getHelmChart()); + validateCredentialsExist(HELM, hostname, creds); + } + + private void validateCredentialsExist( + ArtifactTypeEnum artifactType, + String hostname, + List credentials) { + if (!registryValidationService.isArtifactValidationEnabled(artifactType, hostname)) { + return; + } + + // Gets here if artifact-validation is enabled. If there are registry creds for the + // hostname, then we don't have to throw IllegalStateException with missing registry + // credential message. + if (!CollectionUtils.isEmpty(credentials)) { + return; + } + var mesg = MESG_MISSING_REGISTRY_CREDENTIALS.formatted(artifactType, hostname); + log.error(mesg); + throw new IllegalStateException(mesg); + } + + private void validateCredentialsExist(TaskEntity taskEntity) { + try { + validateContainerRegistryCredentialsExist(taskEntity); + validateHelmRegistryCredentialsExist(taskEntity); + registryCredentialService.getModelRegistryCredentials(taskEntity); + registryCredentialService.getResourceRegistryCredentials(taskEntity); + } catch (Exception e) { + if (e instanceof BadRequestException badrequestexception) throw badrequestexception; + log.error(e.getMessage(), e); + throw new BadRequestException(e.getMessage(), e); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryCredentialService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryCredentialService.java new file mode 100644 index 000000000..a1e485cca --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryCredentialService.java @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.CONTAINER; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import com.google.common.collect.Sets; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.account.dto.RegistryCredentialDto; +import com.nvidia.nvct.service.registry.dto.DockerConfigJsonAuthDto; +import com.nvidia.nvct.service.registry.dto.DockerConfigJsonDto; +import com.nvidia.nvct.service.registry.dto.K8sSecretsDto; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Service +@Slf4j +public class RegistryCredentialService { + private static final String MESG_COMMON_ERROR_PREFIX = + "Account '%s', Task '%s': "; + private static final String MESG_MISSING_REGISTRY = + MESG_COMMON_ERROR_PREFIX + "Missing %s registry for hostname '%s'"; + private static final String MESG_FAIL_TO_ENCODE_SECRETS = + MESG_COMMON_ERROR_PREFIX + "Failed to encode secrets"; + private static final String MESG_INVALID_REGISTRY_URL = "Invalid registry URL: %s"; + + private final AccountService accountService; + private final RegistryMapperService registryMapperService; + private final RegistryTaskMapperService registryTaskMapperService; + private final JsonMapper jsonMapper; + private final String sidecarImagePullSecret; + private final String sidecarRegistryHostname; + + public RegistryCredentialService( + AccountService accountService, + RegistryMapperService registryMapperService, + RegistryTaskMapperService registryTaskMapperService, + JsonMapper jsonMapper, + @Value("${nvct.sidecars.image-pull-secret}") + String sidecarImagePullSecret, + @Value("${nvct.sidecars.hostname}") + String sidecarRegistryHostname) { + this.accountService = accountService; + this.registryTaskMapperService = registryTaskMapperService; + this.registryMapperService = registryMapperService; + this.jsonMapper = jsonMapper; + this.sidecarImagePullSecret = sidecarImagePullSecret; + this.sidecarRegistryHostname = sidecarRegistryHostname; + } + + public String getBase64EncodedSidecarRegistryImagePullSecret(TaskEntity taskEntity) { + var dockerConfigJsonDto = DockerConfigJsonDto.builder() + .auths(Map.of( + this.sidecarRegistryHostname, + DockerConfigJsonAuthDto.builder() + .auth(this.sidecarImagePullSecret) + .build() + )) + .build(); + return base64Encode(taskEntity, dockerConfigJsonDto); + } + + public List getRegistryCredentials(TaskEntity task, + String hostname, + ArtifactTypeEnum artifactTypeEnum) { + var account = accountService.getAccount(task.getNcaId()); + if (CollectionUtils.isEmpty(account.registryCredentials())) { + return Collections.emptyList(); + } + var normalizedHostname = registryMapperService.toNormalizedHostname(hostname); + return account.registryCredentials().stream() + .filter(rc -> rc.artifactTypes().contains(artifactTypeEnum) && + rc.registryHostname().equals(normalizedHostname)) + .toList(); + } + + private List getRegistryCredentials( + TaskEntity task, + Set artifactTypeEnums) { + var account = accountService.getAccount(task.getNcaId()); + if (CollectionUtils.isEmpty(account.registryCredentials())) { + return Collections.emptyList(); + } + return account.registryCredentials().stream() + .filter(regCred -> !Sets + .intersection(regCred.artifactTypes(), artifactTypeEnums) + .isEmpty()) + .toList(); + } + + public String getRegistryHostname(String artifactUrl) { + // WAR for using URI lib to parse url host, since image url won't include protocol prefix. + var normalizedArtifactUrl = + !artifactUrl.startsWith("http") ? "https://" + artifactUrl : artifactUrl; + return Optional.ofNullable(URI.create(normalizedArtifactUrl).getHost()) + .orElseThrow(() -> new IllegalStateException( + MESG_INVALID_REGISTRY_URL.formatted(artifactUrl))); + } + + public String getHelmRegistryHostname(String artifactUrl) { + var uri = URI.create(artifactUrl); + var scheme = uri.getScheme(); + + // Validate scheme - only allow http, https, oci + if (scheme == null || + !scheme.equals("http") && !scheme.equals("https") && !scheme.equals("oci")) { + throw new IllegalStateException( + MESG_INVALID_REGISTRY_URL.formatted(artifactUrl)); + } + + return Optional.ofNullable(uri.getHost()) + .orElseThrow(() -> new IllegalStateException( + MESG_INVALID_REGISTRY_URL.formatted(artifactUrl))); + } + + @SneakyThrows + public List getModelRegistriesHostnames(TaskEntity task) { + var models = task.getModels(); + if (CollectionUtils.isEmpty(models)) { + return Collections.emptyList(); + } + + return models.stream() + .map(model -> getRegistryHostname(model.getUrl())) + .toList(); + } + + @SneakyThrows + public List getResourceRegistriesHostnames(TaskEntity task) { + var resources = task.getResources(); + if (CollectionUtils.isEmpty(resources)) { + return Collections.emptyList(); + } + + return resources.stream() + .map(resource -> getRegistryHostname(resource.getUrl())) + .toList(); + } + + private K8sSecretsDto getRegistryImagePullSecrets( + List registryCredentials) { + K8sSecretsDto k8SSecretsDto = K8sSecretsDto.builder().k8sSecrets(new ArrayList<>()).build(); + registryCredentials.forEach(registry -> { + var hostname = registry.registryHostname(); + var secret = registry.secret().value().asString(); + var dockerConfigJsonRegistryCredentialDto = DockerConfigJsonAuthDto + .builder() + .auth(secret) + .build(); + k8SSecretsDto.k8sSecrets().add( + DockerConfigJsonDto.builder().auths( + Map.of(hostname, dockerConfigJsonRegistryCredentialDto)).build()); + }); + return k8SSecretsDto; + } + + public List getContainerRegistryCredentials(TaskEntity task) { + if (Strings.isNotBlank(task.getContainerImage())) { + var containerRegistryHostname = getRegistryHostname(task.getContainerImage()); + var registryCredentials = + getRegistryCredentials(task, containerRegistryHostname, CONTAINER); + // For Container-based task. Return container registry credentials whose hostname + // matches the hostname specified in the task definition. + return registryCredentials.stream() + .map(dto -> { + if (RegistryMapperService.isCanaryHostname(containerRegistryHostname)) { + return registryTaskMapperService + .toRegistryCredentialDtoWithCanaryHostname(dto); + } + return dto; + }) + .toList(); + } + // Helm-based task. Return all the container registry credentials associated with the + // account and duplicated NGC registry with canary alias hostname, since we don't know + // which registry hostname is in use by the helm chart. + return getRegistryCredentials(task, Set.of(CONTAINER)).stream() + .flatMap(registryTaskMapperService::expandRegistryCredentialWithCanaryHostname) + .toList(); + } + + public List getContainerRegistryCredentialsValue(TaskEntity taskEntity) { + return getContainerRegistryCredentials(taskEntity) + .stream().map(x -> x.secret().value().asString()) + .toList(); + } + + public K8sSecretsDto getContainerRegistryImagePullSecrets(TaskEntity task) { + var containerRegistryCredentials = getContainerRegistryCredentials(task); + return getRegistryImagePullSecrets(containerRegistryCredentials); + } + + public List getHelmRegistryCredentials(TaskEntity task) { + + if (Strings.isNotBlank(task.getHelmChart())) { + var helmRegistryHostname = getHelmRegistryHostname(task.getHelmChart()); + return getRegistryCredentials(task, helmRegistryHostname, + ArtifactTypeEnum.HELM).stream() + .map(dto -> { + if (RegistryMapperService.isCanaryHostname(helmRegistryHostname)) { + return registryTaskMapperService + .toRegistryCredentialDtoWithCanaryHostname(dto); + } + return dto; + }) + .toList(); + } + // Container-based function. + return Collections.emptyList(); + } + + public List getHelmRegistryCredentialsValue(TaskEntity taskEntity) { + return getHelmRegistryCredentials(taskEntity) + .stream().map(x -> x.secret().value().asString()) + .toList(); + } + + public K8sSecretsDto getHelmRegistryImagePullSecrets(TaskEntity task) { + var helmRegistryCredentials = getHelmRegistryCredentials(task); + return getRegistryImagePullSecrets(helmRegistryCredentials); + } + + public Map> getModelRegistryCredentials(TaskEntity task) { + var ncaId = task.getNcaId(); + var taskId = task.getTaskId(); + var normalizedModelRegistriesHostnames = getModelRegistriesHostnames(task); + if (normalizedModelRegistriesHostnames.isEmpty()) { + return Collections.emptyMap(); + } + var modelRegistryCredentials = normalizedModelRegistriesHostnames.stream() + .map(hostname -> { + var registryCredentials = + getRegistryCredentials(task, hostname, ArtifactTypeEnum.MODEL); + + if (CollectionUtils.isEmpty(registryCredentials)) { + var mesg = MESG_MISSING_REGISTRY.formatted(ncaId, taskId, + ArtifactTypeEnum.MODEL, + hostname); + log.error(mesg); + throw new IllegalStateException(mesg); + } + return registryCredentials; + }) + .flatMap(Collection::stream) + .toList(); + return modelRegistryCredentials.stream() + .flatMap(registryTaskMapperService::expandRegistryCredentialWithCanaryHostname) + .collect(Collectors.groupingBy(RegistryCredentialDto::registryHostname)); + } + + public Map> getModelRegistryCredentialsValue(TaskEntity taskEntity) { + return getModelRegistryCredentials(taskEntity).entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + x -> x.getValue().stream() + .map(registryCredentialDto -> + registryCredentialDto + .secret().value().asString()) + .toList())); + } + + public Map> getResourceRegistryCredentials( + TaskEntity task) { + var ncaId = task.getNcaId(); + var taskId = task.getTaskId(); + var normalizedResourceRegistriesHostnames = getResourceRegistriesHostnames(task); + if (normalizedResourceRegistriesHostnames.isEmpty()) { + return Collections.emptyMap(); + } + var resourceRegistryCredentials = normalizedResourceRegistriesHostnames.stream() + .map(hostname -> { + var registryCredentials = + getRegistryCredentials(task, hostname, ArtifactTypeEnum.RESOURCE); + + if (CollectionUtils.isEmpty(registryCredentials)) { + var mesg = MESG_MISSING_REGISTRY.formatted(ncaId, taskId, + ArtifactTypeEnum.RESOURCE, + hostname); + log.error(mesg); + throw new IllegalStateException(mesg); + } + return registryCredentials; + }) + .flatMap(Collection::stream) + .toList(); + return resourceRegistryCredentials.stream() + .flatMap(registryTaskMapperService::expandRegistryCredentialWithCanaryHostname) + .collect(Collectors.groupingBy(RegistryCredentialDto::registryHostname)); + } + + public Map> getResourceRegistryCredentialsValue( + TaskEntity taskEntity) { + return getResourceRegistryCredentials(taskEntity).entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + x -> x.getValue().stream() + .map(registryCredentialDto -> + registryCredentialDto + .secret().value().asString()) + .toList())); + } + + public String getBase64EncodedContainerRegistryImagePullSecrets(TaskEntity task) { + var k8SSecretsDto = getContainerRegistryImagePullSecrets(task); + return base64Encode(task, k8SSecretsDto); + } + + public String getBase64EncodedHelmRegistryImagePullSecrets(TaskEntity task) { + var k8SSecretsDto = getHelmRegistryImagePullSecrets(task); + return base64Encode(task, k8SSecretsDto); + } + + private String base64Encode(TaskEntity task, Object secrets) { + var taskId = task.getTaskId(); + var ncaId = task.getNcaId(); + + try { + var jsonString = jsonMapper.writeValueAsString(secrets); + return java.util.Base64.getEncoder().encodeToString(jsonString.getBytes()); + } catch (JacksonException e) { + var msg = MESG_FAIL_TO_ENCODE_SECRETS.formatted(ncaId, taskId); + log.error(msg); + throw new IllegalArgumentException(msg); + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryTaskMapperService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryTaskMapperService.java new file mode 100644 index 000000000..cf1f52b04 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/RegistryTaskMapperService.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.nvct.service.account.dto.RegistryCredentialDto; +import java.util.stream.Stream; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class RegistryTaskMapperService { + private final RegistryMapperService registryMapperService; + + public Stream expandRegistryCredentialWithCanaryHostname( + RegistryCredentialDto registryCredentialDto) { + var originalHostname = registryCredentialDto.registryHostname(); + var canaryHostname = registryMapperService.toCanaryHostname(originalHostname); + + if (canaryHostname.equals(originalHostname)) { + return Stream.of(registryCredentialDto); + } + return Stream.of( + registryCredentialDto, + toRegistryCredentialDtoWithCanaryHostname(registryCredentialDto)); + } + + public RegistryCredentialDto toRegistryCredentialDtoWithCanaryHostname( + RegistryCredentialDto registryDetailsDto) { + return registryDetailsDto.toBuilder() + .registryHostname(registryMapperService + .toCanaryHostname(registryDetailsDto.registryHostname())) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonAuthDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonAuthDto.java new file mode 100644 index 000000000..9e84c5d09 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonAuthDto.java @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +@Schema(description = "dockerconfigjson registry auth object") +public record DockerConfigJsonAuthDto( + @Schema() + @NotBlank String auth) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonDto.java new file mode 100644 index 000000000..fa0154817 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/DockerConfigJsonDto.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import lombok.Builder; + +@Builder +@Schema(description = "Object of dockerconfigjson") +public record DockerConfigJsonDto( + @Schema(description = "K8s secret in dockerconfigjson format") + @NotNull Map auths) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/K8sSecretsDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/K8sSecretsDto.java new file mode 100644 index 000000000..440ea4e2e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/registry/dto/K8sSecretsDto.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "Objects to create image-pull secrets using registry credentials") +public record K8sSecretsDto( + @Schema(description = "K8s secrets") + @NotNull List k8sSecrets) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/result/ResultService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/result/ResultService.java new file mode 100644 index 000000000..19fc6922d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/result/ResultService.java @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.result; + +import static com.datastax.oss.driver.api.core.data.ByteUtils.fromHexString; +import static com.datastax.oss.driver.api.core.data.ByteUtils.toHexString; +import static com.nvidia.nvct.util.NvctConstants.DEFAULT_PAGINATION_LIMIT; +import static com.nvidia.nvct.util.NvctConstants.MESG_INVALID_CURSOR; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.persistence.result.ResultsByTaskRepository; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskKey; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.result.dto.ResultDto; +import com.nvidia.nvct.service.task.TaskService; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Predicate; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ResultService { + private static final String MESG_CANNOT_BE_NULL = "Parameter '%s' cannot be null"; + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MESG_TASK_RESULT_OPERATION = + "Task id '{}': {}"; + private static final String MESG_TASK_RESULT_IGNORED = + "Task id '{}': Result ignored as resultHandlingStrategy is NONE"; + private static final String MESG_FORBIDDEN_TO_LIST_RESULTS = + "Task '%s': Forbidden to list results"; + + private final ResultsByTaskRepository resultsByTaskRepository; + private final TaskService taskService; + private final JsonMapper jsonMapper; + + @Builder + public record ResultsSliceContext(List results, String cursor, Integer limit) { } + + public ResultsSliceContext fetchResults(String ncaId, UUID taskId, int limit, String cursor, + Predicate taskAccessMatch) { + log.debug(MESG_TASK_RESULT_OPERATION, taskId, "Fetching results"); + var taskEntity = taskService.validateAccount(ncaId, taskId); + if (!taskAccessMatch.test(taskEntity)) { + var mesg = MESG_FORBIDDEN_TO_LIST_RESULTS.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + + Slice pagedResult; + try { + var byteBuffer = cursor == null ? null : fromHexString(cursor); + var pageable = PageRequest.of(0, limit); + var pageRequest = CassandraPageRequest.of(pageable, byteBuffer); + pagedResult = resultsByTaskRepository.findByKeyTaskId(taskId, pageRequest); + } catch (Exception e) { + var mesg = MESG_INVALID_CURSOR.formatted(cursor); + log.error(mesg); + throw new BadRequestException(mesg, e); + } + + var dtos = pagedResult.getContent().stream() + .map(this::toResultDto) + .toList(); + var builder = ResultsSliceContext.builder().results(dtos); + if (pagedResult.hasNext()) { + var pagingState = ((CassandraPageRequest) pagedResult.getPageable()).getPagingState(); + builder.cursor(toHexString(pagingState)); + builder.limit(limit); + } + + log.debug(MESG_TASK_RESULT_OPERATION, taskId, "Fetched results"); + return builder.build(); + } + + @VisibleForTesting + public List fetchResults(String ncaId, UUID taskId) { + var results = fetchResults(ncaId, taskId, Integer.parseInt(DEFAULT_PAGINATION_LIMIT), null, + taskEntity -> true); + return results.results(); + } + + public boolean deleteResults(String ncaId, UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // If the Task was deleted but for some reason results were not deleted, then don't + // throw an exception and continue deleting results. + taskService.lookupTask(taskId).ifPresent(task -> taskService.validateAccount(ncaId, task)); + resultsByTaskRepository.deleteByKeyTaskId(taskId); + log.info(MESG_TASK_RESULT_OPERATION, taskId, "Deleted results"); + return true; + } + + public ResultByTaskEntity insertResult( + TaskEntity taskEntity, + String name, + String metadata) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + taskService.validateAccount(ncaId, taskId); + if (taskEntity.getResultHandlingStrategy() == ResultHandlingStrategy.NONE) { + // If resultHandlingStrategy is NONE, we will ignore calls to insert an entry + // in the results_by_task table. This can happen when the Task Container + // updates progress file with just percentComplete set to 100 to indicate + // a graceful exit so that Utils Container can mark the Task as COMPLETED. + log.debug(MESG_TASK_RESULT_IGNORED, taskId); + return null; + } + + if (StringUtils.isBlank(name)) { + var mesg = MESG_BLANK_PARAMETER.formatted("name"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + if (StringUtils.isBlank(metadata)) { + var mesg = MESG_BLANK_PARAMETER.formatted("metadata"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + var resultEntity = ResultByTaskEntity.builder() + .key(ResultByTaskKey.builder().taskId(taskId).resultId(UUID.randomUUID()).build()) + .createdAt(Instant.now()) + .ncaId(ncaId) + .name(name) + .metadata(metadata) + .build(); + resultEntity = resultsByTaskRepository.save(resultEntity); + log.info(MESG_TASK_RESULT_OPERATION, taskId, "Inserted result"); + return resultEntity; + } + + // Used by cleanup subroutine that executes periodically. There is no validation performed + // to match the NCA Id as there could be scenario where the Task entry is deleted but the + // results were not deleted. If the validation kicks in, then it will result in + // NotFoundException and we end up with partially cleaned Task. + public void cleanResults(UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + resultsByTaskRepository.deleteByKeyTaskId(taskId); + } + + @SneakyThrows + private ResultDto toResultDto(ResultByTaskEntity entity) { + return ResultDto.builder() + .resultId(entity.getKey().getResultId()) + .taskId(entity.getKey().getTaskId()) + .ncaId(entity.getNcaId()) + .name(entity.getName()) + .metadata(jsonMapper.readValue(entity.getMetadata(), ObjectNode.class)) + .createdAt(entity.getCreatedAt()) + .build(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalClient.java new file mode 100644 index 000000000..3d3d3895a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalClient.java @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.reval; + +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientRevalProperties; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.service.registry.RegistryArtifactValidationService; +import com.nvidia.nvct.service.registry.RegistryCredentialService; +import com.nvidia.nvct.service.registry.dto.K8sSecretsDto; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import tools.jackson.databind.json.JsonMapper; + + +@Slf4j +@Service +@RefreshScope +public class RevalClient { + private static final String MESG_REVAL_ERROR = + "Internal error during validation. Reval Status %s"; + private static final String MESG_REVAL_INTERNAL_ERRORS = "ReVal internal errors: {}"; + private static final String MESG_HELM_CHART_VALIDATION_ERRORS = + "Helm chart validation error(s) from NVCF ReVal service: %s"; + private static final String MESG_UNKNOWN_VALIDATION_ERROR = + "Helm chart validation failure. Unknown error."; + public static final String CLIENT_REGISTRATION_ID = "reval"; + + private final RevalStubService revalStubService; + private final boolean enabled; + private final RegistryArtifactValidationService registryArtifactValidationService; + private final RegistryCredentialService registryCredentialService; + private final JsonMapper jsonMapper; + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public RevalClient( + @Value("${nvct.reval.base-url}") String baseUrl, + @Value("${nvct.reval.enabled:true}") boolean enabled, + @Value("${spring.security.oauth2.client.registration.reval.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.reval.client-secret}") + String clientSecret, + @Value("${spring.security.oauth2.client.registration.reval.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.reval.token-uri}") String tokenUri, + Optional staticClientRevalProperties, + RegistryArtifactValidationService registryArtifactValidationService, + RegistryCredentialService registryCredentialService, + ManagedHttpResources revalHttpResources, + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + JsonMapper jsonMapper) { + var authFilter = oauthFilter(staticClientRevalProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .clientConnector(revalHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.revalStubService = factory.createClient(RevalStubService.class); + + this.enabled = enabled; + this.registryArtifactValidationService = registryArtifactValidationService; + this.registryCredentialService = registryCredentialService; + this.jsonMapper = jsonMapper; + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientRevalProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientRevalProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + public String validate( + String ncaId, + TaskEntity task, + GpuSpecificationDto gpuSpec) { + if (!enabled) { + return StringUtils.EMPTY; + } + var imageRegistryAuthConfig = validateAndGetContainerRegistryImagePullSecrets(task); + var helmRegistryAuthConfig = validateAndGetHelmRegistryImagePullSecrets(task); + + var request = RevalStubService.RevalValidateRequest + .builder() + .helmChart(task.getHelmChart()) + .instanceType(gpuSpec.instanceType()) + .gpu(gpuSpec.gpu()) + .configuration(gpuSpec.configuration()) + .imageRegistryAuthConfig(jsonMapper.valueToTree(imageRegistryAuthConfig)) + .helmRegistryAuthConfig(jsonMapper.valueToTree(helmRegistryAuthConfig)) + .build(); + var response = this.revalStubService.validate(request); + if (response.getStatusCode().is2xxSuccessful() && response.hasBody()) { + var body = response.getBody(); + assert body != null; // Shuts up Sonar as it doesn't recognize hasBody() check above. + + if (!CollectionUtils.isEmpty(body.getInternalErrors())) { + var internalErrors = String.join(", ", body.getInternalErrors()); + log.warn(MESG_REVAL_INTERNAL_ERRORS, internalErrors); + } + + if (body.isValid()) { + return StringUtils.EMPTY; + } + + String errors; + if (CollectionUtils.isEmpty(body.getValidationErrors())) { + errors = MESG_UNKNOWN_VALIDATION_ERROR; + } else { + errors = String.join(", ", body.getValidationErrors()); + } + + var msg = String.format(MESG_HELM_CHART_VALIDATION_ERRORS, errors); + log.error(msg); + return msg; + } + + var msg = String.format(MESG_REVAL_ERROR, response.getStatusCode()); + log.error(msg); + + if (response.getStatusCode().is4xxClientError()) { + // 4xx from Reval means that NVCT API is doing something wrong when invoking it. + // That's why we throw IllegalStateException. + throw new IllegalStateException(msg); + } + + throw new UpstreamException(msg); + } + + private K8sSecretsDto validateAndGetContainerRegistryImagePullSecrets(TaskEntity task) { + registryArtifactValidationService.validateContainerRegistryCredentialsExist(task); + return registryCredentialService.getContainerRegistryImagePullSecrets(task); + } + + private K8sSecretsDto validateAndGetHelmRegistryImagePullSecrets(TaskEntity task) { + registryArtifactValidationService.validateHelmRegistryCredentialsExist(task); + return registryCredentialService.getHelmRegistryImagePullSecrets(task); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalStubService.java new file mode 100644 index 000000000..67a7cee23 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/reval/RevalStubService.java @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.reval; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.node.ObjectNode; +import java.util.List; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.PostExchange; + +public interface RevalStubService { + + @Value + @Jacksonized + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + class RevalValidateRequest { + String helmChart; + + String instanceType; + + String gpu; + + ObjectNode configuration; + + ObjectNode imageRegistryAuthConfig; + + ObjectNode helmRegistryAuthConfig; + } + + @Value + @Jacksonized + @Builder + class RevalValidateResponse { + boolean valid; + + List validationErrors; + + List internalErrors; + } + + @PostExchange("/v1/validate") + ResponseEntity validate(@RequestBody RevalValidateRequest request); +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutine.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutine.java new file mode 100644 index 000000000..8155c7a5f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutine.java @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.util.NvctConstants.BATCH_SIZE; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.icms.IcmsService; +import com.nvidia.nvct.service.task.TaskService; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RefreshScope +@ConditionalOnProperty(name = "nvct.scheduled-routines.enabled", + havingValue = "true", + matchIfMissing = true) +public class CleanTerminalTasksRoutine { + + private static final String MESG_TASK_CLEANING = + "Task id '{}', status '{}', last heartbeat at '{}': Cleaning related events, result" + + "metadata, ICMS requests, secrets, and definition"; + private static final String MESG_TASK_CLEANED = + "Task id '{}', status '{}', last heartbeat '{}': Completed cleaning"; + private static final String MESG_ERROR_PROCESSING_TASK_SLICE = + "Error processing Task slice: {}"; + private static final String MESG_FINISHED_ROUTINE = + "Finished CleanTerminalTasksRoutine in {} ms. Processed {} Tasks"; + private static final String MESG_STARTED_ROUTINE = + "Started CleanTerminalTasksRoutine"; + private static final String MESG_UNEXPECTED_EXCEPTION = + "Unexpected exception: {}"; + + private static final String CONFIG_TERMINAL_TASK_RETENTION_DURATION = + "nvct.scheduled-routines.clean-terminal-tasks-routine.retention-duration"; + private static final String CONFIG_ROUTINE_ENABLED = + "nvct.scheduled-routines.clean-terminal-tasks-routine.enabled"; + + private final IcmsService icmsService; + private final EventService eventService; + private final ResultService resultService; + private final TaskService taskService; + private final EssService essService; + private final Duration retentionDuration; + private final TasksRepository tasksRepository; + + // The setter can be used to enable/disable the routine during testing without + // restarting the context and incurring delays. + @VisibleForTesting + @Setter + private volatile boolean enabled; + + public CleanTerminalTasksRoutine( + EssService essService, + IcmsService icmsService, + EventService eventService, + ResultService resultService, + TaskService taskService, + TasksRepository tasksRepository, + @Value("${" + CONFIG_TERMINAL_TASK_RETENTION_DURATION + ":P7D}") + Duration retentionDuration, + @Value("${" + CONFIG_ROUTINE_ENABLED + ":true}") + boolean enabled) { + this.essService = essService; + this.icmsService = icmsService; + this.eventService = eventService; + this.resultService = resultService; + this.taskService = taskService; + this.tasksRepository = tasksRepository; + this.retentionDuration = retentionDuration; + this.enabled = enabled; + } + + public void run() { + if (!enabled) { + return; + } + + runUnchecked(); + } + + @VisibleForTesting + public void runUnchecked() { + var totalProcessed = 0; + var start = System.currentTimeMillis(); + + log.info(MESG_STARTED_ROUTINE); + + try { + var slice = tasksRepository.findAllBy(Pageable.ofSize(BATCH_SIZE).first()); + do { + totalProcessed += processSlice(slice); + if (!slice.hasNext()) { + break; + } + slice = tasksRepository.findAllBy(slice.nextPageable()); + } while (true); + } catch (Exception e) { + // Swallow and move on as async routines should process all the Tasks. + log.error(MESG_UNEXPECTED_EXCEPTION, e.getMessage(), e); + } + + log.info(MESG_FINISHED_ROUTINE, System.currentTimeMillis() - start, totalProcessed); + } + + private int processSlice(Slice slice) { + var processed = new AtomicInteger(0); + + slice.stream() + .filter(CleanTerminalTasksRoutine::hasTerminalStatus) + .filter(this::hasRetentionPeriodElapsed) + .forEach(taskEntity -> { + try { + cleanUpTask(taskEntity); + } catch (Exception ex) { + // Swallow exception, log, and continue. + log.error(MESG_ERROR_PROCESSING_TASK_SLICE, ex.getMessage(), ex); + } + processed.incrementAndGet(); + }); + return processed.get(); + } + + private static boolean hasTerminalStatus(TaskEntity taskEntity) { + return TERMINAL_TASK_STATUSES.contains(taskEntity.getStatus()); + } + + private boolean hasRetentionPeriodElapsed(TaskEntity taskEntity) { + var lastHeartbeat = taskEntity.getLastHeartbeatAt(); + var instant = lastHeartbeat; + + if (lastHeartbeat == null) { + // If lastHeartbeatAt is null, then fall back to lastUpdatedAt. If lastUpdatedAt + // is null, then use createdAt as the last option. + instant = taskEntity.getLastUpdatedAt() != null ? taskEntity.getLastUpdatedAt() : + taskEntity.getCreatedAt(); + } + return instant.isBefore(Instant.now().minus(retentionDuration)); + } + + private void cleanUpTask(TaskEntity taskEntity) { + var taskId = taskEntity.getTaskId(); + var status = taskEntity.getStatus(); + var lastHeartbeatAt = taskEntity.getLastHeartbeatAt(); + + log.info(MESG_TASK_CLEANING, taskId, status, lastHeartbeatAt); + eventService.cleanEvents(taskId); + resultService.cleanResults(taskId); + icmsService.terminateInstanceByTaskId(taskEntity.getNcaId(), taskId); + taskService.deleteTask(taskId); + essService.deleteSecretsPath(taskId); + log.info(MESG_TASK_CLEANED, taskId, status, lastHeartbeatAt); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CommonRoutineService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CommonRoutineService.java new file mode 100644 index 000000000..03895cc56 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/CommonRoutineService.java @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap; +import static com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest; +import static com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.State.CANCELED; +import static com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.State.CLOSED; +import static com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.State.FAILED; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.google.common.collect.ImmutableListMultimap; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.State; +import jakarta.validation.constraints.NotNull; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +class CommonRoutineService { + + private static final String MESG_ICMS_REQUEST_INSTANCE_DETAILS = + "ICMS Request Id %s: Request State %s; Instance Id %s; Instance State %s; "; + private static final String MESG_HEALTH_INFO_UNAVAILABLE = + "Health info is not available"; + private static final String UNKNOWN = "UNKNOWN"; + + public static final Set TERMINAL_INSTANCE_STATES = + Collections.unmodifiableSet(EnumSet.of(CLOSED, CANCELED, FAILED)); + + public static ImmutableListMultimap mapInstancesByRequestId( + Collection instanceRequests) { + return instanceRequests.stream() + .collect(toImmutableListMultimap( + InstanceRequest::getInstanceRequestId, + Function.identity())); + } + + // Return true if there are no instances or all of the instances are in a terminal state. + public static boolean areAllInstancesInTerminalState( + Collection instanceRequests) { + return CollectionUtils.isEmpty(instanceRequests) + || instanceRequests.stream().map(InstanceRequest::getState) + .allMatch(TERMINAL_INSTANCE_STATES::contains); + } + + public static Set getHealthDtos( + List instanceRequestIds, + List instanceRequests) { + return instanceRequestIds.stream() + .map(instanceRequestId -> { + var instances = instanceRequests.stream() + .filter(instanceRequest -> instanceRequest.getInstanceRequestId() + .equals(instanceRequestId)) + .toList(); + var allWithErrors = instances.stream() + .allMatch(instance -> instance.getHealthInfo() != null && + isNotBlank(instance.getHealthInfo().getErrorLog())); + if (allWithErrors) { + return instances.stream().findAny() + .map(instance -> { + var healthInfo = instance.getHealthInfo(); + var launchSpec = instance.getLaunchSpecification(); + var error = healthInfo != null ? + healthInfo.getErrorLog() : "No health info"; + return getHealthDto(launchSpec.getGpu(), + launchSpec.getInstanceType(), + instance.getCloudProvider(), + error); + }) + .orElse(null); + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + public static HealthDto getHealthDto( + String gpu, + String instanceType, + String backend, + String error) { + return HealthDto.builder() + .error(error) + .instanceType(instanceType) + .gpu(gpu) + .backend(backend) + .build(); + } + + public static HealthDto getHealthDto( + GpuSpecUdt gpuSpec, + String errorMessage) { + return CommonRoutineService.getHealthDto(gpuSpec.getGpu(), + gpuSpec.getInstanceType(), + gpuSpec.getBackend(), + errorMessage); + } + + public static String getErrorMessage(Set healthDtos) { + var builder = new StringBuilder(); + if (healthDtos.isEmpty()) { + return builder.append("No health information available").toString(); + } + + healthDtos.forEach(healthDto -> { + if (!builder.isEmpty()) { + builder.append("; "); + } + builder.append(healthDto.error()); + }); + return builder.toString(); + } + + // Collect health info of all the instances. + public static String getErrorMessage(Collection instanceRequests) { + if (CollectionUtils.isEmpty(instanceRequests)) { + return "No error logs available"; + } + + var healthInfos = instanceRequests.stream() + .map(CommonRoutineService::getHealthInfo) + .toList(); + return StringUtils.join(healthInfos, ";"); + } + + private static String getHealthInfo(InstanceRequest instanceRequest) { + var errorMessageBuilder = getErrorMessageStringBuilder(instanceRequest); + var healthInfo = instanceRequest.getHealthInfo(); + if ((healthInfo != null) && isNotBlank(healthInfo.getErrorLog())) { + errorMessageBuilder.append("Error Message - ").append(healthInfo.getErrorLog()); + } else { + errorMessageBuilder.append(MESG_HEALTH_INFO_UNAVAILABLE); + } + return errorMessageBuilder.toString(); + } + + @NotNull + private static StringBuilder getErrorMessageStringBuilder(InstanceRequest instanceRequest) { + var icmsRequesId = instanceRequest.getInstanceRequestId(); + var instanceId = isNotBlank(instanceRequest.getInstanceId()) ? + instanceRequest.getInstanceId() : UNKNOWN; + var requestState = instanceRequest.getState() != null ? + instanceRequest.getState() : UNKNOWN; + var instanceState = instanceRequest.getInstanceState() != null ? + instanceRequest.getInstanceState().getName() : UNKNOWN; + return new StringBuilder(MESG_ICMS_REQUEST_INSTANCE_DETAILS.formatted(icmsRequesId, + requestState, + instanceId, + instanceState)); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutine.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutine.java new file mode 100644 index 000000000..c077ce637 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutine.java @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.LAUNCHED; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR; +import static com.nvidia.nvct.service.scheduler.CommonRoutineService.getHealthDto; +import static com.nvidia.nvct.util.NvctConstants.BATCH_SIZE; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static java.util.stream.Collectors.joining; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.metrics.TaskErrorMetricsService; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService.Instance; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@RefreshScope +@ConditionalOnProperty(name = "nvct.scheduled-routines.enabled", + havingValue = "true", + matchIfMissing = true) +public class MonitorLaunchedTasksRoutine { + + private static final String MESG_NOT_UPDATED_RECENTLY = + "Task id '{}': Status LAUNCHED - last updated more than 5 minutes ago at '{}'"; + private static final String MESG_TASK_HEALTH = + "Task id '{}': {}"; + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + private static final String MESG_ERROR_PROCESSING_TASK_SLICE = + "Error processing Task slice: {}"; + private static final String MESG_FINISHED_ROUTINE = + "Finished MonitorLaunchedTasksRoutine in {} ms. Processed {} Tasks"; + private static final String MESG_STARTED_ROUTINE = + "Started MonitorLaunchedTasksRoutine"; + private static final String MESG_UNEXPECTED_EXCEPTION = + "Unexpected exception: {}"; + + private static final String MISSING_ICMS_INSTANCES = + "No corresponding ICMS instances found"; + private static final String NO_HEALTH_INFORMATION_AVAILABLE = + "No health information available"; + + // If the current time is greater than timestamp in the lastUpdatedAt field of a Task with + // LAUNCHED status by this duration, then the Task is transitioned to ERRORED status. + private static final Duration MAX_DURATION = Duration.ofMinutes(5); + + private final EventService eventService; + private final IcmsClient icmsClient; + private final TaskService taskService; + private final TaskErrorMetricsService taskErrorMetricsService; + private final TasksRepository tasksRepository; + private final Tracer tracer; + + // The setter can be used to enable/disable the routine during testing without + // restarting the context and incurring delays. + @VisibleForTesting + @Setter + private volatile boolean enabled; + + public MonitorLaunchedTasksRoutine( + EventService eventService, + IcmsClient icmsClient, + TaskService taskService, + TaskErrorMetricsService taskErrorMetricsService, + TasksRepository tasksRepository, + Tracer tracer, + @Value("${nvct.scheduled-routines.monitor-launched-tasks-routine.enabled:true}") + boolean enabled) { + this.eventService = eventService; + this.icmsClient = icmsClient; + this.taskService = taskService; + this.taskErrorMetricsService = taskErrorMetricsService; + this.tasksRepository = tasksRepository; + this.tracer = tracer; + this.enabled = enabled; + } + + public void run() { + if (!enabled) { + return; + } + + runUnchecked(Instant.now()); + } + + @VisibleForTesting + public void runUnchecked(Instant now) { + var totalProcessed = 0; + var start = System.currentTimeMillis(); + + log.info(MESG_STARTED_ROUTINE); + + try { + var slice = tasksRepository.findAllBy(Pageable.ofSize(BATCH_SIZE).first()); + do { + totalProcessed += processSlice(slice, now); + if (!slice.hasNext()) { + break; + } + slice = tasksRepository.findAllBy(slice.nextPageable()); + } while (true); + } catch (Exception e) { + // Swallow and move on as async routines should process all the Tasks. + log.error(MESG_UNEXPECTED_EXCEPTION, e.getMessage(), e); + } + + log.info(MESG_FINISHED_ROUTINE, System.currentTimeMillis() - start, totalProcessed); + } + + private int processSlice(Slice slice, Instant now) { + var processed = new AtomicInteger(0); + + slice.stream() + .filter(task -> task.getStatus() == LAUNCHED) + .forEach(taskEntity -> { + try { + processLaunchedTask(taskEntity, now); + } catch (Exception ex) { + // Swallow exception, log, and continue. + log.error(MESG_ERROR_PROCESSING_TASK_SLICE, ex.getMessage(), ex); + } + processed.incrementAndGet(); + }); + return processed.get(); + } + + private void processLaunchedTask(TaskEntity taskEntity, Instant now) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var lastUpdatedAt = taskEntity.getLastUpdatedAt(); + + NvctUtils.addTagsToChildSpan(tracer, + Map.of(SPAN_TAG_NCA_ID, ncaId, + SPAN_TAG_TASK_ID, taskId), + "process-launched-task"); + + if (lastUpdatedAt != null && + lastUpdatedAt.isBefore(now.minus(MAX_DURATION))) { + log.info(MESG_NOT_UPDATED_RECENTLY, taskId, lastUpdatedAt); + var instances = icmsClient.getAllInstancesByTaskId(ncaId, taskId); + if (CollectionUtils.isEmpty(instances)) { + transitionToErroredWhenNoIcmsRequestsFound(taskEntity); + return; + } + + transitionToErrored(taskEntity, getInstanceErrorMessage(instances)); + } + } + + private void transitionToErrored(TaskEntity taskEntity, String errorMessage) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + log.info(MESG_TASK_HEALTH, taskId, errorMessage); + + taskService.updateTask(taskId, + TaskStatus.ERRORED, + getHealthDto(taskEntity.getGpuSpec(), errorMessage)); + taskErrorMetricsService.recordTaskError(ncaId); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(LAUNCHED, ERRORED, errorMessage); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + + private static String getInstanceErrorMessage(List instances) { + var errorMessage = instances.stream() + .map(Instance::getHealthInfo) + .filter(healthInfo -> healthInfo != null && + isNotBlank(healthInfo.getErrorLog())) + .map(healthInfo -> healthInfo.getErrorLog()) + .collect(joining("; ")); + return isNotBlank(errorMessage) ? errorMessage : NO_HEALTH_INFORMATION_AVAILABLE; + } + + // Transitions the Task to ERRORED status when there are no corresponding ICMS Request Id(s). + // This should not happen. We saw this when ICMS was not hooked up to NVCT. So, keeping this + // for completeness. + private void transitionToErroredWhenNoIcmsRequestsFound(TaskEntity task) { + var gpuSpec = task.getGpuSpec(); + var taskId = task.getTaskId(); + var ncaId = task.getNcaId(); + var healthDto = getHealthDto(gpuSpec, MISSING_ICMS_INSTANCES); + log.info(MESG_TASK_HEALTH, taskId, MISSING_ICMS_INSTANCES); + + taskService.updateTask(taskId, TaskStatus.ERRORED, healthDto); + taskErrorMetricsService.recordTaskError(ncaId); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(LAUNCHED, ERRORED, MISSING_ICMS_INSTANCES); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutine.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutine.java new file mode 100644 index 000000000..51dd4b7ca --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutine.java @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR; +import static com.nvidia.nvct.service.scheduler.CommonRoutineService.getHealthDto; +import static com.nvidia.nvct.util.NvctConstants.BATCH_SIZE; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static java.util.stream.Collectors.joining; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.metrics.TaskErrorMetricsService; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService.Instance; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@RefreshScope +@ConditionalOnProperty(name = "nvct.scheduled-routines.enabled", + havingValue = "true", + matchIfMissing = true) +public class MonitorQueuedTasksRoutine { + + private static final String MESG_ICMS_REQUESTS_CAN_PRODUCE = + "Task id '{}': ICMS Requests can still produce instance so stays in QUEUED state"; + private static final String MESG_TASK_HEALTH = + "Task id '{}': {}"; + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + private static final String MESG_ERROR_PROCESSING_TASK_SLICE = + "Error processing Task slice: {}"; + private static final String MESG_FINISHED_ROUTINE = + "Finished MonitorQueuedTasksRoutine in {} ms. Processed {} Tasks"; + private static final String MESG_STARTED_ROUTINE = + "Started MonitorQueuedTasksRoutine"; + private static final String MESG_UNEXPECTED_EXCEPTION = + "Unexpected exception: {}"; + + private static final String MISSING_ICMS_REQUEST_IDS = + "No corresponding ICMS request-id(s) found"; + private static final String NO_HEALTH_INFORMATION_AVAILABLE = + "No health information available"; + + // Extra time given after maxQueuedDuration before transitioning the Task to + // EXCEEDED_MAX_QUEUED_DURATION status. Extra time is being given to avoid race with ICMS as + // it updates the health information that NVCT can then use to update its DB. This will help + // users to see the error message percolating from the Worker -> ICMS -> NVCT. + private static final Duration DELTA_DURATION = Duration.ofMinutes(3); + + private final EventService eventService; + private final IcmsClient icmsClient; + private final TaskService taskService; + private final TaskErrorMetricsService taskErrorMetricsService; + private final TasksRepository tasksRepository; + private final Tracer tracer; + + // The setter can be used to enable/disable the routine during testing without + // restarting the context and incurring delays. + @VisibleForTesting + @Setter + private volatile boolean enabled; + + public MonitorQueuedTasksRoutine( + EventService eventService, + IcmsClient icmsClient, + TaskService taskService, + TaskErrorMetricsService taskErrorMetricsService, + TasksRepository tasksRepository, + Tracer tracer, + @Value("${nvct.scheduled-routines.monitor-queued-tasks-routine.enabled:true}") + boolean enabled) { + this.eventService = eventService; + this.icmsClient = icmsClient; + this.taskService = taskService; + this.taskErrorMetricsService = taskErrorMetricsService; + this.tasksRepository = tasksRepository; + this.tracer = tracer; + this.enabled = enabled; + } + + public void run() { + if (!enabled) { + return; + } + + runUnchecked(); + } + + @VisibleForTesting + public void runUnchecked() { + var totalProcessed = 0; + var start = System.currentTimeMillis(); + + log.info(MESG_STARTED_ROUTINE); + + try { + var slice = tasksRepository.findAllBy(Pageable.ofSize(BATCH_SIZE).first()); + do { + totalProcessed += processSlice(slice); + if (!slice.hasNext()) { + break; + } + slice = tasksRepository.findAllBy(slice.nextPageable()); + } while (true); + } catch (Exception e) { + // Swallow and move on as async routines should process all the Tasks. + log.error(MESG_UNEXPECTED_EXCEPTION, e.getMessage(), e); + } + + log.info(MESG_FINISHED_ROUTINE, System.currentTimeMillis() - start, totalProcessed); + } + + private int processSlice(Slice slice) { + var processed = new AtomicInteger(0); + + slice.stream() + .filter(task -> task.getStatus() == QUEUED) + .forEach(taskEntity -> { + try { + processQueuedTask(taskEntity); + } catch (Exception ex) { + // Swallow exception, log, and continue. + log.error(MESG_ERROR_PROCESSING_TASK_SLICE, ex.getMessage(), ex); + } + processed.incrementAndGet(); + }); + return processed.get(); + } + + private void processQueuedTask(TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var createdAt = taskEntity.getCreatedAt(); + + NvctUtils.addTagsToChildSpan(tracer, + Map.of(SPAN_TAG_NCA_ID, ncaId, + SPAN_TAG_TASK_ID, taskId), + "process-queued-task"); + + var instances = icmsClient.getAllInstancesByTaskId(ncaId, taskId); + + if (CollectionUtils.isEmpty(instances)) { + if (Instant.now().isAfter(createdAt.plus(Duration.ofMinutes(2)))) { + // If the Task is created more than two minutes ago and still there is no + // corresponding ICMS instances, then transition to ERRORED. + transitionToErroredWhenNoIcmsRequestsFound(taskEntity); + } + return; + } + + // Transition to EXCEEDED_MAX_QUEUED_DURATION if the Task has been in QUEUED status + // for more than the specified maxQueuedDuration. Use health info from ICMS if + // available. + var haveAllInstancesExceededMaxQueuedDuration = instances.stream() + .allMatch(instance -> hasExceededMaxQueuedDurationPlusDelta(instance, taskEntity)); + if (haveAllInstancesExceededMaxQueuedDuration) { + log.info("Task id '{}': Transition to EXCEEDED_MAX_QUEUED_DURATION", taskId); + transitionToExceededMaxQueuedDuration(taskEntity, instances); + return; + } + + // Transition to ERRORED if all the instances are in terminal state and has health info. + // This can happen in scenarios when there is no capacity or Task Container exited + // ungracefully during startup. These things can happen even before maxQueuedDuration + // has elapsed. + if (areAllInstancesInTerminalStateByTaskId(instances)) { + log.info("Task id '{}': Transition to ERRORED as all instances are in terminal state", + taskId); + transitionToErrored(taskEntity, instances); + return; + } + + // The ICMS still can produce active instances and maxQueuedDuration has not elapsed. + log.info(MESG_ICMS_REQUESTS_CAN_PRODUCE, taskId); + } + + // Transitions the Task to ERRORED status when there are no corresponding ICMS Request Id(s). + // This should not happen. We saw this when ICMS was not hooked up to NVCT. So, keeping this + // for completeness. + private void transitionToErroredWhenNoIcmsRequestsFound(TaskEntity taskEntity) { + var taskId = taskEntity.getTaskId(); + var ncaId = taskEntity.getNcaId(); + var gpuSpec = taskEntity.getGpuSpec(); + var healthDto = getHealthDto(gpuSpec, MISSING_ICMS_REQUEST_IDS); + log.info(MESG_TASK_HEALTH, taskId, MISSING_ICMS_REQUEST_IDS); + + taskService.updateTask(taskId, TaskStatus.ERRORED, healthDto); + taskErrorMetricsService.recordTaskError(ncaId); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(QUEUED, ERRORED, MISSING_ICMS_REQUEST_IDS); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + + private void transitionToExceededMaxQueuedDuration( + TaskEntity taskEntity, + List instances) { + var taskId = taskEntity.getTaskId(); + var errorMessage = getInstanceErrorMessage(instances); + var healthDto = getHealthDto(taskEntity.getGpuSpec(), errorMessage); + log.info(MESG_TASK_HEALTH, taskId, healthDto.error()); + + var ncaId = taskEntity.getNcaId(); + taskService.updateTask(taskId, TaskStatus.EXCEEDED_MAX_QUEUED_DURATION, healthDto); + taskErrorMetricsService.recordTaskError(ncaId); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(QUEUED, EXCEEDED_MAX_QUEUED_DURATION, errorMessage); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + + private void transitionToErrored( + TaskEntity taskEntity, + List instances) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var errorMessage = getInstanceErrorMessage(instances); + log.info(MESG_TASK_HEALTH, taskId, errorMessage); + + taskService.updateTask(taskId, + TaskStatus.ERRORED, + getHealthDto(taskEntity.getGpuSpec(), errorMessage)); + taskErrorMetricsService.recordTaskError(ncaId); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR + .formatted(QUEUED, ERRORED, errorMessage); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + + private static boolean areAllInstancesInTerminalStateByTaskId(List instances) { + return instances.stream() + .map(Instance::getState) + .allMatch(state -> state != null && + "terminated".equalsIgnoreCase(state.getName())); + } + + private static String getInstanceErrorMessage(List instances) { + var errorMessage = instances.stream() + .map(Instance::getHealthInfo) + .filter(healthInfo -> healthInfo != null && + isNotBlank(healthInfo.getErrorLog())) + .map(InstanceRequest.HealthInfo::getErrorLog) + .collect(joining("; ")); + return isNotBlank(errorMessage) ? errorMessage : NO_HEALTH_INFORMATION_AVAILABLE; + } + + private static boolean hasExceededMaxQueuedDurationPlusDelta( + Instance instance, + TaskEntity taskEntity) { + var maxQueuedDuration = taskEntity.getMaxQueuedDuration(); + var createdAt = instance.getCreateTime(); + return Instant.now().minus(maxQueuedDuration.plus(DELTA_DURATION)).isAfter(createdAt); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutine.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutine.java new file mode 100644 index 000000000..d9a2ab314 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutine.java @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR; +import static com.nvidia.nvct.service.scheduler.CommonRoutineService.getHealthDto; +import static com.nvidia.nvct.util.NvctConstants.BATCH_SIZE; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static java.util.stream.Collectors.joining; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.metrics.TaskErrorMetricsService; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService.Instance; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.tracing.Tracer; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@RefreshScope +@ConditionalOnProperty(name = "nvct.scheduled-routines.enabled", + havingValue = "true", + matchIfMissing = true) +public class MonitorWorkerHeartbeatRoutine { + private static final String MESG_NO_HEARTBEAT_RECEIVED = + "Task id '{}': Status RUNNING; Last heartbeat received more than 4 minutes ago at '{}'"; + private static final String MESG_TASK_HEALTH = + "Task id '{}': {}"; + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + private static final String MESG_ERROR_PROCESSING_TASK_SLICE = + "Error processing Task slice: {}"; + private static final String MESG_FINISHED_ROUTINE = + "Finished MonitorWorkerHeartbeatRoutine in {} ms. Processed {} Tasks"; + private static final String MESG_STARTED_ROUTINE = + "Started MonitorWorkerHeartbeatRoutine"; + private static final String MESG_UNEXPECTED_EXCEPTION = + "Unexpected exception: {}"; + private static final String MESG_CHECKING_TASK_EVENTS = + "Task id '{}': Checking task events for terminal status"; + private static final String MESG_TERMINAL_STATUS_IN_LATEST_EVENT = + "Task id '{}': Updated with terminal status '{}' from the latest event"; + private static final String MESG_NO_TERMINAL_STATUS_IN_LATEST_EVENT = + "Task id '{}': No terminal status found in latest event"; + + private static final String MESG_MISSING_ICMS_REQUEST_IDS = + "Task id '%s': No corresponding ICMS request-id(s) found"; + private static final String MESG_ICMS_INSTANCE_DETAILS = + "ICMS Request Id %s: Instance Id %s; Instance State %s; %s"; + private static final String MESG_HEALTH_INFO_UNAVAILABLE = + "Health info is not available"; + private static final String UNKNOWN = "UNKNOWN"; + + // If the current time is greater than timestamp in the lastHeartbeatAt field of a Task with + // RUNNING status by this duration, then the Task is transitioned to ERRORED status. + private static final Duration MAX_DURATION = Duration.ofMinutes(4); + + private final EventService eventService; + private final TaskService taskService; + private final IcmsClient icmsClient; + private final TaskErrorMetricsService tasksErrorMetricsService; + private final TasksRepository tasksRepository; + private final Tracer tracer; + + // The setter can be used to enable/disable the routine during testing without + // restarting the context and incurring delays. + @VisibleForTesting + @Setter + private volatile boolean enabled; + + public MonitorWorkerHeartbeatRoutine( + EventService eventService, + TaskService taskService, + IcmsClient icmsClient, + TaskErrorMetricsService tasksErrorMetricsService, + TasksRepository tasksRepository, + Tracer tracer, + @Value("${nvct.scheduled-routines.monitor-worker-heartbeat-routine.enabled:true}") + boolean enabled) { + this.eventService = eventService; + this.taskService = taskService; + this.icmsClient = icmsClient; + this.tasksErrorMetricsService = tasksErrorMetricsService; + this.tasksRepository = tasksRepository; + this.tracer = tracer; + this.enabled = enabled; + } + + public void run() { + if (!enabled) { + return; + } + + runUnchecked(); + } + + @VisibleForTesting + public void runUnchecked() { + var totalProcessed = 0; + var start = System.currentTimeMillis(); + + log.info(MESG_STARTED_ROUTINE); + + try { + var slice = tasksRepository.findAllBy(Pageable.ofSize(BATCH_SIZE).first()); + do { + totalProcessed += processSlice(slice); + if (!slice.hasNext()) { + break; + } + slice = tasksRepository.findAllBy(slice.nextPageable()); + } while (true); + } catch (Exception e) { + // Swallow and move on as async routines should process all the Tasks. + log.error(MESG_UNEXPECTED_EXCEPTION, e.getMessage(), e); + } + + log.info(MESG_FINISHED_ROUTINE, System.currentTimeMillis() - start, totalProcessed); + } + + private int processSlice(Slice slice) { + var processed = new AtomicInteger(0); + + slice.stream() + .filter(this::hasNoRecentHeartbeatForRunningTask) + .forEach(taskEntity -> { + try { + processRunningTasksWithNoRecentHeartbeat(taskEntity); + } catch (Exception ex) { + // Swallow exception, log, and continue. + log.error(MESG_ERROR_PROCESSING_TASK_SLICE, ex.getMessage(), ex); + } + processed.incrementAndGet(); + }); + return processed.get(); + } + + private void processRunningTasksWithNoRecentHeartbeat(TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var lastHeartbeatAt = taskEntity.getLastHeartbeatAt(); + + NvctUtils.addTagsToChildSpan(tracer, + Map.of(SPAN_TAG_NCA_ID, ncaId, + SPAN_TAG_TASK_ID, taskId), + "process-worker-heartbeat-task"); + + log.info(MESG_NO_HEARTBEAT_RECEIVED, taskId, lastHeartbeatAt); + var instances = icmsClient.getAllInstancesByTaskId(ncaId, taskId); + if (CollectionUtils.isEmpty(instances)) { + processTaskWithMissingIcmsInstances(taskId); + return; + } + + var errorMessage = getErrorMessage(instances); + transitionTaskToErrored(taskEntity, errorMessage); + } + + private void transitionTaskToErrored(TaskEntity taskEntity, String errorMessage) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var healthDto = getHealthDto(taskEntity.getGpuSpec(), errorMessage); + log.info(MESG_TASK_HEALTH, taskId, healthDto.error()); + + // There may be multiple ICMS Requests associated with a Task, but we must + // transition the Task to ERRORED state just once and also insert just one + // event. + taskService.updateTask(taskId, TaskStatus.ERRORED, healthDto); + tasksErrorMetricsService.recordTaskError(ncaId); + + var error = healthDto.error(); + var mesg = STATUS_CHANGE_EVENT_MESSAGE_WITH_ERROR.formatted(RUNNING, ERRORED, error); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + } + + private void processTaskWithMissingIcmsInstances(UUID taskId) { + var mesg = MESG_MISSING_ICMS_REQUEST_IDS.formatted(taskId); + log.info(mesg); + + // Check the latest Task event to see if there's a terminal status recorded. + log.info(MESG_CHECKING_TASK_EVENTS, taskId); + eventService.getTerminalStatusFromLatestEvent(taskId) + .ifPresentOrElse( + terminalStatus -> { + // Update Task with the terminal status found in the latest event. + // Note that we will not be inserting a new entry in events_by_tasks + // table for this as it was already done earlier. + taskService.updateTask(taskId, terminalStatus); + log.info(MESG_TERMINAL_STATUS_IN_LATEST_EVENT, + taskId, terminalStatus); + }, + () -> log.info(MESG_NO_TERMINAL_STATUS_IN_LATEST_EVENT, taskId)); + } + + private String getErrorMessage(List instances) { + return instances.stream() + .map(instance -> { + var requestId = isNotBlank(instance.getLaunchRequestId()) ? + instance.getLaunchRequestId() : UNKNOWN; + var instanceId = isNotBlank(instance.getInstanceId()) ? + instance.getInstanceId() : UNKNOWN; + var instanceState = instance.getState() != null ? + instance.getState().getName() : UNKNOWN; + var healthInfo = instance.getHealthInfo(); + var healthMessage = healthInfo != null && + isNotBlank(healthInfo.getErrorLog()) ? + "Error Message - " + healthInfo.getErrorLog() : + MESG_HEALTH_INFO_UNAVAILABLE; + return MESG_ICMS_INSTANCE_DETAILS.formatted( + requestId, instanceId, instanceState, healthMessage); + }) + .collect(joining("; ")); + } + + private boolean hasNoRecentHeartbeatForRunningTask(TaskEntity taskEntity) { + var lastHeartbeatAt = taskEntity.getLastHeartbeatAt(); + return taskEntity.getStatus() == RUNNING + && (lastHeartbeatAt == null || + lastHeartbeatAt.isBefore(Instant.now().minus(MAX_DURATION))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/ScheduledRoutineService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/ScheduledRoutineService.java new file mode 100644 index 000000000..bc5cc4237 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/scheduler/ScheduledRoutineService.java @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import io.micrometer.observation.annotation.Observed; +import jakarta.annotation.Nonnull; +import java.util.concurrent.CountDownLatch; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.ApplicationListener; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RefreshScope +@ConditionalOnProperty(name = "nvct.scheduled-routines.enabled", + havingValue = "true", + matchIfMissing = true) +@EnableScheduling +public class ScheduledRoutineService implements ApplicationListener { + + private static final String CLEAN_TERMINAL_TASKS_ROUTINE = + "clean-terminal-tasks-routine"; + private static final String MONITOR_LAUNCHED_TASKS_ROUTINE = + "monitor-launched-tasks-routine"; + private static final String MONITOR_QUEUED_TASKS_ROUTINE = + "monitor-queued-tasks-routine"; + private static final String MONITOR_WORKER_HEARTBEAT_ROUTINE = + "monitor-worker-heartbeat-routine"; + private final CountDownLatch initialised; + private final MonitorQueuedTasksRoutine monitorQueuedTasksRoutine; + private final MonitorLaunchedTasksRoutine monitorLaunchedTasksRoutine; + private final CleanTerminalTasksRoutine cleanTerminalTasksRoutine; + private final MonitorWorkerHeartbeatRoutine monitorWorkerHeartbeatRoutine; + + public ScheduledRoutineService( + MonitorQueuedTasksRoutine monitorQueuedTasksRoutine, + MonitorLaunchedTasksRoutine monitorLaunchedTasksRoutine, + CleanTerminalTasksRoutine cleanTerminalTasksRoutine, + MonitorWorkerHeartbeatRoutine monitorWorkerHeartbeatRoutine) { + this.monitorQueuedTasksRoutine = monitorQueuedTasksRoutine; + this.monitorLaunchedTasksRoutine = monitorLaunchedTasksRoutine; + this.cleanTerminalTasksRoutine = cleanTerminalTasksRoutine; + this.monitorWorkerHeartbeatRoutine = monitorWorkerHeartbeatRoutine; + this.initialised = new CountDownLatch(1); + } + + @Override + public void onApplicationEvent(@Nonnull ApplicationReadyEvent event) { + initialised.countDown(); + } + + @SchedulerLock(name = MONITOR_QUEUED_TASKS_ROUTINE, + lockAtLeastFor = "PT5M", + lockAtMostFor = "PT5M") + @Scheduled(fixedDelayString = "PT5M") + @Observed(name = "monitor-queued-tasks") + void monitorQueuedTasks() throws InterruptedException { + initialised.await(); + LockAssert.assertLocked(); + log.debug("Begin Routine: " + MONITOR_QUEUED_TASKS_ROUTINE); + monitorQueuedTasksRoutine.run(); + log.debug("End Routine: " + MONITOR_QUEUED_TASKS_ROUTINE); + } + + @SchedulerLock(name = MONITOR_LAUNCHED_TASKS_ROUTINE, + lockAtLeastFor = "PT8M", + lockAtMostFor = "PT8M") + @Scheduled(fixedDelayString = "PT8M") + @Observed(name = "monitor-launched-tasks") + void monitorLaunchedTasks() throws InterruptedException { + initialised.await(); + LockAssert.assertLocked(); + log.debug("Begin Routine: " + MONITOR_LAUNCHED_TASKS_ROUTINE); + monitorLaunchedTasksRoutine.run(); + log.debug("End Routine: " + MONITOR_LAUNCHED_TASKS_ROUTINE); + } + + @SchedulerLock(name = CLEAN_TERMINAL_TASKS_ROUTINE, + lockAtLeastFor = "PT14M", + lockAtMostFor = "PT14M") + @Scheduled(fixedDelayString = "PT14M") + @Observed(name = "clean-up-terminal-tasks") + void cleanUpTerminalTasks() throws InterruptedException { + initialised.await(); + LockAssert.assertLocked(); + log.debug("Begin Routine: " + CLEAN_TERMINAL_TASKS_ROUTINE); + cleanTerminalTasksRoutine.run(); + log.debug("End Routine: " + CLEAN_TERMINAL_TASKS_ROUTINE); + } + + @SchedulerLock(name = MONITOR_WORKER_HEARTBEAT_ROUTINE, + lockAtLeastFor = "PT3M", + lockAtMostFor = "PT3M") + @Scheduled(fixedDelayString = "PT3M") + @Observed(name = "monitor-worker-heartbeat") + void monitorWorkerHeartbeat() throws InterruptedException { + initialised.await(); + LockAssert.assertLocked(); + log.debug("Begin Routine: " + MONITOR_WORKER_HEARTBEAT_ROUTINE); + monitorWorkerHeartbeatRoutine.run(); + log.debug("End Routine: " + MONITOR_WORKER_HEARTBEAT_ROUTINE); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/secret/SecretManagementService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/secret/SecretManagementService.java new file mode 100644 index 000000000..6d04d4130 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/secret/SecretManagementService.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.secret; + +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; + +import tools.jackson.databind.JsonNode; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SecretManagementService { + + public static Optional getNgcApiKey(Set secrets) { + return secrets.stream() + .filter(secret -> secret.name().equalsIgnoreCase(NGC_API_KEY)) + .map(SecretDto::value) + .findFirst(); + } + + public static boolean hasDupeSecrets(Set secrets) { + var dedupedCount = (Long) secrets.stream().map(SecretDto::name).distinct().count(); + return dedupedCount != secrets.size(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/GpuSpecificationMapperService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/GpuSpecificationMapperService.java new file mode 100644 index 000000000..501ce3a10 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/GpuSpecificationMapperService.java @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.nvct.service.task; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import java.util.Base64; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GpuSpecificationMapperService { + + private final JsonMapper jsonMapper; + private final HelmValidationPolicyMapperService helmValidationPolicyMapperService; + + /** + * Convert DTO to {@link GpuSpecUdt}, including + * {@code helmValidationPolicy} serialized as JSON. + */ + public GpuSpecUdt toGpuSpecUdt(GpuSpecificationDto dto) { + var builder = GpuSpecUdt.builder() + .backend(dto.backend()) + .gpu(dto.gpu()) + .instanceType(dto.instanceType()) + .clusters(dto.clusters()) + .helmValidationPolicy( + helmValidationPolicyMapperService.toHelmValidationPolicyJson( + dto.helmValidationPolicy())); + + if (dto.configuration() != null) { + builder.configuration( + Base64.getEncoder().encodeToString( + dto.configuration().toString().getBytes(UTF_8))); + } + + return builder.build(); + } + + /** + * Convert {@link GpuSpecUdt} to DTO, including {@code helmValidationPolicy}. + */ + @SneakyThrows + public GpuSpecificationDto toGpuSpecificationDto(GpuSpecUdt udt) { + var builder = GpuSpecificationDto.builder() + .backend(udt.getBackend()) + .gpu(udt.getGpu()) + .instanceType(udt.getInstanceType()) + .clusters(udt.getClusters()) + .helmValidationPolicy( + helmValidationPolicyMapperService.toHelmValidationPolicyDto( + udt.getHelmValidationPolicy())); + + if (isNotBlank(udt.getConfiguration())) { + builder.configuration( + jsonMapper.readValue( + Base64.getDecoder().decode(udt.getConfiguration()), + ObjectNode.class)); + } + + return builder.build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/HelmValidationPolicyMapperService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/HelmValidationPolicyMapperService.java new file mode 100644 index 000000000..765a0adb0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/HelmValidationPolicyMapperService.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.nvct.service.task; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class HelmValidationPolicyMapperService { + + private final JsonMapper jsonMapper; + + /** + * Serialize {@link HelmValidationPolicyDto} to JSON for storage in the + * {@code helm_validation_policy} TEXT column. + */ + @SneakyThrows + public String toHelmValidationPolicyJson(HelmValidationPolicyDto dto) { + if (dto == null) { + return null; + } + return jsonMapper.writeValueAsString(dto); + } + + /** + * Deserialize JSON from the {@code helm_validation_policy} TEXT column back to + * {@link HelmValidationPolicyDto}. + */ + @SneakyThrows + public HelmValidationPolicyDto toHelmValidationPolicyDto(String json) { + if (StringUtils.isBlank(json)) { + return null; + } + return jsonMapper.readValue(json, HelmValidationPolicyDto.class); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskAuditService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskAuditService.java new file mode 100644 index 000000000..3506bab55 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskAuditService.java @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.task; + +import static com.nvidia.nvct.util.NvctConstants.GRP_TYPE_TASK_MANAGEMENT; +import static com.nvidia.nvct.util.NvctConstants.NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.OPER_CANCEL_TASK; +import static com.nvidia.nvct.util.NvctConstants.OPER_CREATE_TASK; +import static com.nvidia.nvct.util.NvctConstants.OPER_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.OPER_UPDATE_TASK; +import static com.nvidia.nvct.util.NvctConstants.STATE_CANCELED; +import static com.nvidia.nvct.util.NvctConstants.STATE_CREATED; +import static com.nvidia.nvct.util.NvctConstants.STATE_DELETED; +import static com.nvidia.nvct.util.NvctConstants.STATE_UPDATED; +import static com.nvidia.nvct.util.NvctConstants.SUMMARY_CANCEL_TASK; +import static com.nvidia.nvct.util.NvctConstants.SUMMARY_CREATE_TASK; +import static com.nvidia.nvct.util.NvctConstants.SUMMARY_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.SUMMARY_UPDATE_TASK; +import static com.nvidia.nvct.util.NvctConstants.TASK_ID; +import static com.nvidia.nvct.util.NvctConstants.TASK_OBJECT_LOCATION; +import static com.nvidia.nvct.util.NvctConstants.TASK_STATUS; +import static java.lang.String.format; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.AuditService; +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskAuditService { + private final AuditService auditService; + private final JsonMapper jsonMapper; + + public AuditEventPayload.Builder auditEventPayloadBuilder() { + return auditService.auditEventPayloadBuilder(); + } + + public AuditEventPayload.Builder auditEventPayloadBuilder( + Authentication authentication, + Map customProperties) { + return auditService.auditEventPayloadBuilder(authentication, customProperties); + } + + public void auditTaskCreate( + AuditEventPayload.Builder payloadBuilder, + TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var status = taskEntity.getStatus(); + var summary = format(SUMMARY_CREATE_TASK, taskId, ncaId); + payloadBuilder.operation(OPER_CREATE_TASK) + .type(TaskEntity.class.getCanonicalName()) + .groupType(GRP_TYPE_TASK_MANAGEMENT) + .objectId(taskId.toString()) + .objectLocation(TASK_OBJECT_LOCATION) + .jsonBefore(jsonMapper.createObjectNode()) // empty + .jsonAfter(jsonMapper.valueToTree(taskEntity)) + .state(STATE_CREATED) + .summary(summary) + .custom(NCA_ID, ncaId) + .custom(TASK_ID, taskId) + .custom(TASK_STATUS, status); + auditService.audit(payloadBuilder); + } + + public void auditTaskUpdate( + AuditEventPayload.Builder payloadBuilder, + JsonNode taskJsonBefore, + TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var status = taskEntity.getStatus(); + var summary = format(SUMMARY_UPDATE_TASK, taskId, status); + payloadBuilder.operation(OPER_UPDATE_TASK) + .type(TaskEntity.class.getCanonicalName()) + .groupType(GRP_TYPE_TASK_MANAGEMENT) + .objectId(taskId.toString()) + .objectLocation(TASK_OBJECT_LOCATION) + .jsonBefore(taskJsonBefore) + .jsonAfter(jsonMapper.valueToTree(taskEntity)) + .state(STATE_UPDATED) + .summary(summary) + .custom(NCA_ID, ncaId) + .custom(TASK_ID, taskId) + .custom(TASK_STATUS, status); + auditService.audit(payloadBuilder); + } + + public void auditTaskCancel( + AuditEventPayload.Builder payloadBuilder, + JsonNode taskJsonBefore, + TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var status = taskEntity.getStatus(); + var summary = format(SUMMARY_CANCEL_TASK, taskId); + payloadBuilder.operation(OPER_CANCEL_TASK) + .type(TaskEntity.class.getCanonicalName()) + .groupType(GRP_TYPE_TASK_MANAGEMENT) + .objectId(taskId.toString()) + .objectLocation(TASK_OBJECT_LOCATION) + .jsonBefore(taskJsonBefore) + .jsonAfter(jsonMapper.valueToTree(taskEntity)) + .state(STATE_CANCELED) + .summary(summary) + .custom(NCA_ID, ncaId) + .custom(TASK_ID, taskId) + .custom(TASK_STATUS, status); + auditService.audit(payloadBuilder); + } + + public void auditTaskDelete( + AuditEventPayload.Builder payloadBuilder, + TaskEntity taskEntity) { + var ncaId = taskEntity.getNcaId(); + var taskId = taskEntity.getTaskId(); + var summary = format(SUMMARY_DELETE_TASK, taskId); + payloadBuilder.operation(OPER_DELETE_TASK) + .type(TaskEntity.class.getCanonicalName()) + .groupType(GRP_TYPE_TASK_MANAGEMENT) + .objectId(taskId.toString()) + .objectLocation(TASK_OBJECT_LOCATION) + .jsonBefore(jsonMapper.valueToTree(taskEntity)) + .jsonAfter(jsonMapper.createObjectNode()) // empty + .state(STATE_DELETED) + .summary(summary) + .custom(NCA_ID, ncaId) + .custom(TASK_ID, taskId); + auditService.audit(payloadBuilder); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskManagementService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskManagementService.java new file mode 100644 index 000000000..bad0d6a84 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskManagementService.java @@ -0,0 +1,635 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.task; + +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE; +import static com.nvidia.nvct.service.secret.SecretManagementService.getNgcApiKey; +import static com.nvidia.nvct.service.secret.SecretManagementService.hasDupeSecrets; +import static com.nvidia.nvct.service.task.TaskMapperService.DEFAULT_TERMINATION_GRACE_PERIOD_DURATION; +import static com.nvidia.nvct.service.telemetry.dto.TelemetryTypeEnum.LOGS; +import static com.nvidia.nvct.service.telemetry.dto.TelemetryTypeEnum.METRICS; +import static com.nvidia.nvct.service.telemetry.dto.TelemetryTypeEnum.TRACES; +import static com.nvidia.nvct.util.NvctConstants.TERMINAL_TASK_STATUSES; +import static java.lang.String.format; + +import tools.jackson.databind.json.JsonMapper; +import com.nimbusds.oauth2.sdk.util.CollectionUtils; +import com.nimbusds.oauth2.sdk.util.MapUtils; +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.BasicTaskDto; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.account.dto.AccountDto; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.icms.IcmsClusterGroupClient; +import com.nvidia.nvct.service.icms.IcmsService; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse.ClusterGroup; +import com.nvidia.nvct.service.icms.IcmsStubService.ClusterGroupsResponse.ClusterGroup.Gpu; +import com.nvidia.nvct.service.instance.InstanceService; +import com.nvidia.nvct.service.ngc.NgcRegistryClient; +import com.nvidia.nvct.service.registry.RegistryArtifactService; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.reval.RevalClient; +import com.nvidia.nvct.service.task.TaskService.TasksSliceContext; +import com.nvidia.nvct.service.telemetry.dto.TelemetryDto; +import com.nvidia.nvct.service.telemetry.dto.TelemetryTypeEnum; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskManagementService { + + private static final Duration GFN_MAX_RUNTIME_DURATION = Duration.ofHours(8); + private static final Pattern RESULTS_LOCATION_PATTERN = + Pattern.compile("^[^/]+/([^/]+/)?[^/]+[a-zA-Z0-9\\-]*$"); + + private static final String MESG_CREATE_AND_LAUNCH_TASK = + "Account '{}': Creating and launching new task with name '{}'"; + private static final String MESG_ACCOUNT_OPERATION = + "Account '{}': {}"; + private static final String MESG_TASK_OPERATION = + "Task id '{}': {}"; + private static final String MESG_TASK_INFO = + "Task id '{}': {}"; + + private static final String MESG_BLANK_PARAMETER = + "'%s' cannot be blank"; + private static final String MESG_CANNOT_BE_NULL = + "Parameter '%s' cannot be null"; + private static final String MESG_INVALID_MAX_RUNTIME_DURATION = + "Invalid request: 'maxRuntimeDuration' cannot exceed PT8H for launching Task on 'GFN'"; + private static final String MESG_MISSING_MAX_RUNTIME_DURATION = + "Invalid request: 'maxRuntimeDuration' must be specified when launching Task on 'GFN'"; + private static final String MESG_INVALID_GRACE_PERIOD_DURATION = + "Invalid request: 'terminationGracePeriodDuration' cannot exceed 'maxRuntimeDuration'"; + private static final String MESG_DUPLICATE_SECRETS = + "Invalid request: Duplicate secrets keys in the payload"; + private static final String MESG_NO_NGC_API_KEY = + "Invalid request: Missing 'NGC_API_KEY' secret key in the payload"; + private static final String MESG_INVALID_CLUSTER_GROUP = + "Invalid request: Invalid Backend '%s' specified"; + private static final String MESG_GPUS_MISSING = + "Invalid request: GPUs missing for Backend '%s'"; + private static final String MESG_INVALID_GPU = + "Invalid request: Invalid GPU '%s' specified"; + private static final String MESG_INVALID_INSTANCE_TYPE = + "Invalid request: Invalid InstanceType '%s' specified"; + private static final String MESG_MISSING_RESULTS_LOCATION = + "Invalid request: Missing 'resultsLocation' property"; + private static final String MESG_INVALID_RESULTS_PATH = + "Invalid request: 'resultsLocation' should have format " + + "'/optional-team-name/'"; + private static final String MESG_MISSING_SECRETS = + "Invalid request: Missing secrets with default or UPLOAD 'resultHandlingStrategy'"; + private static final String MESG_INVALID_DURATION = + "Invalid request: '%s' cannot be of zero length"; + private static final String MESG_CANCEL_TERMINAL = + "Invalid request: Cannot cancel a Task with '%s' terminal status"; + private static final String MESG_MISSING_REQ_PROPS = + "Invalid request: One of the following properties 'containerImage' " + + "or 'helmChart' must be specified in the payload"; + private static final String MESG_ONE_OF_PROPS_CONSTRAINT_VIOLATION = + "Invalid request: Cannot specify both '%s' and '%s' properties in the payload"; + private static final String MESG_TELEMETRY_NOT_AVAILABLE = + "Invalid request: Telemetry '%s' not found in account '%s'"; + private static final String MESG_TELEMETRY_INVALID_TYPE = + "Invalid request: Telemetry '%s' - Invalid type '%s'"; + private static final String MESG_CONFIGURATION_NOT_EMPTY = + "Invalid request: Container-based Task should have empty " + + "configuration field in GPU specification"; + private static final String MESG_HELM_VALIDATION_POLICY_NOT_SUPPORTED = + "Invalid request: Container-based Task should have empty " + + "helmValidationPolicy field in GPU specification"; + private static final String MESG_MAX_ALLOWED_EXCEEDED = + "Account '%s': Reached or exceeded the limit for max number of in-flight Tasks '%d'"; + private static final String MESG_FORBIDDEN_TO_CREATE_TASK = + "Forbidden to create task"; + private static final String MESG_FORBIDDEN_TO_RETRIEVE_TASK = + "Insufficient resource access for task details"; + private static final String MESG_FORBIDDEN_TO_CANCEL_TASK = + "Insufficient resource access to cancel task"; + private static final String MESG_FORBIDDEN_TO_DELETE_TASK = + "Insufficient resource access to delete task"; + + private static final String PROP_CONTAINER_IMAGE = "containerImage"; + private static final String PROP_CONTAINER_ARGS = "containerArgs"; + private static final String PROP_CONTAINER_ENVIRONMENT = "containerEnvironment"; + private static final String PROP_HELM_CHART = "helmChart"; + + private final AccountService accountService; + private final EventService eventService; + private final ResultService resultService; + private final IcmsService icmsService; + private final IcmsClusterGroupClient icmsClusterGroupClient; + private final TaskAuditService taskAuditService; + private final TaskMapperService taskMapperService; + private final TaskService taskService; + private final EssService essService; + private final InstanceService instanceService; + private final NgcRegistryClient ngcRegistryClient; + private final JsonMapper jsonMapper; + private final RevalClient revalClient; + private final RegistryArtifactService registryArtifactService; + + @SuppressWarnings("checkstyle:VariableDeclarationUsageDistance") + public TaskDto createAndLaunchTask( + String ncaId, + CreateTaskRequest request, + BooleanSupplier taskAccessMatch, + AuditEventPayload.Builder payloadBuilder) { + log.info(MESG_CREATE_AND_LAUNCH_TASK, ncaId, request.name()); + if (!taskAccessMatch.getAsBoolean()) { + throw new ForbiddenException(MESG_FORBIDDEN_TO_CREATE_TASK); + } + var taskEntity = validateCreateTaskRequest(ncaId, request); + var taskId = taskEntity.getTaskId(); + + Optional> secretNames = Optional.empty(); + try { + if (!CollectionUtils.isEmpty(request.secrets())) { + secretNames = saveSecrets(request.secrets(), taskId); + taskEntity.setHasSecrets(true); + } + taskService.saveTask(taskEntity); + icmsService.scheduleInstance(taskEntity); + } catch (Exception ex) { + // Delete secrets from ESS and Task from the NVCT DB. + essService.deleteSecretsPath(taskId); + taskService.deleteTask(taskId); + log.error(ex.getMessage()); + throw ex; + } + + taskAuditService.auditTaskCreate(payloadBuilder, taskEntity); + log.info(MESG_TASK_OPERATION, taskId, "Created"); + return taskMapperService.toTaskDto(taskEntity, secretNames, Optional.empty()); + } + + public TasksSliceContext listTasks( + String ncaId, + TaskStatusEnum status, + int limit, + String cursor, + Predicate taskFilter) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + accountService.lookupAccountUsingNcaIdOrThrow(ncaId); + + log.debug(MESG_ACCOUNT_OPERATION, ncaId, "Listing tasks"); + var sliceContext = taskService.fetchTasksByAccount(ncaId, limit, status, cursor, taskFilter); + log.debug(MESG_ACCOUNT_OPERATION, ncaId, "Listed tasks"); + + return sliceContext; + } + + public TaskDto getTaskDetails( + String ncaId, + UUID taskId, + boolean includeSecrets, + Predicate taskAccessMatch) { + log.debug(MESG_TASK_OPERATION, taskId, "Retrieving"); + var taskEntity = taskService.validateAccount(ncaId, taskId); + + var filteredTask = Optional.of(taskEntity) + .filter(taskAccessMatch) + .orElseThrow(() -> new ForbiddenException(MESG_FORBIDDEN_TO_RETRIEVE_TASK)); + var hasSecrets = filteredTask.hasSecrets(); + var secretsNames = includeSecrets && hasSecrets ? essService.getSecretNames(taskId) : + Optional.>empty(); + var instances = instanceService.getInstances(filteredTask); + var dto = taskMapperService.toTaskDto(filteredTask, secretsNames, instances); + log.debug(MESG_TASK_OPERATION, taskId, "Retrieved"); + return dto; + } + + public BasicTaskDto getBasicTaskDetails( + String ncaId, + UUID taskId, + Predicate taskAccessMatch) { + log.debug(MESG_TASK_OPERATION, taskId, "Retrieving"); + var taskEntity = taskService.validateAccount(ncaId, taskId); + + // Filter by predicate - if denied, throw ForbiddenException + var filteredTask = Optional.of(taskEntity) + .filter(taskAccessMatch) + .orElseThrow(() -> new ForbiddenException(MESG_FORBIDDEN_TO_RETRIEVE_TASK)); + + var dto = taskMapperService.toBasicTaskDto(filteredTask); + log.debug(MESG_TASK_OPERATION, taskId, "Retrieved"); + return dto; + } + + public TaskDto cancelTask( + String ncaId, + UUID taskId, + Predicate taskAccessMatch, + AuditEventPayload.Builder payloadBuilder) { + var taskEntity = taskService.validateAccount(ncaId, taskId); + if (!taskAccessMatch.test(taskEntity)) { + throw new ForbiddenException(MESG_FORBIDDEN_TO_CANCEL_TASK); + } + var status = taskEntity.getStatus(); + if (TERMINAL_TASK_STATUSES.contains(status)) { + var mesg = MESG_CANCEL_TERMINAL.formatted(status.name()); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var jsonBefore = jsonMapper.valueToTree(taskEntity); + log.info(MESG_TASK_OPERATION, taskId, "Canceling"); + icmsService.terminateInstanceByTaskId(ncaId, taskId); + + taskEntity.setStatus(TaskStatus.CANCELED); + taskService.updateTask(taskEntity, false); // Don't audit - audited by cancelTask + + // Record the event for the change in the status. + var mesg = STATUS_CHANGE_EVENT_MESSAGE.formatted(status, taskEntity.getStatus()); + log.info(MESG_TASK_INFO, taskId, mesg); + eventService.insertEvent(ncaId, taskId, mesg); + + taskAuditService.auditTaskCancel(payloadBuilder, jsonBefore, taskEntity); + var dto = taskMapperService.toTaskDto(taskEntity, Optional.empty(), Optional.empty()); + log.info(MESG_TASK_OPERATION, taskId, "Canceled"); + return dto; + } + + public boolean deleteTask( + String ncaId, + UUID taskId, + Predicate taskAccessMatch, + AuditEventPayload.Builder payloadBuilder) { + var taskEntity = taskService.validateAccount(ncaId, taskId); + if (!taskAccessMatch.test(taskEntity)) { + log.error(MESG_FORBIDDEN_TO_DELETE_TASK); + throw new ForbiddenException(MESG_FORBIDDEN_TO_DELETE_TASK); + } + + log.info(MESG_TASK_OPERATION, taskId, "Deleting"); + eventService.deleteEvents(ncaId, taskId); + resultService.deleteResults(ncaId, taskId); + icmsService.terminateInstanceByTaskId(ncaId, taskId); + essService.deleteSecretsPath(taskId); + taskService.deleteTask(taskId); + + taskAuditService.auditTaskDelete(payloadBuilder, taskEntity); + log.info(MESG_TASK_OPERATION, taskId, "Deleted"); + return true; + } + + private Optional> saveSecrets(Set secrets, UUID taskId) { + if (CollectionUtils.isNotEmpty(secrets)) { + essService.saveSecrets(taskId, secrets); + return Optional.of(secrets.stream() + .map(SecretDto::name) + .collect(Collectors.toSet())); + } + return Optional.empty(); + } + + private TaskEntity validateCreateTaskRequest(String ncaId, CreateTaskRequest request) { + Objects.requireNonNull(request, () -> MESG_CANNOT_BE_NULL.formatted("request")); + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // Validate there is an account corresponding to the specified NCA Id. + var account = accountService.lookupAccountUsingNcaIdOrThrow(ncaId); + // validateLimitForMaxTasksAllowed(ncaId, account); // Limit not enforced for now. + validateDurations(request); + validateGpuSpecification(ncaId, request); + validateSecrets(request); + validateResultHandlingStrategy(request); + validateContainer(request); + validateTelemetries(ncaId, request); + + // Validate artifacts before validating the helm chart using Reval service. + var taskEntity = taskMapperService.toTaskEntity(ncaId, request); + registryArtifactService.validateArtifacts(taskEntity); + validateHelmChart(ncaId, request, taskEntity); + return taskEntity; + } + + // During load testing, this method seemed to be causing a bottleneck as we would fetch + // all the Tasks from the DB and filter them for each new Task that is being created. As + // the number of Tasks in an account increases, this method will start taking more and + // more time. Till we have a good solution, we will not call this method and not enforce + // the limit. + private void validateLimitForMaxTasksAllowed(String ncaId, AccountDto account) { + // A new Task is going to be created, check if total number of in-flight + // Tasks under the specified account is smaller than threshold. + var inFlightTaskCount = taskService.fetchTasksByAccount(ncaId) + .filter(task -> !TERMINAL_TASK_STATUSES.contains(task.getStatus())) + .count(); + if (inFlightTaskCount >= account.maxTasksAllowed()) { + var msg = format(MESG_MAX_ALLOWED_EXCEEDED, ncaId, account.maxTasksAllowed()); + log.error(msg); + throw new BadRequestException(msg); + } + } + + private static void validateDurations(CreateTaskRequest request) { + if ((request.maxRuntimeDuration() != null) && request.maxRuntimeDuration().isZero()) { + var mesg = MESG_INVALID_DURATION.formatted("maxRuntimeDuration"); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if ((request.terminationGracePeriodDuration() != null) + && request.terminationGracePeriodDuration().isZero()) { + var mesg = MESG_INVALID_DURATION.formatted("terminationGracePeriodDuration"); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if ((request.maxQueuedDuration() != null) && request.maxQueuedDuration().isZero()) { + var mesg = MESG_INVALID_DURATION.formatted("maxQueuedDuration"); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var maxRuntimeDuration = request.maxRuntimeDuration(); + var terminationGracePeriodDuration = + Objects.requireNonNullElse(request.terminationGracePeriodDuration(), + DEFAULT_TERMINATION_GRACE_PERIOD_DURATION); + + if ((maxRuntimeDuration != null) + && maxRuntimeDuration.minus(terminationGracePeriodDuration).isNegative()) { + log.error(MESG_INVALID_GRACE_PERIOD_DURATION); + throw new BadRequestException(MESG_INVALID_GRACE_PERIOD_DURATION); + } + } + + private void validateGpuSpecification(String ncaId, CreateTaskRequest request) { + var spec = request.gpuSpecification(); + var maxRuntimeDuration = request.maxRuntimeDuration(); + List gpus; + var backend = spec.backend(); + var clusterGroups = icmsClusterGroupClient.getClusterGroups( + ncaId, getTaskContainerType(request)); + + if (StringUtils.isNotBlank(backend)) { + if (backend.equals("GFN")) { + if (maxRuntimeDuration == null) { + log.error(MESG_MISSING_MAX_RUNTIME_DURATION); + throw new BadRequestException(MESG_MISSING_MAX_RUNTIME_DURATION); + } + + if (GFN_MAX_RUNTIME_DURATION.minus(maxRuntimeDuration).isNegative()) { + log.error(MESG_INVALID_MAX_RUNTIME_DURATION); + throw new BadRequestException(MESG_INVALID_MAX_RUNTIME_DURATION); + } + } + + var clusterGroup = clusterGroups.stream() + .filter(cg -> cg.getName().equalsIgnoreCase(backend)) + .findAny() + .orElseThrow(() -> { + var mesg = MESG_INVALID_CLUSTER_GROUP.formatted(backend); + log.error(mesg); + return new BadRequestException(mesg); + }); + if (CollectionUtils.isEmpty(clusterGroup.getGpus())) { + var mesg = MESG_GPUS_MISSING.formatted(backend); + log.error(mesg); + throw new BadRequestException(mesg); + } + gpus = clusterGroup.getGpus().stream() + .filter(gpu -> gpu.getName().equalsIgnoreCase(spec.gpu())) + .toList(); + + } else { + gpus = clusterGroups.stream() + .map(ClusterGroup::getGpus) + .flatMap(Collection::stream) + .filter(gpu -> gpu.getName().equalsIgnoreCase(spec.gpu())) + .toList(); + } + + if (CollectionUtils.isEmpty(gpus)) { + var mesg = MESG_INVALID_GPU.formatted(spec.gpu()); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var instanceType = spec.instanceType(); + if (StringUtils.isNotBlank(instanceType) + && gpus.stream() + .map(ClusterGroup.Gpu::getInstanceTypes) + .flatMap(Collection::stream) + .noneMatch(it -> it.getName().equalsIgnoreCase(instanceType))) { + var mesg = MESG_INVALID_INSTANCE_TYPE.formatted(spec.instanceType()); + log.error(mesg); + throw new BadRequestException(mesg); + } + } + + private void validateResultHandlingStrategy(CreateTaskRequest request) { + var secrets = request.secrets(); + var resultHandlingStrategy = request.resultHandlingStrategy(); + + if ((resultHandlingStrategy == null) + || resultHandlingStrategy == ResultHandlingStrategyEnum.UPLOAD) { + if (CollectionUtils.isEmpty(secrets)) { + log.error(MESG_MISSING_SECRETS); + throw new BadRequestException(MESG_MISSING_SECRETS); + } + + var optNgcApiKey = getNgcApiKey(secrets); + if (CollectionUtils.isEmpty(secrets) || optNgcApiKey.isEmpty()) { + log.error(MESG_NO_NGC_API_KEY); + throw new BadRequestException(MESG_NO_NGC_API_KEY); + } + + var resultsLocation = request.resultsLocation(); + if (StringUtils.isBlank(resultsLocation)) { + log.error(MESG_MISSING_RESULTS_LOCATION); + throw new BadRequestException(MESG_MISSING_RESULTS_LOCATION); + } + + if (!RESULTS_LOCATION_PATTERN.matcher(resultsLocation).matches()) { + log.error(MESG_INVALID_RESULTS_PATH); + throw new BadRequestException(MESG_INVALID_RESULTS_PATH); + } + + // Validate whether the specified NGC_API_KEY can be used to create results + // or checkpoint models at the specified resultsLocation. + var ngcApiKey = optNgcApiKey.get(); + ngcRegistryClient.validate(ngcApiKey, resultsLocation); + } + } + + private static void validateSecrets(CreateTaskRequest request) { + var secrets = request.secrets(); + if (CollectionUtils.isNotEmpty(secrets) && hasDupeSecrets(secrets)) { + log.error(MESG_DUPLICATE_SECRETS); + throw new BadRequestException(MESG_DUPLICATE_SECRETS); + } + } + + private void validateHelmChart( + String ncaId, + CreateTaskRequest request, + TaskEntity task) { + var helmChart = request.helmChart(); + + if (helmChart == null && request.containerImage() == null) { + log.error(MESG_MISSING_REQ_PROPS); + throw new BadRequestException(MESG_MISSING_REQ_PROPS); + } + + if (helmChart != null && request.containerImage() != null) { + var mesg = MESG_ONE_OF_PROPS_CONSTRAINT_VIOLATION + .formatted(PROP_CONTAINER_IMAGE, PROP_HELM_CHART); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if (helmChart != null && StringUtils.isNotBlank(request.containerArgs())) { + var mesg = MESG_ONE_OF_PROPS_CONSTRAINT_VIOLATION + .formatted(PROP_CONTAINER_ARGS, PROP_HELM_CHART); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if (helmChart != null && request.containerEnvironment() != null) { + var mesg = MESG_ONE_OF_PROPS_CONSTRAINT_VIOLATION + .formatted(PROP_CONTAINER_ENVIRONMENT, PROP_HELM_CHART); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if (helmChart != null) { + var spec = request.gpuSpecification(); + var validationErrorMsg = revalClient.validate(ncaId, task, spec); + + if (StringUtils.isBlank(validationErrorMsg)) { + return; + } + + log.error(validationErrorMsg); + throw new BadRequestException(validationErrorMsg); + } + } + + // Container based Task should have empty configuration and helmValidationPolicy + // fields in gpuSpec. + private static void validateContainer(CreateTaskRequest request) { + var helmChart = request.helmChart(); + if (helmChart == null) { + var spec = request.gpuSpecification(); + if (spec.configuration() != null + && StringUtils.isNotBlank(spec.configuration().toString())) { + var mesg = MESG_CONFIGURATION_NOT_EMPTY; + log.error(mesg); + throw new BadRequestException(mesg); + } + if (spec.helmValidationPolicy() != null) { + var mesg = MESG_HELM_VALIDATION_POLICY_NOT_SUPPORTED; + log.error(mesg); + throw new BadRequestException(mesg); + } + } + } + + private void validateTelemetries(String ncaId, CreateTaskRequest request) { + var telemetriesDto = request.telemetries(); + if (telemetriesDto == null) { + return; + } + + // Invalidate cache for the specified account to fetch any new telemetries that may + // be defined before the Task is created. + accountService.invalidateCacheForSpecificAccount(ncaId); + var telemetryMap = accountService.getAccountTelemetryMap(ncaId); + + if (telemetriesDto.logsTelemetryId() != null) { + validateTelemetryType(ncaId, telemetryMap, telemetriesDto.logsTelemetryId(), LOGS); + } + + if (telemetriesDto.metricsTelemetryId() != null) { + validateTelemetryType(ncaId, telemetryMap, telemetriesDto.metricsTelemetryId(), METRICS); + } + + if (telemetriesDto.tracesTelemetryId() != null) { + validateTelemetryType(ncaId, telemetryMap, telemetriesDto.tracesTelemetryId(), TRACES); + } + } + + private static void validateTelemetryType( + String ncaId, + Map telemetryMap, + UUID telemetryId, + TelemetryTypeEnum expectedType) { + if (telemetryId == null) { + return; + } + + if (MapUtils.isEmpty(telemetryMap) || !telemetryMap.containsKey(telemetryId)) { + var mesg = MESG_TELEMETRY_NOT_AVAILABLE.formatted(telemetryId, ncaId); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var telemetryDto = telemetryMap.get(telemetryId); + if (!telemetryDto.types().contains(expectedType)) { + var mesg = MESG_TELEMETRY_INVALID_TYPE.formatted(telemetryId, expectedType); + log.error(mesg); + throw new BadRequestException(mesg); + } + } + + // Determines if the instance-type can be used to specifically launch just a + // Container-based Task or any(Container / Helm) Task. + private static InstanceUsageTypeEnum getTaskContainerType(CreateTaskRequest request) { + if (Objects.nonNull(request.containerImage())) { + return InstanceUsageTypeEnum.CONTAINER; + } + return InstanceUsageTypeEnum.DEFAULT; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskMapperService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskMapperService.java new file mode 100644 index 000000000..e456b9826 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskMapperService.java @@ -0,0 +1,355 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.task; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.nvct.persistence.task.entity.HealthUdt; +import com.nvidia.nvct.persistence.task.entity.ModelUdt; +import com.nvidia.nvct.persistence.task.entity.ResourceUdt; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.rest.task.dto.ArtifactDto; +import com.nvidia.nvct.rest.task.dto.BasicTaskDto; +import com.nvidia.nvct.rest.task.dto.ContainerEnvironmentEntryDto; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.rest.task.dto.InstanceDto; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.telemetry.dto.TelemetriesDto; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskMapperService { + + public static final Duration DEFAULT_MAX_QUEUE_DURATION = Duration.parse("PT72H"); + public static final Duration DEFAULT_TERMINATION_GRACE_PERIOD_DURATION = Duration.parse("PT1H"); + private static final String MESG_DEFAULT_REGISTRY_MISSING = + "Default '%s' registry is missing"; + + private static final ListEnvTypeReference LIST_ENV_TYPE_REFERENCE = new ListEnvTypeReference(); + + private static class ListEnvTypeReference extends + TypeReference> { + } + + private final JsonMapper jsonMapper; + private final GpuSpecificationMapperService gpuSpecificationMapperService; + + + public TaskEntity toTaskEntity(String ncaId, CreateTaskRequest request) { + var models = getModelUdts(request.models()); + var resources = getResourceUdts(request.resources()); + var maxQueuedDuration = + Objects.requireNonNullElse(request.maxQueuedDuration(), DEFAULT_MAX_QUEUE_DURATION); + var terminationGracePeriodDuration = + Objects.requireNonNullElse(request.terminationGracePeriodDuration(), + DEFAULT_TERMINATION_GRACE_PERIOD_DURATION); + var resultStrategy = request.resultHandlingStrategy() == null ? + ResultHandlingStrategy.UPLOAD : + ResultHandlingStrategy.fromText(request.resultHandlingStrategy().toString()); + var serializedContainerEnvironment = + serializeContainerEnvironment(request.containerEnvironment()); + var taskId = UUID.randomUUID(); + var createdAt = Instant.now(); + var containerImage = Objects.nonNull(request.containerImage()) ? + request.containerImage().toString() : null; + var helmChart = Objects.nonNull(request.helmChart()) ? + request.helmChart().toString() : null; + return TaskEntity.builder() + .taskId(taskId) + .ncaId(ncaId) + .name(request.name()) + .status(TaskStatus.QUEUED) + .description(request.description()) + .tags(request.tags()) + .helmChart(helmChart) + .containerImage(containerImage) + .containerArgs(request.containerArgs()) + .containerEnvironment(serializedContainerEnvironment.orElse(null)) + .models(models.orElse(null)) + .resources(resources.orElse(null)) + .gpuSpec(gpuSpecificationMapperService.toGpuSpecUdt(request.gpuSpecification())) + .maxRuntimeDuration(request.maxRuntimeDuration()) + .maxQueuedDuration(maxQueuedDuration) + .terminalGracePeriodDuration(terminationGracePeriodDuration) + .resultHandlingStrategy(resultStrategy) + .resultsLocation(request.resultsLocation()) + .createdAt(createdAt) + .status(TaskStatus.QUEUED) + .telemetries(toTelemetriesUdt(request.telemetries()).orElse(null)) + .build(); + } + + public TaskDto toTaskDto(TaskEntity entity) { + return toTaskDto(entity, Optional.empty(), Optional.empty()); + } + + public TaskDto toTaskDto( + TaskEntity entity, + Optional> secrets, + Optional> instances) { + var resultHandlingStrategyRaw = entity.getResultHandlingStrategy().name(); + var deserializeContainerEnvironment = + deserializeContainerEnvironment(entity.getContainerEnvironment()); + var resultHandlingStrategy = ResultHandlingStrategyEnum.fromText(resultHandlingStrategyRaw); + var containerImage = StringUtils.isNotBlank(entity.getContainerImage()) ? + URI.create(entity.getContainerImage()) : null; + var helmChart = StringUtils.isNotBlank(entity.getHelmChart()) ? + URI.create(entity.getHelmChart()) : null; + var healthInfo = deserializeHealth(entity.getHealth()) + .or(() -> toHealthDto(entity.getLegacyHealthInfo())) + .orElse(null); + return TaskDto.builder() + .id(entity.getTaskId()) + .ncaId(entity.getNcaId()) + .name(entity.getName()) + .description(entity.getDescription()) + .tags(entity.getTags()) + .helmChart(helmChart) + .containerImage(containerImage) + .containerArgs(entity.getContainerArgs()) + .containerEnvironment(deserializeContainerEnvironment.orElse(null)) + .models(getModelDtos(entity.getModels()).orElse(null)) + .resources(getResourceDtos(entity.getResources()).orElse(null)) + .gpuSpecification( + gpuSpecificationMapperService.toGpuSpecificationDto(entity.getGpuSpec())) + .maxRuntimeDuration(entity.getMaxRuntimeDuration()) + .maxQueuedDuration(entity.getMaxQueuedDuration()) + .terminationGracePeriodDuration(entity.getTerminalGracePeriodDuration()) + .resultsLocation(entity.getResultsLocation()) + .resultHandlingStrategy(resultHandlingStrategy) + .percentComplete(entity.getPercentComplete()) + .status(toTaskStatusEnum(entity.getStatus())) + .healthInfo(healthInfo) + .lastHeartbeatAt(entity.getLastHeartbeatAt()) + .lastUpdatedAt(entity.getLastUpdatedAt()) + .createdAt(entity.getCreatedAt()) + .secrets(secrets.orElse(null)) + .instances(instances.orElse(null)) + .telemetries(toTelemetriesDto(entity.getTelemetries()).orElse(null)) + .build(); + } + + public BasicTaskDto toBasicTaskDto(TaskEntity taskEntity) { + return BasicTaskDto.builder() + .id(taskEntity.getTaskId()) + .name(taskEntity.getName()) + .status(toTaskStatusEnum(taskEntity.getStatus())) + .build(); + } + + @SneakyThrows + private Optional serializeContainerEnvironment( + List environment) { + if (Objects.isNull(environment) || environment.isEmpty()) { + return Optional.empty(); + } + + var json = jsonMapper.writeValueAsBytes(environment); + return Optional.of(Base64.getEncoder().encodeToString(json)); + } + + @SneakyThrows + private Optional> deserializeContainerEnvironment( + String env) { + if (StringUtils.isBlank(env)) { + return Optional.empty(); + } + + var json = Base64.getDecoder().decode(env); + return Optional.of(jsonMapper.readValue(json, LIST_ENV_TYPE_REFERENCE)); + } + + private Optional> getModelUdts(Set models) { + if (models == null) { + return Optional.empty(); + } + return Optional.of(models.stream() + .map(this::toModelUdt) + .collect(Collectors.toSet())); + } + + @VisibleForTesting + public Optional> getModelDtos(Set models) { + if (models == null) { + return Optional.empty(); + } + return Optional.of(models.stream() + .map(this::toArtifactDto) + .collect(Collectors.toSet())); + } + + private Optional> getResourceUdts(Set resources) { + if (resources == null) { + return Optional.empty(); + } + return Optional.of(resources.stream() + .map(this::toResourceUdt) + .collect(Collectors.toSet())); + } + + @VisibleForTesting + public Optional> getResourceDtos(Set resources) { + if (resources == null) { + return Optional.empty(); + } + return Optional.of(resources.stream() + .map(this::toArtifactDto) + .collect(Collectors.toSet())); + } + + private ModelUdt toModelUdt(ArtifactDto artifactDto) { + return ModelUdt.builder() + .name(artifactDto.getName()) + .version(artifactDto.getVersion()) + .url(artifactDto.getUri().toString()) + .build(); + } + + private ArtifactDto toArtifactDto(ModelUdt modelUdt) { + return ArtifactDto.builder() + .name(modelUdt.getName()) + .version(modelUdt.getVersion()) + .uri(URI.create(modelUdt.getUrl())) + .build(); + } + + private ArtifactDto toArtifactDto(ResourceUdt resourceUdt) { + return ArtifactDto.builder() + .name(resourceUdt.getName()) + .version(resourceUdt.getVersion()) + .uri(URI.create(resourceUdt.getUrl())) + .build(); + } + + private ResourceUdt toResourceUdt(ArtifactDto artifactDto) { + return ResourceUdt.builder() + .name(artifactDto.getName()) + .version(artifactDto.getVersion()) + .url(artifactDto.getUri().toString()) + .build(); + } + + @SneakyThrows + public Optional serializeHealth(HealthDto healthDto) { + if (healthDto == null) { + return Optional.empty(); + } + + return Optional.of(jsonMapper.writeValueAsString(healthDto)); + } + + @VisibleForTesting + public Optional deserializeHealth(String json) { + if (StringUtils.isBlank(json)) { + return Optional.empty(); + } + + try { + return Optional.of(jsonMapper.readValue(json, HealthDto.class)); + } catch (JacksonException e) { + log.error("Failed to deserialize task health: {}", e.getMessage(), e); + return Optional.empty(); + } + } + + public static Optional toHealthDto(HealthUdt udt) { + if (udt == null) { + return Optional.empty(); + } + + return Optional.of(HealthDto.builder() + .backend(udt.getBackend()) + .gpu(udt.getGpu()) + .instanceType(udt.getInstanceType()) + .error(udt.getError()) + .build()); + } + + private static Optional toTelemetriesUdt(TelemetriesDto telemetriesDto) { + if (telemetriesDto == null) { + return Optional.empty(); + } + + return Optional.of(TelemetriesUdt.builder() + .logsTelemetryId(telemetriesDto.logsTelemetryId()) + .metricsTelemetryId(telemetriesDto.metricsTelemetryId()) + .tracesTelemetryId(telemetriesDto.tracesTelemetryId()) + .build()); + } + + private static Optional toTelemetriesDto(TelemetriesUdt telemetriesUdt) { + if (telemetriesUdt == null) { + return Optional.empty(); + } + + return Optional.of(TelemetriesDto.builder() + .logsTelemetryId(telemetriesUdt.getLogsTelemetryId()) + .metricsTelemetryId(telemetriesUdt.getMetricsTelemetryId()) + .tracesTelemetryId(telemetriesUdt.getTracesTelemetryId()) + .build()); + } + + public static TaskStatus toTaskStatus(TaskStatusEnum taskStatusEnum) { + return switch (taskStatusEnum) { + case QUEUED -> TaskStatus.QUEUED; + case LAUNCHED -> TaskStatus.LAUNCHED; + case RUNNING -> TaskStatus.RUNNING; + case ERRORED -> TaskStatus.ERRORED; + case CANCELED -> TaskStatus.CANCELED; + case EXCEEDED_MAX_RUNTIME_DURATION -> TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; + case EXCEEDED_MAX_QUEUED_DURATION -> TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; + case COMPLETED -> TaskStatus.COMPLETED; + }; + } + + private static TaskStatusEnum toTaskStatusEnum(TaskStatus taskStatus) { + return switch (taskStatus) { + case QUEUED -> TaskStatusEnum.QUEUED; + case LAUNCHED -> TaskStatusEnum.LAUNCHED; + case RUNNING -> TaskStatusEnum.RUNNING; + case ERRORED -> TaskStatusEnum.ERRORED; + case CANCELED -> TaskStatusEnum.CANCELED; + case EXCEEDED_MAX_RUNTIME_DURATION -> TaskStatusEnum.EXCEEDED_MAX_RUNTIME_DURATION; + case EXCEEDED_MAX_QUEUED_DURATION -> TaskStatusEnum.EXCEEDED_MAX_QUEUED_DURATION; + case COMPLETED -> TaskStatusEnum.COMPLETED; + }; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskPredicateUtils.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskPredicateUtils.java new file mode 100644 index 000000000..0c625536a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskPredicateUtils.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.task; + +import static com.nvidia.nvct.service.apikeys.ApiKeysService.isApiKeyAuth; + +import java.util.Optional; +import java.util.UUID; +import lombok.experimental.UtilityClass; +import org.springframework.security.core.Authentication; + +@UtilityClass +public class TaskPredicateUtils { + + public static boolean taskAccessMatch( + Authentication authentication, + Optional optTaskId) { + return isApiKeyAuth(authentication).map(access -> { + if (access.privateTasksAllowed()) { + return true; + } + + return optTaskId + .map(access::hasResourcesScopedForTask) + .orElse(false); + }).orElseGet(() -> true); // JWT and others + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskService.java new file mode 100644 index 000000000..f3609b6e0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/task/TaskService.java @@ -0,0 +1,340 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.task; + +import static com.datastax.oss.driver.api.core.data.ByteUtils.fromHexString; +import static com.datastax.oss.driver.api.core.data.ByteUtils.toHexString; +import static com.nvidia.nvct.service.task.TaskMapperService.toTaskStatus; +import static com.nvidia.nvct.util.NvctConstants.MESG_INVALID_CURSOR; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_NCA_ID; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_ID; +import static com.nvidia.nvct.util.NvctConstants.SPAN_TAG_TASK_STATUS; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.NvctUtils; +import io.micrometer.observation.annotation.Observed; +import io.micrometer.tracing.Tracer; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Stream; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskService { + private static final String MESG_TASK_OPERATION = + "Task id '{}', status '{}': {}"; + private static final String MESG_TASK_NOT_FOUND = + "Task id '%s': Not found"; + private static final String MESG_TASK_NOT_IN_ACCOUNT = + "Task id '%s': Not found in account '%s'"; + private static final String MESG_BLANK_PARAMETER = "'%s' cannot be blank"; + private static final String MESG_CANNOT_BE_NULL = "Parameter '%s' cannot be null"; + + private final AccountService accountService; + private final TasksRepository tasksRepository; + private final TaskAuditService taskAuditService; + private final TaskMapperService taskMapperService; + private final JsonMapper jsonMapper; + private final Tracer tracer; + + public long countByNcaId(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return tasksRepository.countByNcaId(ncaId); + } + + public Optional lookupTask(UUID taskId) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, taskId)); + return tasksRepository.getByTaskId(taskId); + } + + @NotNull + public TaskEntity fetchTask(UUID taskId) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_TASK_ID, taskId)); + return tasksRepository.getByTaskId(taskId) + .orElseThrow(() -> { + var mesg = MESG_TASK_NOT_FOUND.formatted(taskId); + log.error(mesg); + return new NotFoundException(mesg); + }); + } + + @Builder + public record TasksSliceContext(List tasks, String cursor, Integer limit) { + } + + public TasksSliceContext fetchTasksByAccount( + String ncaId, + int limit, + TaskStatusEnum status, + String cursor, + Predicate taskFilter) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + Slice pagedResult; + try { + var byteBuffer = cursor == null ? null : fromHexString(cursor); + var pageable = PageRequest.of(0, limit); + var pageRequest = CassandraPageRequest.of(pageable, byteBuffer); + pagedResult = tasksRepository.findByNcaId(ncaId, pageRequest); + } catch (Exception e) { + var mesg = MESG_INVALID_CURSOR.formatted(cursor); + log.error(mesg); + throw new BadRequestException(mesg, e); + } + + var taskStatus = status == null ? null : toTaskStatus(status); + var dtos = pagedResult.getContent().stream() + .filter(task -> status == null || task.getStatus() == taskStatus) + .filter(taskFilter) + .map(taskMapperService::toTaskDto) + .toList(); + var builder = TasksSliceContext.builder().tasks(dtos); + if (pagedResult.hasNext()) { + var pagingState = ((CassandraPageRequest) pagedResult.getPageable()).getPagingState(); + builder.cursor(toHexString(pagingState)); + builder.limit(limit); + } + return builder.build(); + } + + public Stream fetchTasksByAccount(String ncaId) { + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + return tasksRepository.findByNcaId(ncaId); + } + + @Observed(name = "task.save", contextualName = "save-task") + public TaskEntity saveTask(TaskEntity entity) { + var taskId = entity.getTaskId(); + + setSpanAttributes(entity); + tasksRepository.save(entity); + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Saved task"); + return entity; + } + + @Observed(name = "task.delete", contextualName = "delete-task") + public void deleteTask(TaskEntity entity) { + var taskId = entity.getTaskId(); + setSpanAttributes(entity); + tasksRepository.delete(entity); + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Deleted task"); + } + + public void deleteTask(UUID taskId) { + deleteTask(fetchTask(taskId)); + } + + @Observed(name = "task.update", contextualName = "update-task-by-id") + public TaskEntity updateTask(UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + var taskEntity = fetchTask(taskId); + return updateTask(taskEntity); + } + + @Observed(name = "task.update", contextualName = "update-task") + public TaskEntity updateTask(TaskEntity entity) { + return updateTask(entity, true); + } + + @Observed(name = "task.update", contextualName = "update-task-with-audit") + public TaskEntity updateTask(TaskEntity entity, boolean shouldAudit) { + var jsonBefore = jsonMapper.valueToTree(entity); + + setSpanAttributes(entity); + entity.setLastUpdatedAt(Instant.now()); + entity = tasksRepository.insert(entity); + + final var finalEntity = entity; + var optPayloadBuilder = shouldAudit ? + Optional.of(taskAuditService.auditEventPayloadBuilder()) : + Optional.empty(); + optPayloadBuilder + .ifPresent(builder -> taskAuditService.auditTaskUpdate(builder, jsonBefore, + finalEntity)); + + var taskId = entity.getTaskId(); + var status = entity.getStatus(); + log.info(MESG_TASK_OPERATION, taskId, status, "Updated last_updated_at"); + return entity; + } + + @Observed(name = "task.update", contextualName = "update-task-status") + public TaskEntity updateTask(UUID taskId, TaskStatus status) { + var entity = fetchTask(taskId); + var jsonBefore = jsonMapper.valueToTree(entity); + + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Updating status to " + status); + setSpanAttributes(entity); + entity.setStatus(status); + entity.setLastUpdatedAt(Instant.now()); + entity = tasksRepository.insert(entity); + + var payloadBuilder = taskAuditService.auditEventPayloadBuilder(); + taskAuditService.auditTaskUpdate(payloadBuilder, jsonBefore, entity); + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Updated status"); + return entity; + } + + public TaskEntity updateTask(UUID taskId, TaskStatus status, Instant lastHeartbeatAt) { + return updateTask(taskId, status, lastHeartbeatAt, Optional.empty()); + } + + @Observed(name = "task.update", contextualName = "update-task-status-heartbeat") + public TaskEntity updateTask( + UUID taskId, + TaskStatus status, + Instant lastHeartbeatAt, + Optional percentComplete) { + var entity = fetchTask(taskId); + var payloadBuilder = taskAuditService.auditEventPayloadBuilder(); + var jsonBefore = jsonMapper.valueToTree(entity); + + setSpanAttributes(entity); + entity.setStatus(status); + entity.setLastHeartbeatAt(lastHeartbeatAt); + if (percentComplete.isPresent()) { + entity.setPercentComplete(percentComplete.get()); + } + entity.setLastUpdatedAt(Instant.now()); + entity = tasksRepository.insert(entity); + + taskAuditService.auditTaskUpdate(payloadBuilder, jsonBefore, entity); + log.info(MESG_TASK_OPERATION, + taskId, entity.getStatus(), + "Updated status, last_heartbeat_at, and maybe percent_complete"); + return entity; + } + + @Observed(name = "task.update", contextualName = "update-task-heartbeat") + public TaskEntity updateTask(UUID taskId, Instant lastHeartbeatAt) { + var entity = fetchTask(taskId); + var payloadBuilder = taskAuditService.auditEventPayloadBuilder(); + var jsonBefore = jsonMapper.valueToTree(entity); + + setSpanAttributes(entity); + entity.setLastHeartbeatAt(lastHeartbeatAt); + entity.setLastUpdatedAt(Instant.now()); + entity = tasksRepository.insert(entity); + + taskAuditService.auditTaskUpdate(payloadBuilder, jsonBefore, entity); + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Updated last_heartbeat_at"); + return entity; + } + + @Observed(name = "task.update", contextualName = "update-task-status-health") + public TaskEntity updateTask(UUID taskId, TaskStatus status, HealthDto healthInfo) { + var entity = fetchTask(taskId); + var payloadBuilder = taskAuditService.auditEventPayloadBuilder(); + var jsonBefore = jsonMapper.valueToTree(entity); + + setSpanAttributes(entity); + entity.setStatus(status); + entity.setHealth(taskMapperService.serializeHealth(healthInfo).orElse(null)); + entity.setLastUpdatedAt(Instant.now()); + entity = tasksRepository.insert(entity); + + taskAuditService.auditTaskUpdate(payloadBuilder, jsonBefore, entity); + log.info(MESG_TASK_OPERATION, taskId, entity.getStatus(), "Updated status and health"); + return entity; + } + + public TaskEntity validateAccount(String ncaId, UUID taskId) { + Objects.requireNonNull(taskId, () -> MESG_CANNOT_BE_NULL.formatted("taskId")); + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // Validate account exists. + accountService.lookupAccountUsingNcaIdOrThrow(ncaId); + + // Ensure ncaId in Task definition matches the one that is passed in. + var taskEntity = fetchTask(taskId); + if (!taskEntity.getNcaId().equals(ncaId)) { + var mesg = MESG_TASK_NOT_IN_ACCOUNT.formatted(taskId, ncaId); + log.error(mesg); + throw new NotFoundException(mesg); + } + + return taskEntity; + } + + public void validateAccount(String ncaId, TaskEntity taskEntity) { + Objects.requireNonNull(taskEntity, () -> MESG_CANNOT_BE_NULL.formatted("taskEntity")); + if (StringUtils.isBlank(ncaId)) { + var mesg = MESG_BLANK_PARAMETER.formatted("ncaId"); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // Validate account exists. + accountService.lookupAccountUsingNcaIdOrThrow(ncaId); + + // Ensure ncaId in Task definition matches the one that is passed in. + if (!taskEntity.getNcaId().equals(ncaId)) { + var mesg = MESG_TASK_NOT_IN_ACCOUNT.formatted(taskEntity.getTaskId(), ncaId); + log.error(mesg); + throw new NotFoundException(mesg); + } + } + + private void setSpanAttributes(TaskEntity entity) { + NvctUtils.addTagsToCurrentSpan(tracer, Map.of(SPAN_TAG_NCA_ID, entity.getNcaId(), + SPAN_TAG_TASK_ID, entity.getTaskId(), + SPAN_TAG_TASK_STATUS, entity.getStatus())); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/TelemetryService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/TelemetryService.java new file mode 100644 index 000000000..b49ac243d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/TelemetryService.java @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.telemetry.dto.TelemetryDto; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TelemetryService { + + private static final String MESG_TELEMETRY_NOT_AVAILABLE = + "Telemetry '%s': Not found in account '%s'"; + + private final AccountService accountService; + private final JsonMapper jsonMapper; + + public record Telemetries(TelemetryDto logsTelemetry, + TelemetryDto metricsTelemetry, + TelemetryDto tracesTelemetry) { } + + public record SerializeTelemetriesDto(Telemetries telemetries) { } + + @SneakyThrows + public String base64Encode(String ncaId, TelemetriesUdt telemetriesUdt) { + if (telemetriesUdt == null) { + return StringUtils.EMPTY; + } + + if (telemetriesUdt.getLogsTelemetryId() == null && + telemetriesUdt.getMetricsTelemetryId() == null && + telemetriesUdt.getTracesTelemetryId() == null) { + return StringUtils.EMPTY; + } + + // Serialized JSON should be as defined in section 4.2.5 of the SDD. + var telemetriesMap = accountService.getAccountTelemetryMap(ncaId); + var logsTelemetry = toTelemetryDto(ncaId, telemetriesMap, + telemetriesUdt.getLogsTelemetryId()); + var metricsTelemetry = toTelemetryDto(ncaId, telemetriesMap, + telemetriesUdt.getMetricsTelemetryId()); + var tracesTelemetry = toTelemetryDto(ncaId, telemetriesMap, + telemetriesUdt.getTracesTelemetryId()); + var telemetries = new Telemetries(logsTelemetry.orElse(null), + metricsTelemetry.orElse(null), + tracesTelemetry.orElse(null)); + var json = jsonMapper.writeValueAsString(new SerializeTelemetriesDto(telemetries)); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + private Optional toTelemetryDto( + String ncaId, + Map telemetryMap, + UUID telemetryId) { + if (telemetryId == null) { + return Optional.empty(); + } + + if (CollectionUtils.isEmpty(telemetryMap) || !telemetryMap.containsKey(telemetryId)) { + var mesg = MESG_TELEMETRY_NOT_AVAILABLE.formatted(telemetryId, ncaId); + log.error(mesg); + throw new BadRequestException(mesg); + } + + return Optional.of(telemetryMap.get(telemetryId)); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetriesDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetriesDto.java new file mode 100644 index 000000000..0ca9595be --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetriesDto.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import java.util.UUID; +import lombok.Builder; + +@Builder +@Schema(types = {"object"}, description = "Telemetry configuration for logs, metrics, and traces.") +public record TelemetriesDto( + @Nullable + @Schema(description = "UUID representing the logs telemetry.") + UUID logsTelemetryId, + + @Nullable + @Schema(description = "UUID representing the metrics telemetry.") + UUID metricsTelemetryId, + + @Nullable + @Schema(description = "UUID representing the traces telemetry.") + UUID tracesTelemetryId) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryDto.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryDto.java new file mode 100644 index 000000000..7a4b4176d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryDto.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; +import lombok.Builder; + +@Builder +@Schema(description = "Data Transfer Object (DTO) for Telemetry configurations") +public record TelemetryDto( + + @Schema(description = "Unique telemetry ID") + @NotNull UUID telemetryId, + + @Schema(description = "Telemetry name") + @NotBlank String name, + + @Schema(description = "URL for the telemetry endpoint") + @NotBlank String endpoint, + + @Schema(description = "Protocol used for communication") + @NotNull TelemetryProtocolEnum protocol, + + @Schema(description = "Telemetry provider") + @NotNull TelemetryProviderEnum provider, + + @Schema(description = "Set of telemetry data types") + @NotNull @NotEmpty Set types, + + @Schema(description = "Telemetry creation timestamp") + @NotNull Instant createdAt) { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProtocolEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProtocolEnum.java new file mode 100644 index 000000000..d00b57068 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProtocolEnum.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry.dto; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum TelemetryProtocolEnum { + HTTP("HTTP"), + GRPC("GRPC"); + + private static final String MESG_UNSUPPORTED_TELEMETRY_PROTOCOL = + "Unsupported telemetry protocol: '%s'"; + + private final String name; + + TelemetryProtocolEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static TelemetryProtocolEnum fromText(@NonNull String val) { + return EnumSet.allOf(TelemetryProtocolEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(String.format(MESG_UNSUPPORTED_TELEMETRY_PROTOCOL, val))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProviderEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProviderEnum.java new file mode 100644 index 000000000..05d445a66 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryProviderEnum.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry.dto; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum TelemetryProviderEnum { + PROMETHEUS("PROMETHEUS"), + GRAFANA_CLOUD("GRAFANA_CLOUD"), + SPLUNK("SPLUNK"), + DATADOG("DATADOG"), + SERVICENOW("SERVICENOW"), + KRATOS("KRATOS"), + KRATOS_THANOS("KRATOS_THANOS"), + TIMESTREAM("TIMESTREAM"), + VICTORIAMETRICS("VICTORIAMETRICS"), + AZURE_MONITOR("AZURE_MONITOR"), + OTEL_COLLECTOR("OTEL_COLLECTOR"); + + private static final String MESG_UNSUPPORTED_TELEMETRY_PROVIDER = + "Unsupported telemetry provider: '%s'"; + + private final String name; + + TelemetryProviderEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static TelemetryProviderEnum fromText(@NonNull String val) { + return EnumSet.allOf(TelemetryProviderEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(String.format(MESG_UNSUPPORTED_TELEMETRY_PROVIDER,val))); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryTypeEnum.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryTypeEnum.java new file mode 100644 index 000000000..795207b85 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/telemetry/dto/TelemetryTypeEnum.java @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.telemetry.dto; + +import static java.lang.String.format; + +import java.util.EnumSet; +import lombok.NonNull; + +public enum TelemetryTypeEnum { + + LOGS("LOGS"), + METRICS("METRICS"), + TRACES("TRACES"); + + private static final String MESG_UNSUPPORTED_TELEMETRY_TYPE = + "Unsupported telemetry type: '%s'"; + + private final String name; + + TelemetryTypeEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static TelemetryTypeEnum fromText(@NonNull String val) { + return EnumSet.allOf(TelemetryTypeEnum.class) + .stream() + .filter(e -> e.name.equalsIgnoreCase(val)) + .findFirst() + .orElseThrow(() -> new IllegalStateException(format(MESG_UNSUPPORTED_TELEMETRY_TYPE, val))); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/GrpcAuthService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/GrpcAuthService.java new file mode 100644 index 000000000..0ff8926a6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/GrpcAuthService.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.grpc.auth.AuthHeaderPassthroughServerHttpRequest; +import com.nvidia.nvct.grpc.auth.SecurityExpression; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GrpcAuthService { + + private final AuthenticationManagerResolver authenticationManagerResolver; + + /** + * grpc calls do not flow through the regular spring security filters, so we have to check manually + */ + public Authentication validateBearer( + BearerTokenAuthenticationToken bearer, + String... authorities) { + AuthenticationManager authenticationManager = authenticationManagerResolver.resolve( + new AuthHeaderPassthroughServerHttpRequest(bearer)); + Authentication authenticate = authenticationManager.authenticate(bearer); + boolean hasAnyAuthority = new SecurityExpression(authenticate).hasAnyAuthority(authorities); + if (!hasAnyAuthority) { + throw new ForbiddenException("missing task privileges"); + } + return authenticate; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/TokenService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/TokenService.java new file mode 100644 index 000000000..a4adb5cf4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/TokenService.java @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token; + +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.token.client.NotaryClient; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RefreshScope +public class TokenService { + private static final String MESG_MISSING_TOKEN = "Missing token"; + + private final NotaryClient notaryClient; + private final AccountService accountService; + private final WorkerAssertionValidator workerAssertionValidator; + + public TokenService( + NotaryClient notaryClient, + AccountService accountService, + WorkerAssertionValidator workerAssertionValidator) { + this.notaryClient = notaryClient; + this.accountService = accountService; + this.workerAssertionValidator = workerAssertionValidator; + } + + public String issueSecretsAssertion(TaskEntity task) { + var taskId = task.getTaskId(); + var ncaId = task.getNcaId(); + var taskSecretsPresent = task.hasSecrets(); + var telemetries = task.getTelemetries(); + var telemetrySecretsPresent = (telemetries != null); + + if (telemetrySecretsPresent && taskSecretsPresent) { + return notaryClient.issueSecretPathsAssertionToken(ncaId, taskId, telemetries); + } else if (telemetrySecretsPresent) { + return notaryClient.issueSecretPathsAssertionToken(ncaId, telemetries); + } else if (taskSecretsPresent) { + return notaryClient.issueSecretPathsAssertionToken(taskId); + } + + return StringUtils.EMPTY; + } + + public String issueWorkerAccessAssertion(String ncaId, UUID taskId) { + accountService.getAccountName(ncaId); + return notaryClient.issueWorkerAccessAssertionToken(ncaId, taskId); + } + + public void validateWorkerAccessAssertion(String ncaId, UUID taskId) { + accountService.getAccountName(ncaId); + workerAssertionValidator.validate(getAccessToken(), ncaId, taskId); + } + + private static String getAccessToken() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getCredentials() instanceof String token)) { + log.error(MESG_MISSING_TOKEN); + throw new UnauthorizedException(MESG_MISSING_TOKEN); + } + return token; + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/WorkerAssertionValidator.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/WorkerAssertionValidator.java new file mode 100644 index 000000000..31c1db214 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/WorkerAssertionValidator.java @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.service.token.client.NotaryStubService.WorkerAccessAssertion; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class WorkerAssertionValidator { + + private static final String MESG_INVALID_SIGNATURE = + "Task id '%s': Invalid worker assertion token"; + private static final String MESG_INVALID_ISSUER = + "Task id '%s': Invalid or missing issuer in worker assertion token"; + private static final String MESG_EXPIRED_TOKEN = + "Task id '%s': Expired worker assertion token"; + private static final String MESG_INVALID_SUBJECT = + "Task id '%s': Invalid or missing subject in worker assertion token"; + private static final String MESG_INVALID_ASSERTION = + "Task id '%s': Invalid or missing assertion claim in worker assertion token"; + private static final String MESG_MISMATCH_NCA_ID = + "NCA id '%s': Does not match the one in the access token"; + private static final String MESG_MISMATCH_TASK_ID = + "Task id '%s': Does not match the one in the access token"; + + private static final Duration VALIDITY = Duration.ofHours(3); + + private final JwtDecoder jwtDecoder; + private final JsonMapper jsonMapper; + private final Clock clock; + private final String issuer; + private final String subject; + + public WorkerAssertionValidator( + @Qualifier("notaryJwtDecoder") JwtDecoder jwtDecoder, + JsonMapper jsonMapper, + Clock clock, + @Value("${nvct.notary.base-url}") String issuer, + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + String subject) { + this.jwtDecoder = jwtDecoder; + this.jsonMapper = jsonMapper; + this.clock = clock; + this.issuer = issuer; + this.subject = subject; + } + + public void validate(String token, String ncaId, UUID taskId) { + var jwt = decode(token, taskId); + validateIssuer(jwt, taskId); + validateIssuedAt(jwt, taskId); + validateSubject(jwt, taskId); + var accessAssertion = getAssertion(jwt, taskId); + + if (!accessAssertion.ncaId().equals(ncaId)) { + var mesg = MESG_MISMATCH_NCA_ID.formatted(ncaId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + + if (!accessAssertion.taskId().equals(taskId)) { + var mesg = MESG_MISMATCH_TASK_ID.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + } + + private Jwt decode(String token, UUID taskId) { + try { + return jwtDecoder.decode(token); + } catch (JwtException ex) { + var mesg = MESG_INVALID_SIGNATURE.formatted(taskId); + log.error(mesg, ex); + throw new ForbiddenException(mesg, ex); + } + } + + private void validateIssuer(Jwt jwt, UUID taskId) { + if (jwt.getIssuer() == null || !issuer.equals(jwt.getIssuer().toString())) { + var mesg = MESG_INVALID_ISSUER.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + } + + private void validateIssuedAt(Jwt jwt, UUID taskId) { + var issuedAt = jwt.getIssuedAt(); + if (issuedAt == null) { + var mesg = MESG_EXPIRED_TOKEN.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + if (issuedAt.plus(VALIDITY).isBefore(Instant.now(clock))) { + var mesg = MESG_EXPIRED_TOKEN.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + } + + private void validateSubject(Jwt jwt, UUID taskId) { + if (StringUtils.isBlank(jwt.getSubject()) || !jwt.getSubject().equals(subject)) { + var mesg = MESG_INVALID_SUBJECT.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + } + + private WorkerAccessAssertion getAssertion(Jwt jwt, UUID taskId) { + try { + var assertionClaim = jwt.getClaim("assertion"); + if (!(assertionClaim instanceof Map assertionMap)) { + var mesg = MESG_INVALID_ASSERTION.formatted(taskId); + log.error(mesg); + throw new ForbiddenException(mesg); + } + var assertionJson = jsonMapper.writeValueAsString(assertionMap); + return jsonMapper.readValue(assertionJson, WorkerAccessAssertion.class); + } catch (JacksonException | IllegalArgumentException ex) { + var mesg = MESG_INVALID_ASSERTION.formatted(taskId); + log.error(mesg, ex); + throw new ForbiddenException(mesg, ex); + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryAudiencesConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryAudiencesConfiguration.java new file mode 100644 index 000000000..1c8fb6fc2 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryAudiencesConfiguration.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token.client; + +import java.util.Map; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "nvct.notary") +@RefreshScope +@Data +public class NotaryAudiencesConfiguration { + private Map audiences; +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryClient.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryClient.java new file mode 100644 index 000000000..21cde18fa --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryClient.java @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token.client; + +import static com.nvidia.nvct.util.NvctConstants.ESS_NAMESPACE; +import static java.lang.String.format; + +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.nvct.configuration.staticclientauth.FixedBearerExchangeFilterFunction; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientNotaryProperties; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.token.client.NotaryStubService.SecretPathsAssertion; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignResponse; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignSecretPathsRequest; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignWorkerAccessRequest; +import com.nvidia.nvct.service.token.client.NotaryStubService.WorkerAccessAssertion; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Service +@RefreshScope +@Slf4j +public class NotaryClient { + + public enum Audience { + ESS, + NVCT; + } + + private static final String TELEMETRY_SECRETS_PATH_TEMPLATE = "accounts/%s/telemetries/%s"; + private static final String TASKS_SECRETS_PATH_TEMPLATE = "tasks/%s/secrets"; + private static final String MESG_ASSERTION_MISSING_MESSAGE = + "Assertion missing from notary response"; + public static final String CLIENT_REGISTRATION_ID = "notary"; + + private final NotaryStubService notaryStubService; + private final Map audiences; + private final EssService essService; + + // We could have used OAuth2AuthorizedClientManager and relied on Spring Security to + // pick up the configuration properties using ClientRegistrationRepository directly. However, + // the client-secret value held in the ClientRegistrationRepository does not get refreshed when + // client-secret is rotated. Addressing these issues requires introducing a refreshable + // ClientRegistrationRepository that wasn't clean. Instead, we will keep it simple and use + // the tried and tested approach of using @Value and @RefreshScope annotations and wire + // things up ourselves. + public NotaryClient( + @Value("${nvct.notary.base-url}") String baseUrl, + @Value("${spring.security.oauth2.client.registration.notary.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.notary.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.notary.scope}") String scope, + @Value("${spring.security.oauth2.client.provider.notary.token-uri}") String tokenUri, + NotaryAudiencesConfiguration audiencesConfiguration, + EssService essService, + ManagedHttpResources notaryHttpResources, + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + Optional staticClientNotaryProperties) { + this.audiences = audiencesConfiguration.getAudiences(); + this.essService = essService; + var authFilter = oauthFilter(staticClientNotaryProperties, webClientBuilder, + clientId, clientSecret, scope, tokenUri); + var webClient = webClientBuilder + .baseUrl(baseUrl) + .clientConnector(notaryHttpResources.connector()) + .filter(NvctOAuth2ClientUtils.getRetryableFilter(CLIENT_REGISTRATION_ID)) + .filter(authFilter) + .filter(NvctOAuth2ClientUtils.getResponseFilterProcessor("Notary")) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.notaryStubService = factory.createClient(NotaryStubService.class); + } + + private static ExchangeFilterFunction oauthFilter( + Optional staticClientNotaryProperties, + WebClient.Builder webClientBuilder, + String clientId, + String clientSecret, + String scope, + String tokenUri) { + return staticClientNotaryProperties + .map(p -> (ExchangeFilterFunction) + new FixedBearerExchangeFilterFunction(p::getToken)) + .orElseGet(() -> NvctOAuth2ClientUtils + .getOAuth2ExchangeFilter(webClientBuilder, CLIENT_REGISTRATION_ID, + tokenUri, clientId, clientSecret, scope)); + } + + public String issueSecretPathsAssertionToken(String ncaId, TelemetriesUdt telemetries) { + var paths = getTelemetrySecretPaths(ncaId, telemetries); + return issueSecretPathsAssertionTokenInternal(paths); + } + + public String issueSecretPathsAssertionToken(UUID taskId) { + var paths = Set.of(String.format(TASKS_SECRETS_PATH_TEMPLATE, taskId)); + return issueSecretPathsAssertionTokenInternal(paths); + } + + public String issueSecretPathsAssertionToken( + String ncaId, + UUID taskId, + TelemetriesUdt telemetries) { + var paths = getTelemetrySecretPaths(ncaId, telemetries); + paths.add(String.format(TASKS_SECRETS_PATH_TEMPLATE, taskId)); + return issueSecretPathsAssertionTokenInternal(paths); + } + + public String issueWorkerAccessAssertionToken(String ncaId, UUID taskId) { + var assertion = new WorkerAccessAssertion(ncaId, taskId); + var audience = audiences.get(Audience.NVCT); + var request = new SignWorkerAccessRequest(List.of(audience), assertion); + var response = notaryStubService.signWorkerAccess(request); + return Optional.ofNullable(response) + .map(SignResponse::assertion) + .orElseThrow(() -> new UpstreamException(MESG_ASSERTION_MISSING_MESSAGE)); + } + + private String issueSecretPathsAssertionTokenInternal(Set paths) { + var assertions = new SecretPathsAssertion(ESS_NAMESPACE, paths.stream().toList()); + var audience = audiences.get(Audience.ESS); + var secretPathsRequest = new SignSecretPathsRequest(List.of(audience), assertions); + var response = notaryStubService.signSecretPaths(secretPathsRequest); + return Optional.ofNullable(response) + .map(SignResponse::assertion) + .orElseThrow(() -> new UpstreamException(MESG_ASSERTION_MISSING_MESSAGE)); + } + + private Set getTelemetrySecretPaths( + String ncaId, + TelemetriesUdt telemetries) { + var telemetrySecretPaths = new HashSet(); + if (telemetries == null) { + return telemetrySecretPaths; // Return an updatable/modifiable set. + } + + var logsTelemetryId = telemetries.getLogsTelemetryId(); + if (logsTelemetryId != null + && essService.telemetrySecretExist(ncaId, logsTelemetryId)) { + var path = format(TELEMETRY_SECRETS_PATH_TEMPLATE, ncaId, logsTelemetryId); + telemetrySecretPaths.add(path); + } + + var metricsTelemetryId = telemetries.getMetricsTelemetryId(); + if (metricsTelemetryId != null + && essService.telemetrySecretExist(ncaId, metricsTelemetryId)) { + var path = format(TELEMETRY_SECRETS_PATH_TEMPLATE, ncaId, metricsTelemetryId); + telemetrySecretPaths.add(path); + } + + var tracesTelemetryId = telemetries.getTracesTelemetryId(); + if (tracesTelemetryId != null + && essService.telemetrySecretExist(ncaId, tracesTelemetryId)) { + var path = format(TELEMETRY_SECRETS_PATH_TEMPLATE, ncaId, tracesTelemetryId); + telemetrySecretPaths.add(path); + } + return telemetrySecretPaths; + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryStubService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryStubService.java new file mode 100644 index 000000000..c5615944d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/service/token/client/NotaryStubService.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token.client; + +import tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import tools.jackson.databind.annotation.JsonNaming; +import java.util.List; +import java.util.UUID; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.PostExchange; + +public interface NotaryStubService { + + @PostExchange("/sign") + SignResponse signSecretPaths(@RequestBody SignSecretPathsRequest request); + + @PostExchange("/sign") + SignResponse signWorkerAccess(@RequestBody SignWorkerAccessRequest request); + + record SecretPathsAssertion(String namespace, List secretPaths) { + } + + @JsonNaming(SnakeCaseStrategy.class) + record SignSecretPathsRequest( + List audienceServiceIds, + SecretPathsAssertion data) { + } + + record WorkerAccessAssertion(String ncaId, UUID taskId) { + } + + @JsonNaming(SnakeCaseStrategy.class) + record SignWorkerAccessRequest( + List audienceServiceIds, + WorkerAccessAssertion data) { + } + + @JsonNaming(SnakeCaseStrategy.class) + record SignResponse(String assertion) { + + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctConstants.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctConstants.java new file mode 100644 index 000000000..c41187354 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctConstants.java @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; + +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class NvctConstants { + + // Super-Admin Scopes + public static final String ADMIN_SCOPE_LAUNCH_TASK = "admin:launch_task"; + public static final String ADMIN_SCOPE_LIST_TASKS = "admin:list_tasks"; + public static final String ADMIN_SCOPE_TASK_DETAILS = "admin:task_details"; + public static final String ADMIN_SCOPE_CANCEL_TASK = "admin:cancel_task"; + public static final String ADMIN_SCOPE_DELETE_TASK = "admin:delete_task"; + public static final String ADMIN_SCOPE_LIST_EVENTS = "admin:list_events"; + public static final String ADMIN_SCOPE_LIST_RESULTS = "admin:list_results"; + public static final String ADMIN_SCOPE_UPDATE_SECRETS = "admin:update_secrets"; + public static final Set SUPER_ADMIN_SCOPES = Set.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK, + ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_RESULTS, + ADMIN_SCOPE_UPDATE_SECRETS); + + // Account-Admin Scopes + public static final String SCOPE_LAUNCH_TASK = "launch_task"; + public static final String SCOPE_LIST_TASKS = "list_tasks"; + public static final String SCOPE_TASK_DETAILS = "task_details"; + public static final String SCOPE_CANCEL_TASK = "cancel_task"; + public static final String SCOPE_DELETE_TASK = "delete_task"; + public static final String SCOPE_LIST_EVENTS = "list_events"; + public static final String SCOPE_LIST_RESULTS = "list_results"; + public static final String SCOPE_UPDATE_SECRETS = "update_secrets"; + public static final Set ACCOUNT_ADMIN_SCOPES = Set.of(SCOPE_LAUNCH_TASK, + SCOPE_LIST_TASKS, + SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK, + SCOPE_LIST_EVENTS, + SCOPE_LIST_RESULTS, + SCOPE_UPDATE_SECRETS); + + // Base64 encoded empty JSON array []. + public static final String DEFAULT_CONTAINER_ENV = Base64.getEncoder() + .encodeToString("[]".getBytes(StandardCharsets.UTF_8)); + + public static final String REQUEST_URI = "requestUri"; + public static final String REMOTE_ADDRESS = "remoteAddress"; + public static final String HTTP_METHOD = "httpMethod"; + public static final String UNKNOWN = "UNKNOWN"; + public static final String ACTOR_ID_DELIMITER = "_"; + public static final String NGC_API_KEY = "NGC_API_KEY"; + + public static final String TASK_OBJECT_LOCATION = "urn:nvcf:cassandra:nvct:tasks"; + + public static final String GRP_TYPE_TASK_MANAGEMENT = "TASK_MANAGEMENT"; + + public static final String OPER_CREATE_TASK = "CREATE_TASK"; + public static final String OPER_CANCEL_TASK = "CANCEL_TASK"; + public static final String OPER_DELETE_TASK = "DELETE_TASK"; + public static final String OPER_UPDATE_TASK = "UPDATE_TASK"; + + public static final String SUMMARY_CREATE_TASK = "Created task '%s' for account '%s'"; + public static final String SUMMARY_CANCEL_TASK = "Canceled task '%s'"; + public static final String SUMMARY_DELETE_TASK = "Deleted task '%s'"; + public static final String SUMMARY_UPDATE_TASK = "Updated task '%s' status to '%s'"; + public static final String SUMMARY_UPDATE_TASK_HEARTBEAT = "Updated task '%s' heartbeat"; + public static final String SUMMARY_UPDATE_TASK_ENTITY = "Updated task '%s' entity"; + + public static final String STATE_CREATED = "CREATED"; + public static final String STATE_CANCELED = "CANCELED"; + public static final String STATE_DELETED = "DELETED"; + public static final String STATE_UPDATED = "UPDATED"; + + public static final String NCA_ID = "nca_id"; + public static final String TASK_ID = "task_id"; + public static final String TASK_STATUS = "task_status"; + public static final String UPDATE_TYPE = "update_type"; + public static final String UPDATE_TYPE_STATUS = "status"; + public static final String UPDATE_TYPE_HEARTBEAT = "heartbeat"; + public static final String UPDATE_TYPE_ENTITY = "entity"; + + public static final String ENC_KEY_NAME = "current-kid"; + + public static final String SPAN_TAG_TASK_ID = "task_id"; + public static final String SPAN_TAG_TASK_NAME = "task_name"; + public static final String SPAN_TAG_NCA_ID = "nca_id"; + public static final String SPAN_TAG_ACCOUNT_NAME = "account_name"; + public static final String SPAN_TAG_TASK_STATUS = "task_status"; + + public static final int MAX_TAGS_COUNT = 64; + public static final int MAX_TAG_LENGTH = 128; + public static final int MAX_DESCRIPTION_LENGTH = 256; + public static final String NAME_REGEX = "^[a-z0-9A-Z][a-z0-9A-Z\\-_]*$"; + + public static final UUID UUID_WILDCARD = new UUID(0, 0); + + public static final String DEFAULT_PAGINATION_LIMIT = "10"; + public static final String MESG_INVALID_CURSOR = "Invalid cursor: '%s'"; + + // Metrics - Meter names + public static final String METER_TASK_RUNNING = "nvct.task.running"; + public static final String METER_TASK_SUCCESS = "nvct.task.success"; + public static final String METER_TASK_ERROR = "nvct.task.error"; + + // Metrics - Tag names + public static final String TAG_NCA_ID = "nca_id"; + public static final String TAG_TASK_ID = "task_id"; + public static final String TAG_ORG_NAME = "account_name"; + public static final String TAG_ERROR_SOURCE = "error_source"; + + public static final String ESS_NAMESPACE = "nvcf"; + + public static final Set TERMINAL_TASK_STATUSES = + Collections.unmodifiableSet(EnumSet.of(CANCELED, + COMPLETED, + ERRORED, + EXCEEDED_MAX_RUNTIME_DURATION, + EXCEEDED_MAX_QUEUED_DURATION)); + + public static final int MAX_BUFFER_LIMIT = 10 * 1024 * 1024; + + public static final int MAX_SECRET_VALUE_LENGTH = 32768; + public static final int MAX_SECRET_NAME_LENGTH = 48; + + + // Hostname Syntax - https://en.wikipedia.org/wiki/Hostname#Syntax + // public static final String HOSTNAME_REGEX = "^[a-z0-9A-Z][a-z0-9A-Z\\-.]*$"; + // public static final String HOSTNAME_REGEX = "^[A-Za-z0-9][A-Za-z0-9-.]*\\.\\D{2,4}$"; + public static final String HOSTNAME_REGEX = + "(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(? connection + .addHandlerFirst(new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.SECONDS)) + .addHandlerFirst(new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.SECONDS))) + .responseTimeout(RESPONSE_TIMEOUT_DURATION) + .followRedirect(true) + .runOn(loopResources); + httpClient.warmup().block(); + return new ManagedHttpResources( + new ReactorClientHttpConnector(httpClient), + provider, + loopResources, + clientRegistrationId); + } + + // Returns a retry filter for both token server and resource server. Retries twice on + // 5xx from either the resource server or token server - server_error, temporarily_unavailable, + // or HTTP 5xx and then throws UpstreamException. + // + // For other ClientAuthorizationException (auth failures typically from token server), retries + // once and throws UnauthorizedException with details if the retry fails. + public static ExchangeFilterFunction getRetryableFilter(String upstream) { + var svcName = upstream.toUpperCase(); + var retrySpec = Retry.backoff(2, Duration.ofMillis(200)) + .jitter(0.75) + .doBeforeRetry(retrySignal -> log.info("Before retrying {} call", svcName)) + .doAfterRetry(retrySignal -> log.info("After retrying {} call", svcName)) + .filter(throwable -> throwable instanceof UpstreamException + || isTokenServer5xx(throwable)) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + log.error("{} failed to process after max retries", svcName); + var mesg = "Failed to get response from '%s' after retries.".formatted(svcName); + return new UpstreamException(mesg); + }); + + return (request, next) -> next.exchange(request) + .onErrorResume(ClientAuthorizationException.class, ex -> { + if (isTokenServer5xx(ex)) { + return Mono.error(ex); + } + log.warn("OAuth2 token fetch failed for '{}', retrying: {}", svcName, + ex.getMessage()); + return next.exchange(request) + .onErrorResume(ClientAuthorizationException.class, retryEx -> { + var mesg = getClientAuthErrorMessage(svcName, retryEx); + return Mono.error(new UnauthorizedException(mesg, ex)); + }); + }) + .flatMap(clientResponse -> Mono.just(clientResponse).thenReturn(clientResponse)) + .retryWhen(retrySpec); + } + + public static ServerOAuth2AuthorizedClientExchangeFilterFunction getOAuth2ExchangeFilter( + WebClient.Builder webClientBuilder, + String clientRegistrationId, + String tokenUri, + String clientId, + String clientSecret, + String scope) { + var scopes = StringUtils.isBlank(scope) ? List.of() : + Arrays.stream(scope.split(",")).map(String::trim).toList(); + var clientRegistration = ClientRegistration.withRegistrationId(clientRegistrationId) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientId(clientId) + .clientSecret(clientSecret) + .scope(scopes) + .tokenUri(tokenUri) + .build(); + var clientRegistrationRepository = + new InMemoryReactiveClientRegistrationRepository(clientRegistration); + var clientService = + new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrationRepository); + + // Use the Boot-customized builder for the token endpoint so token and resource calls + // share the same observation convention. When the token client used a raw + // WebClient.builder(), it registered http.client.requests with legacy tag keys while + // resource clients registered the same metric name with Boot's current tag keys. + // Prometheus requires every time series under the same metric name to have the same tag + // keys, so it rejected the resource-server meters after the token-server meter existed. + var tokenWebClient = webClientBuilder.clone() + .build(); + var tokenResponseClient = new WebClientReactiveClientCredentialsTokenResponseClient(); + tokenResponseClient.setWebClient(tokenWebClient); + + var clientCredentialsProvider = + new ClientCredentialsReactiveOAuth2AuthorizedClientProvider(); + clientCredentialsProvider.setAccessTokenResponseClient(tokenResponseClient); + + var reactiveClientManager = + new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( + clientRegistrationRepository, clientService); + reactiveClientManager.setAuthorizedClientProvider(clientCredentialsProvider); + + var oauth2ExchangeFilter = + new ServerOAuth2AuthorizedClientExchangeFilterFunction(reactiveClientManager); + oauth2ExchangeFilter.setDefaultClientRegistrationId(clientRegistrationId); + return oauth2ExchangeFilter; + } + + public static ExchangeFilterFunction getResponseFilterProcessor(String upstream) { + return ExchangeFilterFunction.ofResponseProcessor(response -> { + if (response.statusCode().is5xxServerError()) { + return handle5xxError(upstream, response); + } + if (response.statusCode().is4xxClientError()) { + return handle4xxError(upstream, response); + } + return Mono.just(response); + }); + } + + private static Mono handle4xxError( + String serviceName, ClientResponse response) { + var status = response.statusCode(); + var errorMsg = MESG_4XX_RESPONSE.formatted(serviceName, status.value()); + log.error(errorMsg); + + return response.bodyToMono(String.class) + .defaultIfEmpty(errorMsg) + .flatMap(body -> { + var detail = getDetailFromProblemDetailsResponse(JSON_MAPPER, serviceName, body); + if (status.isSameCodeAs(UNAUTHORIZED)) { + return Mono.error(new UnauthorizedException(detail)); + } + if (status.isSameCodeAs(PAYMENT_REQUIRED)) { + return Mono.error(new PaymentRequiredException(detail)); + } + if (status.isSameCodeAs(FORBIDDEN)) { + return Mono.error(new ForbiddenException(detail)); + } + if (status.isSameCodeAs(NOT_FOUND)) { + return Mono.error(new NotFoundException(detail)); + } + if (status.isSameCodeAs(CONFLICT)) { + return Mono.error(new ConflictException(detail)); + } + if (status.isSameCodeAs(TOO_MANY_REQUESTS)) { + return Mono.error(new TooManyRequestsException(detail)); + } + if (status.isSameCodeAs(INSUFFICIENT_STORAGE)) { + return Mono.error(new UpstreamException(detail)); + } + if (status.isSameCodeAs(UNPROCESSABLE_CONTENT)) { + return Mono.error(new UnprocessableEntityException(detail)); + } + if (status.isSameCodeAs(BAD_REQUEST)) { + return Mono.error(new BadRequestException(detail)); + } + return Mono.error(new UpstreamException(detail)); + }); + } + + private static Mono handle5xxError( + String serviceName, ClientResponse response) { + var statusValue = response.statusCode().value(); + var errorMsg = MESG_5XX_RESPONSE.formatted(serviceName, statusValue); + log.error(errorMsg); + + return response.bodyToMono(String.class) + .switchIfEmpty(Mono.defer(() -> { + log.error(errorMsg); + return Mono.error(new UpstreamException(errorMsg)); + })) + .flatMap(body -> { + var detail = getDetailFromProblemDetailsResponse(JSON_MAPPER, serviceName, body); + var mesg = MESG_5XX_RESPONSE_WITH_DETAIL + .formatted(serviceName, statusValue, detail); + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + } + + private static boolean isTokenServer5xx(Throwable throwable) { + if (!(throwable instanceof ClientAuthorizationException ex)) { + return false; + } + if (ex.getError() != null) { + var code = ex.getError().getErrorCode(); + if (OAuth2ErrorCodes.SERVER_ERROR.equals(code) + || OAuth2ErrorCodes.TEMPORARILY_UNAVAILABLE.equals(code)) { + return true; + } + } + var cause = ex.getCause(); + if (cause instanceof WebClientResponseException wce) { + return wce.getStatusCode().is5xxServerError(); + } + return false; + } + + private static String getClientAuthErrorMessage( + String upstream, + ClientAuthorizationException ex) { + var mesg = upstream + " authentication failed: " + ex.getMessage(); + if (ex.getError() != null) { + mesg += " [OAuth2 error: " + ex.getError().getErrorCode(); + if (ex.getError().getDescription() != null) { + mesg += " - " + ex.getError().getDescription(); + } + mesg += "]"; + } + return mesg; + } + + /** + * Bundles a {@link ClientHttpConnector} with the Reactor Netty + * {@link ConnectionProvider} and {@link LoopResources} that back it, so they + * can be disposed together via {@link #close()}. + * + *

BLOCK_TIMEOUT must stay under {@code spring.lifecycle.timeout-per-shutdown-phase} + * (default 30s), otherwise Spring kills the JVM before disposal finishes. + * Ideally DISPOSE_TIMEOUT ≥ {@link #RESPONSE_TIMEOUT_DURATION} so in-flight + * requests can finish cleanly, but this is not required. + */ + @Slf4j + public static final class ManagedHttpResources implements AutoCloseable { + static final Duration QUIET_PERIOD = Duration.ofSeconds(2); + static final Duration DISPOSE_TIMEOUT = Duration.ofSeconds(25); + static final Duration BLOCK_TIMEOUT = DISPOSE_TIMEOUT.plusSeconds(2); + + private static final String MESG_DISPOSED_CLEANLY = "%s '%s' disposed cleanly"; + private static final String MESG_DISPOSE_TIMED_OUT = + "%s '%s' dispose did not complete within %s — resources may be force-closed by " + + "Netty shutdown hooks"; + + private final ClientHttpConnector connector; + private final ConnectionProvider connectionProvider; + private final LoopResources loopResources; + private final String name; + + public ManagedHttpResources( + ClientHttpConnector connector, + ConnectionProvider connectionProvider, + LoopResources loopResources, + String name) { + this.connector = connector; + this.connectionProvider = connectionProvider; + this.loopResources = loopResources; + this.name = name; + } + + public ClientHttpConnector connector() { + return connector; + } + + @Override + public void close() { + disposeQuietly("ConnectionProvider", connectionProvider == null ? null : + connectionProvider.disposeLater()); + disposeQuietly("LoopResources", loopResources == null ? null : + loopResources.disposeLater(QUIET_PERIOD, DISPOSE_TIMEOUT)); + } + + private void disposeQuietly(String kind, Mono disposeMono) { + if (disposeMono == null) { + return; + } + try { + disposeMono.block(BLOCK_TIMEOUT); + log.info(MESG_DISPOSED_CLEANLY.formatted(kind, name)); + } catch (Exception ex) { + log.warn(MESG_DISPOSE_TIMED_OUT.formatted(kind, name, BLOCK_TIMEOUT), ex); + } + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctUtils.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctUtils.java new file mode 100644 index 000000000..6ba3f8d62 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/NvctUtils.java @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.nvct.util.NvctConstants.HTTP_METHOD; +import static com.nvidia.nvct.util.NvctConstants.REMOTE_ADDRESS; +import static com.nvidia.nvct.util.NvctConstants.REQUEST_URI; + +import tools.jackson.databind.json.JsonMapper; +import io.micrometer.tracing.Tracer; +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ProblemDetail; + +@Slf4j +@UtilityClass +public final class NvctUtils { + private static final String MISSING_PROBLEM_DETAILS_RESPONSE = + "Missing ProblemDetails response from %s"; + private static final String INVALID_PROBLEM_DETAILS_RESPONSE = + "Invalid ProblemDetails response from {} - '{}'"; + + public static Map getCustomProperties(@Nullable HttpServletRequest request) { + if (request == null) { + // An internal background thread is updating the state and causing the audit log to + // be generated. + return Map.of(REMOTE_ADDRESS, "0.0.0.0"); + } + + return Map.of(REQUEST_URI, request.getRequestURI(), + REMOTE_ADDRESS, request.getRemoteAddr(), + HTTP_METHOD, request.getMethod()); + } + + // Returns detail from ProblemDetails response. + public static String getDetailFromProblemDetailsResponse( + JsonMapper jsonMapper, + String service, + String body) { + if (StringUtils.isBlank(body)) { + return MISSING_PROBLEM_DETAILS_RESPONSE.formatted(service); + } + + try { + var pd = jsonMapper.readValue(body, ProblemDetail.class); + return ((pd != null) && StringUtils.isNotBlank(pd.getDetail())) ? pd.getDetail() : body; + } catch (Exception ex) { + log.warn(INVALID_PROBLEM_DETAILS_RESPONSE, service, ex.getMessage()); + return body; // Return original response body as-is. + } + } + + public static void addTagsToCurrentSpan(Tracer tracer, Map tags) { + var span = tracer.currentSpan(); + if (span != null) { + tags.forEach((key, value) -> span.tag(key, String.valueOf(value))); + } + } + + /** + * Creates a child span with the given name, adds the tags to it, and ends the span. + * The child span becomes the current span for the duration of this method. A parent span + * can have multiple child spans with the same name. Each child span is a separate span + * with its own span ID and timestamps - the name is just a label for the span type. + */ + public static void addTagsToChildSpan( + Tracer tracer, + Map tags, + String childSpanName) { + var span = tracer.nextSpan().name(childSpanName).start(); + try (var unused = tracer.withSpan(span)) { // Replace unused with _(underscore) with Java 25 + tags.forEach((key, value) -> span.tag(key, String.valueOf(value))); + } finally { + span.end(); + } + } + + public static void recordExceptionUsingCurrentSpan(Tracer tracer, Throwable throwable) { + var span = tracer.currentSpan(); + if (span != null) { + span.error(throwable); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/ProtoMappingUtils.java b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/ProtoMappingUtils.java new file mode 100644 index 000000000..cb0887a6a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/java/com/nvidia/nvct/util/ProtoMappingUtils.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import com.google.protobuf.Timestamp; +import java.time.Instant; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ProtoMappingUtils { + + public static Timestamp toTimestamp(Instant instant) { + return Timestamp.newBuilder() + .setSeconds(instant.getEpochSecond()) + .setNanos(instant.getNano()) + .build(); + } + + public static Instant fromTimestamp(Timestamp timestamp) { + return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/main/proto/nvct.proto b/src/control-plane-services/cloud-tasks/nvct-core/src/main/proto/nvct.proto new file mode 100644 index 000000000..80949f474 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/main/proto/nvct.proto @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +syntax = "proto3"; + +package nvct; + +import "google/protobuf/timestamp.proto"; + +option java_multiple_files = true; +option java_package = "com.nvidia.nvct.proto"; + +service Worker { + rpc Connect (ConnectRequest) returns (ConnectResponse); + rpc SendHeartbeat (HeartbeatRequest) returns (HeartbeatResponse); + rpc SendResultMetadata (stream ResultMetadataRequest) returns (ResultMetadataResponse); + rpc GetArtifacts (ArtifactsRequest) returns (ArtifactsResponse); + rpc RefreshToken (RefreshTokenRequest) returns (RefreshTokenResponse); + rpc RequestSecretCredentials (SecretCredentialsRequest) returns (SecretCredentialsResponse); +} + +message ConnectRequest { + string instanceId = 1; + string taskId = 2; +} + +message ConnectResponse { + string connectedRegion = 1; +} + +message HeartbeatRequest { + string instanceId = 1; + string taskId = 2; + string instanceType = 3; + ExecutionStatus status = 4; // ERRORED, EXCEEDED_MAX_DURATION + optional string errorMessage = 5; +} + +message HeartbeatResponse { + string taskId = 1; // UUID + string ExecutionStatus = 2; // COMPLETED, ERRORED, etc. +} + +message StringKV { + string key = 1; + string value = 2; +} + +message ResultMetadata { + bytes body = 1; +} + +message ErrorDetails { + string type = 1; + string title = 2; + uint32 status = 3; + string detail = 4; +} + +enum ExecutionStatus { + ERRORED = 0; + IN_PROGRESS = 1; + COMPLETED = 2; + PENDING_EVALUATION = 3; + EXCEEDED_MAX_RUNTIME_DURATION = 4; + EXCEEDED_MAX_QUEUED_DURATION = 5; + QUEUED = 6; + LAUNCHED = 7; + RUNNING = 8; + CANCELED = 9; + TASK_CONTAINER_INITIALIZING = 10; + WORKER_TERMINATED = 11; +} + +message ResultMetadataRequest { + string taskId = 1; // UUID + string instanceId = 2; // UUID + string instanceType = 3; + ExecutionStatus status = 4; // ERRORED, IN_PROGRESS, COMPLETED, etc. + optional uint32 percentComplete = 5; + string resultName = 6; // Result/Checkpoints metadata + oneof result { + ResultMetadata metadata = 7; // Results/Checkpoints metadata + ErrorDetails errorDetails = 8; + } +} + +message ResultMetadataResponse { + string taskId = 1; // UUID + string ExecutionStatus = 2; // COMPLETED, ERRORED, etc. +} + +message ArtifactsRequest { + string taskId = 1; // UUID +} + +message ArtifactsResponse { + repeated ArtifactResponse artifacts = 1; + message ArtifactResponse { + string name = 1; + string version = 2; + ArtifactKindEnum kind = 3; + repeated ArtifactFile files = 4; + enum ArtifactKindEnum { + MODEL = 0; + RESOURCE = 1; + } + message ArtifactFile { + string path = 1; + string url = 2; + } + } +} + +message RefreshTokenRequest { + string taskId = 1; // UUID +} + +message RefreshTokenResponse { + string token = 1; +} + +message SecretCredentialsRequest { + string taskId = 1; +} + +message SecretCredentialsResponse { + string secretCredentialsToken = 1; + google.protobuf.Timestamp expiration = 2; +} + + +service Skyway { + rpc AuthGetLogs (SkywayAuthRequest) returns (SkywayAuthResponse); + rpc AuthExecuteCommand (SkywayAuthRequest) returns (SkywayAuthResponse); + rpc AuthListInstances (SkywayAuthRequest) returns (SkywayAuthResponse); +} + + +message SkywayAuthRequest { + string clientAuthorizationToken = 1; + string taskId = 2; + // used for super admins (KAS) to invoke on behalf of a given nca id + optional string targetNcaId = 3; +} + +message SkywayAuthResponse { + message Instance { + string instanceId = 1; + string location = 2; + string state = 3; + } + string taskId = 1; + string clientAuthSubject = 2; + string clientNcaId = 3; + optional string backend = 4; + repeated Instance instances = 5; +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/IntegrationTestConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/IntegrationTestConfiguration.java new file mode 100644 index 000000000..3955136c5 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/IntegrationTestConfiguration.java @@ -0,0 +1,446 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.boot.audit.AuditProperties; +import com.nvidia.boot.mock.oauth2.MockOAuth2TokenServer; +import com.nvidia.boot.mock.oauth2.OAuth2TokenServerConfigurationProperties; +import jakarta.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.jar.JarEntry; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.DockerImageName; + +@Slf4j +@Configuration +@EnableCassandraRepositories(basePackages = "com.nvidia") +public class IntegrationTestConfiguration { + + /** + * Classpath prefix for compose assets bundled in nvct-core-tests.jar (see nvct-core {@code + * maven-resources-plugin} {@code copy-integration-local-env}). + */ + private static final String LOCAL_ENV_CLASSPATH_PREFIX = "local_env"; + + private static final String COMPOSE_FILE_NAME = "docker-compose.test.yml"; + + private static final String DOCKER_COMPOSE_IMAGE = "docker:24.0.2"; + + /** + * Relative to working directory (legacy / IDE): {@code local_env/docker-compose.test.yml}. + */ + private static final String FS_FALLBACK_COMPOSE = + LOCAL_ENV_CLASSPATH_PREFIX + "/" + COMPOSE_FILE_NAME; + + /** + * If {@code false}, failure to create an extract directory under {@code target/} is a hard + * error (set in CI via {@code -D...=false} or env). Default {@code true} keeps a + * java.io.tmpdir fallback for constrained local environments. + */ + private static final String ALLOW_TMPDIR_FALLBACK_PROPERTY = + "nvct.integration.extract.allowTmpdirFallback"; + + private static final String ALLOW_TMPDIR_FALLBACK_ENV = "NVCT_INTEGRATION_ALLOW_TMPDIR_FALLBACK"; + + private static final List INTEGRATION_EXTRACT_ROOTS = new CopyOnWriteArrayList<>(); + + private static final AtomicBoolean INTEGRATION_EXTRACT_SHUTDOWN_HOOK = new AtomicBoolean(); + + private static final String KEY_SPACE = "nvct"; + private static final String CASSANDRA_SERVICE_NAME = "cassandra-1"; + private static final int CASSANDRA_PORT = 9042; + + public static final MockOAuth2TokenServer MOCK_OAUTH2_TOKEN_SERVER; // Used in test files + private static final String OAUTH2_TOKEN_ISSUER = "http://localhost:9092"; + private static final String OAUTH2_KEYSET_URL = "http://localhost:9092/.well-known/jwks.json"; + + private static String CASSANDRA_HOST; + private static int CASSANDRA_MAPPED_PORT; + + private static final String AUDIT_SIGNING_KID = "integration-audit-kid"; + private static final byte[] AUDIT_SIGNING_KEY_RAW = new byte[32]; + + static { + for (int i = 0; i < AUDIT_SIGNING_KEY_RAW.length; i++) { + AUDIT_SIGNING_KEY_RAW[i] = (byte) (i + 1); + } + + var cassandraWaitStrategy = new WaitAllStrategy(WaitAllStrategy.Mode.WITH_OUTER_TIMEOUT); + cassandraWaitStrategy.withStartupTimeout(Duration.of(3, ChronoUnit.MINUTES)); + cassandraWaitStrategy.withStrategy( + Wait.forLogMessage(".*Cassandra init scripts executed.*", 1)); + + File composeDir = resolveComposeAssetDirectory(); + File composeFile = new File(composeDir, COMPOSE_FILE_NAME); + if (!composeFile.isFile()) { + throw new IllegalStateException( + "Compose file missing at " + composeFile.getAbsolutePath()); + } + + var composeContainer = createComposeContainer(composeFile) + .withExposedService(CASSANDRA_SERVICE_NAME, + CASSANDRA_PORT, + cassandraWaitStrategy) + .withBuild(false); + + composeContainer.start(); + + CASSANDRA_HOST = composeContainer.getServiceHost(CASSANDRA_SERVICE_NAME, CASSANDRA_PORT); + CASSANDRA_MAPPED_PORT = composeContainer.getServicePort(CASSANDRA_SERVICE_NAME, + CASSANDRA_PORT); + composeContainer.getContainerByServiceName(CASSANDRA_SERVICE_NAME) + .ifPresentOrElse( + container -> log.info(container.getLogs()), + () -> { + throw new IllegalStateException("Missing container " + + CASSANDRA_SERVICE_NAME); + }); + + + MOCK_OAUTH2_TOKEN_SERVER = new MockOAuth2TokenServer( + new OAuth2TokenServerConfigurationProperties(OAUTH2_TOKEN_ISSUER, OAUTH2_KEYSET_URL, + null, null, null, null)); + MOCK_OAUTH2_TOKEN_SERVER.start(); + } + + /** + * Directory containing {@link #COMPOSE_FILE_NAME} and the {@code cassandra/} subtree required + * by that compose file (volume mounts use paths relative to the compose file). + */ + private static File resolveComposeAssetDirectory() { + var cl = IntegrationTestConfiguration.class.getClassLoader(); + var composeUrl = cl.getResource(LOCAL_ENV_CLASSPATH_PREFIX + "/" + COMPOSE_FILE_NAME); + if (composeUrl == null) { + var fallbackCompose = new File(FS_FALLBACK_COMPOSE); + if (fallbackCompose.isFile()) { + File dir = fallbackCompose.getParentFile(); + log.info("Using compose bundle from filesystem: {}", dir.getAbsolutePath()); + return materializeComposeAssets(dir); + } + throw new IllegalStateException( + "Missing classpath resource " + + LOCAL_ENV_CLASSPATH_PREFIX + "/" + COMPOSE_FILE_NAME + + " (ensure nvct-core-tests.jar is on the test classpath and built with" + + " copy-integration-local-env), and no " + FS_FALLBACK_COMPOSE + + " in the working directory."); + } + + try { + if ("file".equalsIgnoreCase(composeUrl.getProtocol())) { + var composePath = Path.of(composeUrl.toURI()); + var dir = composePath.getParent().toFile(); + log.debug("Using compose bundle from filesystem (test-classes): {}", + dir.getAbsolutePath()); + return materializeComposeAssets(dir); + } + + if ("jar".equalsIgnoreCase(composeUrl.getProtocol())) { + return extractLocalEnvFromJar(composeUrl); + } + } catch (IOException | URISyntaxException e) { + throw new IllegalStateException("Failed to resolve compose bundle", e); + } + + throw new IllegalStateException( + "Unsupported compose URL protocol: " + composeUrl + "; expected file or jar."); + } + + /** + * Extract {@code local_env/**} from the tests JAR so Docker can bind-mount files by host path. + * Uses {@code target/nvct-integration-local-env-*} under the module directory (same class of path + * as checkout-local {@code local_env/}), not {@code java.io.tmpdir}. Nested/containerised Compose + * in CI often fails mounts from {@code /tmp} while mounts from the job workspace succeed. + */ + private static File extractLocalEnvFromJar(URL composeUrlInsideJar) throws IOException { + + JarURLConnection connection = (JarURLConnection) composeUrlInsideJar.openConnection(); + try (var jarFile = connection.getJarFile()) { + var extractRoot = createIntegrationExtractDirectory().normalize(); + var prefix = LOCAL_ENV_CLASSPATH_PREFIX + "/"; + + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + var name = entry.getName(); + if (!name.startsWith(prefix) || entry.isDirectory()) { + continue; + } + + var dest = extractRoot.resolve(name.substring(prefix.length())).normalize(); + // Zip-slip guard: a crafted entry name (e.g. containing "..") must + // never resolve outside the extraction root. + if (!dest.startsWith(extractRoot)) { + throw new IOException( + "Refusing to extract entry outside the target directory: " + name); + } + Files.createDirectories(dest.getParent()); + try (var in = jarFile.getInputStream(entry)) { + Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING); + } + if (name.endsWith("entrypoint.sh")) { + try { + Files.setPosixFilePermissions(dest, + PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystems (e.g. Windows): Docker Desktop still runs via bash. + } catch (IOException e) { + throw new IllegalStateException("Could not chmod entrypoint script", e); + } + } + } + + log.info("Extracted integration compose bundle from {} to {}", + jarFile.getName(), extractRoot.toAbsolutePath()); + return extractRoot.toFile(); + } + } + + /** + * Copy a filesystem compose bundle into a directory of real files, dereferencing symlinks. + * + *

Under Bazel the test runfiles present {@code local_env/**} as symlinks into the build + * sandbox. Docker bind-mounts of a directory follow the mount but leave those symlinks dangling + * inside the container (their targets do not exist there), so {@code ./cassandra/schema} mounts + * empty: {@code entrypoint.sh} finds no {@code *.cql}, the {@code nvct} keyspace is never + * created, and Spring fails to start with {@code InvalidKeyspaceException}. Materialising to real + * files makes the bind mounts serve actual content, the same way {@link #extractLocalEnvFromJar} + * does for the Maven/JAR path. A no-op-ish copy on a plain checkout (real files already). + */ + private static File materializeComposeAssets(File sourceDir) { + try { + var srcRoot = sourceDir.toPath(); + var extractRoot = createIntegrationExtractDirectory(); + try (var walk = Files.walk(srcRoot, FileVisitOption.FOLLOW_LINKS)) { + for (var src : (Iterable) walk::iterator) { + var dest = extractRoot.resolve(srcRoot.relativize(src).toString()); + if (Files.isDirectory(src)) { + Files.createDirectories(dest); + continue; + } + Files.createDirectories(dest.getParent()); + Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING); + if ("entrypoint.sh".equals(src.getFileName().toString())) { + try { + Files.setPosixFilePermissions(dest, + PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem; Docker still runs the script via bash. + } + } + } + } + log.info("Materialized integration compose bundle from {} to {}", srcRoot, + extractRoot.toAbsolutePath()); + return extractRoot.toFile(); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to materialize compose bundle from " + sourceDir, e); + } + } + + /** + * Creates a directory for JAR-extracted compose assets, preferring {@code target/} under the + * module. Registers recursive deletion on JVM exit (unlike {@link File#deleteOnExit()} on a + * directory, which does not remove contents). + */ + private static Path createIntegrationExtractDirectory() throws IOException { + var target = Path.of(System.getProperty("user.dir"), "target"); + try { + Files.createDirectories(target); + var dir = Files.createTempDirectory(target, "nvct-integration-local-env-"); + registerExtractRootForCleanup(dir); + return dir; + } catch (IOException e) { + if (!isTmpdirFallbackAllowed()) { + throw new IllegalStateException( + "Could not create integration extract directory under " + target.toAbsolutePath() + + ". Set " + ALLOW_TMPDIR_FALLBACK_PROPERTY + "=true or " + + ALLOW_TMPDIR_FALLBACK_ENV + "=true to allow java.io.tmpdir, or fix " + + "permissions on target/.", + e); + } + log.warn("Could not extract under {}; using java.io.tmpdir: {}", + target.toAbsolutePath(), e.toString()); + var fallback = Files.createTempDirectory("nvct-integration-local-env"); + registerExtractRootForCleanup(fallback); + return fallback; + } + } + + private static boolean isTmpdirFallbackAllowed() { + var fromEnv = System.getenv(ALLOW_TMPDIR_FALLBACK_ENV); + if (fromEnv != null && !fromEnv.isBlank()) { + return Boolean.parseBoolean(fromEnv); + } + return Boolean.parseBoolean(System.getProperty(ALLOW_TMPDIR_FALLBACK_PROPERTY, "true")); + } + + private static void registerExtractRootForCleanup(Path root) { + if (INTEGRATION_EXTRACT_SHUTDOWN_HOOK.compareAndSet(false, true)) { + Runtime.getRuntime().addShutdownHook( + new Thread(IntegrationTestConfiguration::deleteIntegrationExtractRoots, + "nvct-integration-extract-cleanup")); + } + INTEGRATION_EXTRACT_ROOTS.add(root); + } + + private static void deleteIntegrationExtractRoots() { + for (var root : INTEGRATION_EXTRACT_ROOTS) { + deleteRecursively(root); + } + } + + private static void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) { + return; + } + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach( + p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best-effort (shutdown hook) + } + }); + } catch (IOException ignored) { + // walk failed — best-effort + } + } + + // This is needed for tests to run consistently locally and also in the CI pipeline. + // Testcontainers 2.x uses the host's Docker Compose CLI for the File-only constructor. The + // Maven image used in the CI pipeline can access the Docker daemon, but it does not include + // the Docker Compose CLI. So, use local Docker Compose CLI when available. Otherwise, run + // Compose using a pinned Docker image for reproducible local and CI test execution. + private static ComposeContainer createComposeContainer(File composeFile) { + if (isLocalComposeAvailable()) { + return new ComposeContainer(composeFile); + } + + return new ComposeContainer(DockerImageName.parse(DOCKER_COMPOSE_IMAGE), composeFile); + } + + private static boolean isLocalComposeAvailable() { + try { + var process = new ProcessBuilder("docker", "compose", "version") + .redirectErrorStream(true) + .start(); + boolean completed = process.waitFor(5, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (IOException e) { + return false; + } + } + + public static class Initializer implements + ApplicationContextInitializer { + + @Override + public void initialize(@Nonnull ConfigurableApplicationContext applicationContext) { + TestPropertyValues.of( + "spring.cassandra.contact-points=" + CASSANDRA_HOST, + "spring.cassandra.port=" + CASSANDRA_MAPPED_PORT, + "spring.cassandra.local-datacenter=datacenter1", + "spring.cassandra.keyspace-name=" + KEY_SPACE, + "spring.cassandra.ssl.enabled=false", + "spring.cassandra.ssl.bundle=", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + OAUTH2_TOKEN_ISSUER, + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri=" + OAUTH2_KEYSET_URL, + "spring.security.oauth2.resourceserver.jwt.jws-algorithms=ES256" + ).applyTo(applicationContext); + } + } + + /** + * Mock clock so it can be set to appropriate value for functional testing. + */ + @Primary + @Bean("mockClock") + public Clock getMockClock() { + var clock = mock(Clock.class); + when(clock.instant()).thenReturn(Instant.now()); + return clock; + } + + @Bean + public AuditProperties auditProperties(JsonMapper jsonMapper) { + ArrayNode keys = jsonMapper.createArrayNode(); + ObjectNode entry = jsonMapper.createObjectNode(); + entry.put("kid", AUDIT_SIGNING_KID); + entry.put("key", Base64.getEncoder().encodeToString(AUDIT_SIGNING_KEY_RAW)); + keys.add(entry); + + ObjectNode root = jsonMapper.createObjectNode(); + root.set("keys", keys); + + var props = new AuditProperties(); + props.setHmacKid(AUDIT_SIGNING_KID); + try { + props.setHmacKeys(Base64.getEncoder() + .encodeToString(jsonMapper.writeValueAsBytes(root))); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize HMAC keys configuration", e); + } + return props; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/NvctTestApp.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/NvctTestApp.java new file mode 100644 index 000000000..1e1b53005 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/NvctTestApp.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.micrometer.tracing.autoconfigure.prometheus.PrometheusExemplarsAutoConfiguration; +import org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration; + +/** + * Test-only Spring Boot application class for nvct-core. + * Core is a pure library with no main class — this provides the boot context for tests. + */ +@SpringBootApplication(exclude = { + ReactiveUserDetailsServiceAutoConfiguration.class, + PrometheusExemplarsAutoConfiguration.class}) +public class NvctTestApp { +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingIntegrationTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingIntegrationTest.java new file mode 100644 index 000000000..2d1fb988b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingIntegrationTest.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.actuator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +/** + * Integration tests verifying that actuator endpoints on the management port + * produce traces when using a separate management server (ManagementTracingConfiguration). + */ +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class, + ActuatorTracingTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "management.server.port=0", + "management.tracing.enabled=true" + }) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class ActuatorTracingIntegrationTest { + + private static final AttributeKey URL_PATH = AttributeKey.stringKey("url.path"); + private static final AttributeKey HTTP_REQUEST_METHOD = + AttributeKey.stringKey("http.request.method"); + + @LocalManagementPort + private int managementPort; + + @Autowired + private TestRestTemplate testRestTemplate; + + @BeforeEach + void resetSpanExporter() { + ActuatorTracingTestConfiguration.SPAN_EXPORTER.reset(); + } + + private String actuatorUrl(String path) { + return "http://localhost:" + managementPort + path; + } + + private List awaitSpans() { + return await() + .atMost(Duration.ofSeconds(5)) + .until( + ActuatorTracingTestConfiguration.SPAN_EXPORTER::getFinishedSpanItems, + items -> !items.isEmpty()); + } + + @Test + @DisplayName("Actuator health endpoint produces HTTP trace span") + void actuatorHealthEndpointProducesTrace() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/health"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + List spans = awaitSpans(); + + assertThat(spans) + .anyMatch(span -> hasHttpPath(span, "/actuator/health") + && hasHttpMethod(span, "GET")); + } + + @Test + @DisplayName("Actuator prometheus endpoint produces HTTP trace span") + void actuatorPrometheusEndpointProducesTrace() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/prometheus"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + List spans = awaitSpans(); + + assertThat(spans) + .anyMatch(span -> hasHttpPath(span, "/actuator/prometheus") + && hasHttpMethod(span, "GET")); + } + + private static boolean hasHttpPath(SpanData span, String path) { + var urlPath = span.getAttributes().get(URL_PATH); + return urlPath != null && urlPath.contains(path); + } + + private static boolean hasHttpMethod(SpanData span, String method) { + var httpMethod = span.getAttributes().get(HTTP_REQUEST_METHOD); + return method.equals(httpMethod); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingTestConfiguration.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingTestConfiguration.java new file mode 100644 index 000000000..0da71f118 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/ActuatorTracingTestConfiguration.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.actuator; + +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * Test configuration that provides an in-memory span exporter for asserting + * that actuator endpoints produce traces when using a separate management port. + * + *

Uses {@link SimpleSpanProcessor} so spans are exported immediately rather than + * batched, avoiding timing-related test failures with {@link io.opentelemetry.sdk.trace.export.BatchSpanProcessor}. + */ +@Configuration +public class ActuatorTracingTestConfiguration { + + public static final InMemorySpanExporter SPAN_EXPORTER = InMemorySpanExporter.create(); + + @Bean + @Primary + public SpanExporter inMemorySpanExporter() { + return SPAN_EXPORTER; + } + + /** + * Ensures spans are exported immediately to the in-memory exporter for reliable test assertions. + */ + @Bean + public SpanProcessor testSpanProcessor() { + return SimpleSpanProcessor.create(SPAN_EXPORTER); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/MetricsIntegrationTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/MetricsIntegrationTest.java new file mode 100644 index 000000000..2b73be16c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/actuator/MetricsIntegrationTest.java @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.actuator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.TEXT_PLAIN; + +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +/** + * Integration tests for the public actuator surface on the management port. + * Uses a separate management port (management.server.port=0) so actuator is tested on its own + * server. Verifies the expected exposed endpoints remain available and non-allowed endpoints are + * not exposed. + */ +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "management.server.port=0" + }) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class MetricsIntegrationTest { + + @LocalManagementPort + private int managementPort; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private MeterRegistry meterRegistry; + + private String actuatorUrl(String path) { + return "http://localhost:" + managementPort + path; + } + + @Test + @DisplayName("Actuator health endpoint is available") + void actuatorHealthEndpointIsAvailable() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/health"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Actuator liveness endpoint is available") + void actuatorLivenessEndpointIsAvailable() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/health/liveness"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Actuator readiness endpoint is available") + void actuatorReadinessEndpointIsAvailable() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/health/readiness"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Prometheus endpoint returns 200 and Prometheus text format") + void prometheusEndpointReturnsMetrics() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/prometheus"), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()) + .isNotNull() + .satisfies(mediaType -> assertThat(mediaType.isCompatibleWith(TEXT_PLAIN)).isTrue()); + + var body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body).contains("# HELP"); + assertThat(body).contains("# TYPE"); + } + + @Test + @DisplayName("Prometheus endpoint includes JVM metrics") + void prometheusEndpointIncludesJvmMetrics() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/prometheus"), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body).contains("jvm_"); + } + + @Test + @DisplayName("Metrics endpoint is available") + void metricsEndpointIsAvailable() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/metrics"), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Environment endpoint is not exposed via management.endpoints.web.exposure.include") + void envEndpointIsNotAnonymouslyAvailable() { + ResponseEntity response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/env"), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("MeterRegistry is configured and has metrics") + void meterRegistryHasMetrics() { + assertThat(meterRegistry).isNotNull(); + assertThat(meterRegistry.getMeters()).isNotEmpty(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandlerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandlerTest.java new file mode 100644 index 000000000..787b9b8d3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/configuration/exceptions/ValidationAwareExceptionHandlerTest.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.configuration.exceptions; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import tools.jackson.databind.node.StringNode; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class ValidationAwareExceptionHandlerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @Test + void validationErrorShouldContainDetails() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), 100); + var tags = Set.of(StringUtils.repeat("tag1", MAX_TAG_LENGTH), + StringUtils.repeat("tag2", MAX_TAG_LENGTH)); + var secrets = Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("shhh!shhh!")) + .build(), + SecretDto.builder() + .name("secret2") + .value(new StringNode("confidential")) + .build()); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(tags) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(3)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(secrets) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, String.class); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + log.info(responseEntity.getBody()); + // Error message should contain the failed value, the max correct value, and the + // field in error + assertThat(responseEntity.getBody()).contains(Integer.toString(MAX_TAG_LENGTH), "tags"); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcSkywayServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcSkywayServiceTest.java new file mode 100644 index 000000000..967fdd805 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcSkywayServiceTest.java @@ -0,0 +1,461 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.HEALTHY; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.MD_KEY_AUTHORIZATION; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.proto.SkywayAuthRequest; +import com.nvidia.nvct.proto.SkywayAuthResponse; +import com.nvidia.nvct.proto.SkywayGrpc; +import com.nvidia.nvct.rest.task.dto.InstanceStateEnum; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.MetadataUtils; +import java.net.URL; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "grpc.server.port=9090" + }) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class GrpcSkywayServiceTest { + private static final String SKYWAY_AUTH_SCOPE = "skyway:auth"; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void setupMocks() { + var mockIcmsServerHealthContexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(HEALTHY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, mockIcmsServerHealthContexts); + MockNvcfServer.start(nvcfBaseUrl); + } + + @AfterAll + void cleanupMocks() { + MockIcmsServer.stop(); + MockNvcfServer.stop(); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + @Test + void authGetLogs() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + var authResponse = proxyBlockingStub.authGetLogs(proxyAuthRequest); + validateSuccessResponse(authResponse); + channel.shutdownNow(); + } + + @Test + void authGetLogsInvalidClientSecretScope() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_DELETE_TASK), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authGetLogs(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void authGetLogsInvalidClientSecret() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = "invalid_secret"; + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authGetLogs(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("UNAUTHENTICATED"); + channel.shutdownNow(); + } + + @Test + void authExecuteCommand() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_LAUNCH_TASK), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + var authResponse = proxyBlockingStub.authExecuteCommand(proxyAuthRequest); + validateSuccessResponse(authResponse); + channel.shutdownNow(); + } + + @Test + void authExecuteCommandInvalidClientSecretScope() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_DELETE_TASK), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authExecuteCommand(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void authExecuteCommandInvalidClientSecret() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = "invalid_secret"; + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authExecuteCommand(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("UNAUTHENTICATED"); + channel.shutdownNow(); + } + + @Test + void authListInstances() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + var authResponse = proxyBlockingStub.authListInstances(proxyAuthRequest); + validateSuccessResponse(authResponse); + channel.shutdownNow(); + } + + @Test + void authListInstancesInvalidAccountAccess() { + testTaskService.createTask(TEST_NCA_ID_2, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authListInstances(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void authListInstancesInvalidClientSecretScope() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_DELETE_TASK), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authListInstances(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void authListInstancesInvalidClientSecret() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = "invalid_secret"; + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authListInstances(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("UNAUTHENTICATED"); + channel.shutdownNow(); + } + + @Test + void invalidServiceSecretScopes() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SCOPE_DELETE_TASK); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authListInstances(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void invalidServiceSecret() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.RUNNING); + var md = new Metadata(); + var jwt = "invalid_secret"; + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = + MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authListInstances(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("UNAUTHENTICATED"); + channel.shutdownNow(); + } + + @Test + void inactiveFunction() { + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_TASKS), 100); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authExecuteCommand(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + @Test + void nonExistingFunction() { + var md = new Metadata(); + var jwt = MOCK_OAUTH2_TOKEN_SERVER.getJwt(SKYWAY_AUTH_SCOPE); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwt); + var channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + var proxyBlockingStub = SkywayGrpc.newBlockingStub(channel); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_TASKS), 100); + var notExistingTaskId = UUID.randomUUID().toString(); + var proxyAuthRequest = SkywayAuthRequest.newBuilder() + .setTaskId(notExistingTaskId) + .setClientAuthorizationToken(clientAuth) + .build(); + assertThatThrownBy(() -> proxyBlockingStub.authExecuteCommand(proxyAuthRequest)) + .isInstanceOf(StatusRuntimeException.class) + .hasMessageContaining("PERMISSION_DENIED"); + channel.shutdownNow(); + } + + private static void validateSuccessResponse(SkywayAuthResponse authResponse) { + assertThat(authResponse).isNotNull(); + assertThat(authResponse.getTaskId()).isEqualTo(TEST_TASK_ID_1.toString()); + assertThat(authResponse.getClientAuthSubject()).isEqualTo(TEST_CLIENT_SUBJECT); + assertThat(authResponse.getClientNcaId()).isEqualTo(TEST_NCA_ID); + assertThat(authResponse.getBackend()).isEmpty(); // For backward compatible only + assertThat(authResponse.getInstancesList()).hasSize(1); + authResponse.getInstancesList().forEach(instance -> { + assertThat(instance.getInstanceId()).isNotEmpty(); + // Hardcoded in `src/test/resources/fixtures/icms/raw-healthy-instance.json` + assertThat(instance.getLocation()).isEqualTo("NP-LAX-03"); + assertThat(instance.getState()).isEqualTo(InstanceStateEnum.RUNNING.toString()); + }); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerRefreshSecretAssertionsTokenTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerRefreshSecretAssertionsTokenTest.java new file mode 100644 index 000000000..13035b9b4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerRefreshSecretAssertionsTokenTest.java @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.util.ProtoMappingUtils.fromTimestamp; +import static com.nvidia.nvct.util.TestConstants.MD_KEY_AUTHORIZATION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_TELEMETRIES_4; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_LOGS_ID; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.proto.SecretCredentialsRequest; +import com.nvidia.nvct.proto.WorkerGrpc; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.service.token.TokenService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.stub.MetadataUtils; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "grpc.server.port=9090" + }) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class GrpcWorkerRefreshSecretAssertionsTokenTest { + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TokenService tokenService; + + @Autowired + private TaskService taskService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private EssService essService; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + private ManagedChannel channel; + private WorkerGrpc.WorkerBlockingStub workerBlockingStub; + private WorkerGrpc.WorkerStub workerStub; + + @SneakyThrows + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + channel.shutdown(); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + } + + private void setupGrpc(String jwtToken) { + var md = new Metadata(); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwtToken); + channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + workerBlockingStub = WorkerGrpc.newBlockingStub(channel); + workerStub = WorkerGrpc.newStub(channel); + } + + @Test + void refreshSecretsAssertionTokenForTaskWithoutSecretsAndTelemetries() { + // Create Task with no secrets. + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, TEST_TASK_ID_1); + setupGrpc(token); + + var taskId = TEST_TASK_ID_1.toString(); + var request = SecretCredentialsRequest.newBuilder().setTaskId(taskId).build(); + var response = workerBlockingStub.requestSecretCredentials(request); + assertThat(response).isNotNull(); + assertThat(response.getExpiration()).isNotNull(); + assertThat(response.getSecretCredentialsToken()).isBlank(); + assertThat(fromTimestamp(response.getExpiration())) + .isAfterOrEqualTo(Instant.now().plus(Duration.ofHours(1))); + } + + @Test + void refreshSecretsAssertionTokenForTaskWithSecretsAndWithoutTelemetries() { + // Create Task with secrets. + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + essService.saveSecrets(TEST_TASK_ID_1, TEST_SECRETS); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, TEST_TASK_ID_1); + setupGrpc(token); + + var taskId = TEST_TASK_ID_1.toString(); + var request = SecretCredentialsRequest.newBuilder().setTaskId(taskId).build(); + var response = workerBlockingStub.requestSecretCredentials(request); + assertThat(response).isNotNull(); + assertThat(response.getExpiration()).isNotNull(); + assertThat(response.getSecretCredentialsToken()).isNotBlank(); + assertThat(fromTimestamp(response.getExpiration())) + .isAfterOrEqualTo(Instant.now().plus(Duration.ofHours(1))); + } + + @Test + void refreshSecretsAssertionTokenForTaskWithoutSecretsAndWithTelemetries() { + // Create Task with Telemetries defined in account TEST_NCA_ID_WITH_TELEMETRIES_4. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID).build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1); + setupGrpc(token); + + var taskId = TEST_TASK_ID_1.toString(); + var request = SecretCredentialsRequest.newBuilder().setTaskId(taskId).build(); + var response = workerBlockingStub.requestSecretCredentials(request); + assertThat(response).isNotNull(); + assertThat(response.getExpiration()).isNotNull(); + assertThat(response.getSecretCredentialsToken()).isNotBlank(); + assertThat(fromTimestamp(response.getExpiration())) + .isAfterOrEqualTo(Instant.now().plus(Duration.ofHours(1))); + } + + @Test + void refreshSecretsAssertionTokenForTaskWithSecretsAndWithTelemetries() { + // Create Task with secrets amd Telemetries defined in account TEST_NCA_ID_WITH_TELEMETRIES_4. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID).build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + essService.saveSecrets(TEST_TASK_ID_1, TEST_SECRETS); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1); + setupGrpc(token); + + var taskId = TEST_TASK_ID_1.toString(); + var request = SecretCredentialsRequest.newBuilder().setTaskId(taskId).build(); + var response = workerBlockingStub.requestSecretCredentials(request); + assertThat(response).isNotNull(); + assertThat(response.getExpiration()).isNotNull(); + assertThat(response.getSecretCredentialsToken()).isNotBlank(); + assertThat(fromTimestamp(response.getExpiration())) + .isAfterOrEqualTo(Instant.now().plus(Duration.ofHours(1))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerServiceTest.java new file mode 100644 index 000000000..3d14b15eb --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/GrpcWorkerServiceTest.java @@ -0,0 +1,817 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.proto.ArtifactsResponse.ArtifactResponse.ArtifactKindEnum.MODEL; +import static com.nvidia.nvct.proto.ArtifactsResponse.ArtifactResponse.ArtifactKindEnum.RESOURCE; +import static com.nvidia.nvct.proto.ExecutionStatus.COMPLETED; +import static com.nvidia.nvct.proto.ExecutionStatus.ERRORED; +import static com.nvidia.nvct.proto.ExecutionStatus.EXCEEDED_MAX_RUNTIME_DURATION; +import static com.nvidia.nvct.proto.ExecutionStatus.IN_PROGRESS; +import static com.nvidia.nvct.proto.ExecutionStatus.LAUNCHED; +import static com.nvidia.nvct.proto.ExecutionStatus.PENDING_EVALUATION; +import static com.nvidia.nvct.proto.ExecutionStatus.TASK_CONTAINER_INITIALIZING; +import static com.nvidia.nvct.proto.ExecutionStatus.WORKER_TERMINATED; +import static com.nvidia.nvct.util.ProtoMappingUtils.fromTimestamp; +import static com.nvidia.nvct.util.TestConstants.L40G_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestConstants.MD_KEY_AUTHORIZATION; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.google.protobuf.ByteString; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.proto.ArtifactsRequest; +import com.nvidia.nvct.proto.ArtifactsResponse; +import com.nvidia.nvct.proto.ConnectRequest; +import com.nvidia.nvct.proto.ErrorDetails; +import com.nvidia.nvct.proto.HeartbeatRequest; +import com.nvidia.nvct.proto.HeartbeatResponse; +import com.nvidia.nvct.proto.RefreshTokenRequest; +import com.nvidia.nvct.proto.ResultMetadata; +import com.nvidia.nvct.proto.ResultMetadataRequest; +import com.nvidia.nvct.proto.SecretCredentialsRequest; +import com.nvidia.nvct.proto.WorkerGrpc; +import com.nvidia.nvct.rest.result.dto.ResultDto; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.service.token.TokenService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "grpc.server.port=9090" + }) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class GrpcWorkerServiceTest { + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TaskService taskService; + + @Autowired + private TokenService tokenService; + + @Autowired + private ResultService resultService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + private ManagedChannel channel; + private WorkerGrpc.WorkerBlockingStub workerBlockingStub; + private WorkerGrpc.WorkerStub workerStub; + + @SneakyThrows + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + } + + @AfterAll + void cleanup() { + MockCasServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + channel.shutdown(); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + testTaskService.createTaskWithModel(TEST_NCA_ID, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_2, + TEST_MODELS); + testTaskService.createTaskWithModelAndResources(TEST_NCA_ID_2, TEST_TASK_ID_3, + TEST_ICMS_REQ_ID_3); + } + + private void setupGrpc(String jwtToken) { + var md = new Metadata(); + md.put(MD_KEY_AUTHORIZATION, "Bearer " + jwtToken); + channel = ManagedChannelBuilder + .forAddress("localhost", 9090) + .usePlaintext() + .intercept(MetadataUtils.newAttachHeadersInterceptor(md)) + .build(); + workerBlockingStub = WorkerGrpc.newBlockingStub(channel); + workerStub = WorkerGrpc.newStub(channel); + } + + private void resetGrpc(String jwtToken) { + channel.shutdown(); + setupGrpc(jwtToken); + } + + Stream connectArgs() { + return Stream.of( + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + Status.Code.OK), + // task-id mismatch + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_2, + Status.Code.PERMISSION_DENIED) + ); + } + + @ParameterizedTest + @MethodSource("connectArgs") + void testConnect( + String token, + UUID taskIdConnect, + Status.Code expectedStatus) { + setupGrpc(token); + validateTaskStatus(TEST_NCA_ID, taskIdConnect, TaskStatus.QUEUED); + var status = Status.Code.OK; + try { + connect(taskIdConnect); + validateTaskStatus(TEST_NCA_ID, taskIdConnect, TaskStatus.LAUNCHED); + assertThat(testTaskService.getEventCount(taskIdConnect)).isEqualTo(1); + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + assertThat(status).isEqualTo(expectedStatus); + } + + @Test + void testConnectRejectsUnsignedWorkerAssertion() { + var unsignedToken = new PlainJWT(new JWTClaimsSet.Builder() + .issuer(notaryBaseUrl) + .subject(notaryClientId) + .issueTime(new Date()) + .claim("assertion", + Map.of("ncaId", TEST_NCA_ID, "taskId", TEST_TASK_ID_1.toString())) + .build()).serialize(); + + setupGrpc(unsignedToken); + validateTaskStatus(TEST_NCA_ID, TEST_TASK_ID_1, TaskStatus.QUEUED); + + var status = Status.Code.OK; + try { + connect(TEST_TASK_ID_1); + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + + assertThat(status).isEqualTo(Status.Code.PERMISSION_DENIED); + validateTaskStatus(TEST_NCA_ID, TEST_TASK_ID_1, TaskStatus.QUEUED); + } + + Stream heartBeatArgs() { + var requestOk = HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .build(); + var requestErr = HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setErrorMessage("I am in error") + .setStatus(ERRORED) + .setInstanceType(L40G_INSTANCE_TYPE) + .build(); + var requestErrNoInstance = HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setErrorMessage("I am in error") + .build(); + var requestErrBadInstance = + HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setErrorMessage("I am in error") + .setInstanceType("I am a teapot!") + .build(); + var requestOkErrrored = + HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setInstanceType(L40G_INSTANCE_TYPE) + .setStatus(ERRORED) + .setErrorMessage("System error - ESS Agent failed") + .build(); + var requestOkExceededMaxDuration = + HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setInstanceType(L40G_INSTANCE_TYPE) + .setStatus(EXCEEDED_MAX_RUNTIME_DURATION) + .setErrorMessage("System error - Exceeded specified max runtime duration") + .build(); + var requestContainerInitializing = + HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setInstanceType(L40G_INSTANCE_TYPE) + .setStatus(TASK_CONTAINER_INITIALIZING) + .build(); + var workerTerminated = + HeartbeatRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setInstanceType(L40G_INSTANCE_TYPE) + .setStatus(WORKER_TERMINATED) + .build(); + + return Stream.of( + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.RUNNING, + TaskStatus.RUNNING, + requestOk, + null, + Status.Code.OK), + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.RUNNING, + TaskStatus.ERRORED, + requestOk, + requestOkErrrored, + Status.Code.OK), + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.RUNNING, + TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION, + requestOk, + requestOkExceededMaxDuration, + Status.Code.OK), + // ok followed by error + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.RUNNING, + TaskStatus.ERRORED, + requestOk, + requestErr, + Status.Code.OK), + // task-id mismatch + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.LAUNCHED, + TaskStatus.LAUNCHED, + requestOk.toBuilder().setTaskId(TEST_TASK_ID_2.toString()).build(), + null, + Status.Code.PERMISSION_DENIED), + // invalid instance in error details + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.RUNNING, + TaskStatus.RUNNING, + requestOk, + requestErrNoInstance, + Status.Code.INTERNAL), + // Heartbeat with TASK_CONTAINER_INITIALIZING as ExecutionStatus + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.LAUNCHED, + TaskStatus.LAUNCHED, + requestContainerInitializing, + null, + Status.Code.OK), + // Heartbeat with WORKER_TERMINATED as ExecutionStatus + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + TaskStatus.LAUNCHED, + TaskStatus.LAUNCHED, + workerTerminated, + null, + Status.Code.OK) + // ### Temporarily commented out till NVCA sends correct instanceType to Utils. + // Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + // TEST_TASK_ID_1), + // TEST_TASK_ID_1, + // TaskStatus.RUNNING, + // TaskStatus.RUNNING, + // requestOk, + // requestErrBadInstance, + // Status.Code.INTERNAL) + ); + } + + @ParameterizedTest + @MethodSource("heartBeatArgs") + void testHeartBeat( + String token, + UUID taskId, + TaskStatus initialStatus, + TaskStatus finalStatus, + HeartbeatRequest reqOk, + HeartbeatRequest reqErr, + Status.Code expectedStatus) { + setupGrpc(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, taskId)); + connect(taskId); + resetGrpc(token); + var status = Status.Code.OK; + try { + var responseOK = sendHeartbeat(reqOk); + assertThat(responseOK.getTaskId()).isEqualTo(taskId.toString()); + assertThat(responseOK.getExecutionStatus()).isEqualTo(LAUNCHED.toString()); + validateTaskStatus(TEST_NCA_ID, taskId, initialStatus); + connect(taskId); + validateTaskStatus(TEST_NCA_ID, taskId, initialStatus); + if ((reqOk.getStatus() == TASK_CONTAINER_INITIALIZING) + || (reqOk.getStatus() == WORKER_TERMINATED)) { + assertThat(testTaskService.getEventCount(taskId)).isEqualTo(1); + } else { + assertThat(testTaskService.getEventCount(taskId)).isEqualTo(2); + } + // Send same heartbeat again + // This time the task execution status from last grpc call should be reflected + responseOK = sendHeartbeat(reqOk); + assertThat(responseOK.getTaskId()).isEqualTo(taskId.toString()); + assertThat(responseOK.getExecutionStatus()).isEqualTo(initialStatus.toString()); + if (reqErr != null) { + var responseErr = sendHeartbeat(reqErr); + assertThat(responseErr.getTaskId()).isEqualTo(taskId.toString()); + assertThat(responseErr.getExecutionStatus()).isEqualTo(initialStatus.toString()); + validateTaskStatus(TEST_NCA_ID, taskId, finalStatus); + // Send same heartbeat again + // This time the errored task execution status from last grpc call should be reflected + responseErr = sendHeartbeat(reqErr); + assertThat(responseErr.getTaskId()).isEqualTo(taskId.toString()); + assertThat(responseErr.getExecutionStatus()).isEqualTo(finalStatus.toString()); + } + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + assertThat(status).isEqualTo(expectedStatus); + } + + Stream artifactsArgs() { + return Stream.of( + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + 0, 0, 0, 0, 0, + Status.Code.OK), + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_2), + TEST_TASK_ID_2, + 2, 2, 4, 0, 0, + Status.Code.OK), + // task-id mismatch + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_2, + 0, 0, 0, 0, 0, + Status.Code.PERMISSION_DENIED) + ); + } + + @ParameterizedTest + @MethodSource("artifactsArgs") + void testGetArtifacts( + String token, + UUID taskId, + int artifactCount, + int modelCount, + int modelFiles, + int resourceCount, + int resourceFiles, + Status.Code expectedStatus) { + setupGrpc(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, taskId)); + connect(taskId); + resetGrpc(token); + var status = Status.Code.OK; + var request = ArtifactsRequest.newBuilder() + .setTaskId(taskId.toString()) + .build(); + try { + var artifacts = getArtifacts(request); + assertThat(artifacts).isNotNull(); + assertThat(artifacts.getArtifactsCount()).isEqualTo(artifactCount); + for (int i = 0; i < artifactCount; i++) { + if (artifacts.getArtifacts(i).getKind().equals(MODEL)) { + modelCount--; + modelFiles -= artifacts.getArtifacts(i).getFilesCount(); + } else if (artifacts.getArtifacts(i).getKind().equals(RESOURCE)) { + resourceCount--; + resourceFiles -= artifacts.getArtifacts(i).getFilesCount(); + } + } + assertThat(modelCount).isZero(); + assertThat(resourceCount).isZero(); + assertThat(modelFiles).isZero(); + assertThat(resourceFiles).isZero(); + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + assertThat(status).isEqualTo(expectedStatus); + } + + Stream resultMetadataArgs() { + var metadataJSON = """ + {"step-number": 2000, "token_accuracy": 0.874} + """; + + var metadata = ResultMetadata.newBuilder() + .setBody(ByteString.copyFromUtf8(metadataJSON)) + .build(); + var metadataText = "I am a teapot!"; + var metadataErr = ResultMetadata.newBuilder() + .setBody(ByteString.copyFromUtf8(metadataText)) + .build(); + var resultMetadataRequest = ResultMetadataRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setPercentComplete(10) + .setResultName("Name 10") + .setStatus(IN_PROGRESS) + .setMetadata(metadata) + .setInstanceType(L40G_INSTANCE_TYPE) + .build(); + var errorDetails = ErrorDetails.newBuilder() + .setType("Type1") + .setTitle("Failed Execution") + .setStatus(75) + .setDetail("Failed Execution") + .build(); + return Stream.of( + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + resultMetadataRequest.toBuilder() + .setStatus(COMPLETED) + .setPercentComplete(100) + .build(), + 100, + Status.Code.OK), + // Null ResultMetadata and null ErrorDetails + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + ResultMetadataRequest.newBuilder() + .setTaskId(TEST_TASK_ID_1.toString()) + .setPercentComplete(50) + .setResultName("PERCENT_50") + .setStatus(IN_PROGRESS) + .setInstanceType(L40G_INSTANCE_TYPE) + .build(), + 50, + Status.Code.INTERNAL), + // Empty ResultMetadata + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + resultMetadataRequest.toBuilder() + .setStatus(COMPLETED) + .setPercentComplete(100) + .setMetadata(ResultMetadata.newBuilder().build()) + .build(), + 100, + Status.Code.OK), + // "null" ResultMetadata + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + resultMetadataRequest.toBuilder() + .setStatus(COMPLETED) + .setPercentComplete(100) + .setMetadata(ResultMetadata.newBuilder() + .setBody(ByteString.copyFromUtf8("null")) + .build()) + .build(), + 100, + Status.Code.OK), + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + resultMetadataRequest.toBuilder() + .setStatus(ERRORED) + .setPercentComplete(10) + .setErrorDetails(errorDetails) + .build(), + 10, + Status.Code.OK), + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest, + resultMetadataRequest.toBuilder() + .setStatus(EXCEEDED_MAX_RUNTIME_DURATION) + .setPercentComplete(10) + .setErrorDetails(errorDetails) + .build(), + 10, + Status.Code.OK), + // bad metadata + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest.toBuilder() + .setMetadata(metadataErr) + .build(), + null, + null, + Status.Code.INTERNAL), + // task-id mismatch + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest.toBuilder() + .setTaskId(TEST_TASK_ID_2.toString()) + .build(), + null, + null, + Status.Code.PERMISSION_DENIED), + // invalid state + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + resultMetadataRequest.toBuilder() + .setStatus(PENDING_EVALUATION) + .build(), + null, + null, + Status.Code.INTERNAL) + // ### Temporarily commented out till NVCA sends correct instanceType to Utils. + // invalid instance + // Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + // TEST_TASK_ID_1), + // TEST_TASK_ID_1, + // resultMetadataRequest.toBuilder() + // .setStatus(ERRORED) + // .setInstanceType("Invalid Instance") + // .setErrorDetails(errorDetails).build(), + // null, + // null, + // Status.Code.INTERNAL) + ); + } + + @ParameterizedTest + @MethodSource("resultMetadataArgs") + void testResultMetadata( + String token, + UUID taskId, + ResultMetadataRequest reqInitial, + ResultMetadataRequest reqFinal, + Integer percentComplete, + Status.Code expectedStatus) { + setupGrpc(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, taskId)); + connect(taskId); + sendHeartbeat(taskId); + resetGrpc(token); + + var event = 2; + final CountDownLatch finishLatch = new CountDownLatch(1); + StreamObserver requestStreamObserver = + workerStub.sendResultMetadata( + new TestResultMetadataStreamObserver(taskId, + TaskStatus.RUNNING, + finishLatch, + expectedStatus)); + requestStreamObserver.onNext(reqInitial); + + if (reqFinal != null) { + assertThat(finishLatch.getCount()).isEqualTo(1); + requestStreamObserver.onNext(reqFinal); + assertThat(finishLatch.getCount()).isEqualTo(1); + requestStreamObserver.onCompleted(); + event++; + } + + // Receiving happens asynchronously + try { + if (!finishLatch.await(1, TimeUnit.MINUTES)) { + log.error("ResultMetadataResponse can not finish within 1 minutes"); + assertThat(finishLatch.getCount()).isZero(); + } + } catch (InterruptedException e) { + log.error("Interrupted", e); + Assertions.fail("Test failed: " + e.getMessage()); + } + + if (expectedStatus != Status.Code.OK) { + return; + } + + assertThat(testTaskService.getEventCount(taskId)).isEqualTo(event); + if (reqFinal != null && reqFinal.hasPercentComplete()) { + assertThat(testTaskService.getPercentComplete(taskId)) + .isEqualTo(reqFinal.getPercentComplete()); + } + if (percentComplete != null) { + var results = resultService.fetchResults(TEST_NCA_ID, taskId); + List mutableList = new ArrayList<>(results); + mutableList.sort(Comparator.comparing(ResultDto::createdAt)); + assertThat(mutableList.getLast().metadata().get("percentComplete").asInt()) + .isEqualTo(percentComplete.intValue()); + } + } + + Stream tokenArgs() { + return Stream.of( + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_1, + Status.Code.OK), + // task-id mismatch + Arguments.of(tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1), + TEST_TASK_ID_2, + Status.Code.PERMISSION_DENIED) + ); + } + + @ParameterizedTest + @MethodSource("tokenArgs") + void testRefreshToken( + String token, + UUID taskId, + Status.Code expectedStatus) { + var request = RefreshTokenRequest.newBuilder().setTaskId(taskId.toString()).build(); + setupGrpc(token); + var status = Status.Code.OK; + try { + var newToken = refreshToken(request); + resetGrpc(newToken); + connect(taskId); + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + assertThat(status).isEqualTo(expectedStatus); + } + + @Test + void testRequestSecretCredentials() { + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, + TEST_TASK_ID_1); + setupGrpc(token); + var status = Status.Code.OK; + var taskId = TEST_TASK_ID_1; + var request = SecretCredentialsRequest.newBuilder() + .setTaskId(taskId.toString()) + .build(); + try { + requestSecretCredentials(request); + } catch (Exception e) { + status = Status.fromThrowable(e).getCode(); + } + assertThat(status).isEqualTo(Status.Code.OK); + } + + private void connect(UUID taskId) { + var connect = workerBlockingStub.connect(ConnectRequest.newBuilder() + .setTaskId(taskId.toString()) + .setInstanceId("local-instance") + .build()); + assertThat(connect).isNotNull(); + assertThat(connect.getConnectedRegion()).isNotNull(); + log.info("worker connected"); + } + + private void sendHeartbeat(UUID taskId) { + var heartbeat = workerBlockingStub.sendHeartbeat(HeartbeatRequest.newBuilder() + .setTaskId(taskId.toString()) + .build()); + assertThat(heartbeat).isNotNull(); + } + + private HeartbeatResponse sendHeartbeat(HeartbeatRequest heartbeatRequest) { + var heartbeat = workerBlockingStub.sendHeartbeat(heartbeatRequest); + assertThat(heartbeat).isNotNull(); + return heartbeat; + } + + private String refreshToken(RefreshTokenRequest request) { + var tokenResponse = workerBlockingStub.refreshToken(request); + assertThat(tokenResponse).isNotNull(); + assertThat(tokenResponse.getToken()).isNotNull(); + return tokenResponse.getToken(); + } + + private void requestSecretCredentials(SecretCredentialsRequest request) { + var response = workerBlockingStub.requestSecretCredentials(request); + assertThat(response).isNotNull(); + assertThat(response.getExpiration()).isNotNull(); + assertThat(fromTimestamp(response.getExpiration())) + .isAfterOrEqualTo(Instant.now().plus(Duration.ofHours(1))); + } + + private ArtifactsResponse getArtifacts(ArtifactsRequest request) { + return workerBlockingStub.getArtifacts(request); + } + + private void validateTaskStatus(String ncaId, UUID taskId, TaskStatus status) { + var taskEntity = taskService.fetchTask(taskId); + assertThat(taskEntity.getStatus()).isEqualTo(status); + assertThat(taskEntity.getNcaId()).isEqualTo(ncaId); + if (status == TaskStatus.ERRORED) { + assertThat(taskEntity.getHealth()).isNotBlank(); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestResultMetadataStreamObserver.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestResultMetadataStreamObserver.java new file mode 100644 index 000000000..fd57a7160 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestResultMetadataStreamObserver.java @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.proto.ResultMetadataResponse; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TestResultMetadataStreamObserver + implements StreamObserver { + + private final CountDownLatch finishLatch; + private final Status.Code expectedStatus; + private final UUID taskId; + private final TaskStatus taskStatus; + + public TestResultMetadataStreamObserver(UUID taskId, + TaskStatus taskStatus, + CountDownLatch finishLatch, + Status.Code expectedStatus) { + this.finishLatch = finishLatch; + this.expectedStatus = expectedStatus; + this.taskId = taskId; + this.taskStatus = taskStatus; + } + + @Override + public void onNext(ResultMetadataResponse resultMetadataResponse) { + assertThat(resultMetadataResponse).isNotNull(); + log.debug("Received metadata response '{}'", resultMetadataResponse); + assertThat(taskId).hasToString(resultMetadataResponse.getTaskId()); + assertThat(taskStatus).hasToString(resultMetadataResponse.getExecutionStatus()); + } + + @Override + public void onError(Throwable throwable) { + log.error("Encountered error in TestResultMetadataStreamObserver ", throwable); + var status = Status.fromThrowable(throwable).getCode(); + assertThat(status).isEqualTo(expectedStatus); + finishLatch.countDown(); + } + + @Override + public void onCompleted() { + log.debug("Finished ResultMetadataRequest"); + finishLatch.countDown(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestTaskService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestTaskService.java new file mode 100644 index 000000000..4980c276a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/grpc/TestTaskService.java @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.grpc; + +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_GFN_GPU_SPEC; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.model.ModelRegistry; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistry; +import com.nvidia.nvct.persistence.event.EventsByTaskRepository; +import com.nvidia.nvct.persistence.result.ResultsByTaskRepository; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.ModelUdt; +import com.nvidia.nvct.persistence.task.entity.ResourceUdt; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.service.registry.RegistryArtifactService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockEssServer; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TestTaskService { + @Autowired + TasksRepository tasksRepository; + @Autowired + private EventsByTaskRepository eventsByTaskRepository; + @Autowired + private ResultsByTaskRepository resultsByTaskRepository; + @Autowired + private JsonMapper jsonMapper; + @Autowired + private TaskService taskService; + @Autowired + private RegistryArtifactService artifactService; + @Autowired + private List containerRegistries; + @Autowired + private List helmRegistries; + @Autowired + private List modelRegistries; + @Autowired + private List resourceRegistries; + + public void createTaskWithModel(String ncaId, + UUID taskId, + UUID icmsRequestId, + Set models) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + models, null, null, null, null, null); + + } + + public void createTaskWithResource(String ncaId, UUID taskId, UUID icmsRequestId) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, TEST_RESOURCES, null, null, null, null); + } + + public void createTaskWithTelemetries(String ncaId, + UUID taskId, + UUID icmsRequestId, + TelemetriesUdt telemetriesUdt) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, TEST_RESOURCES, null, null, null, telemetriesUdt); + + } + + public void createTaskWithModelAndResources(String ncaId, + UUID taskId, + UUID icmsRequestId) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + TEST_MODELS, TEST_RESOURCES, null, null, null, null); + } + + public void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, null, null, null, null, null); + } + + public void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId, + TaskStatus status, + GpuSpecUdt gpu) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, null, null, status, gpu, null); + } + + public void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId, + TaskStatus status) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, null, null, status, null, null); + } + + public void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId, + Instant createdAt) { + createTask(ncaId, taskId, icmsRequestId, ResultHandlingStrategy.UPLOAD, + null, null, createdAt, null, null, null); + } + + public void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId, + ResultHandlingStrategy resultHandlingStrategy) { + createTask(ncaId, taskId, icmsRequestId, resultHandlingStrategy, + null, null, null, null, null, null); + } + + @SneakyThrows + private void createTask(String ncaId, + UUID taskId, + UUID icmsRequestId, + ResultHandlingStrategy resultHandlingStrategy, + Set models, + Set resources, + Instant createTime, + TaskStatus status, + GpuSpecUdt gpu, + TelemetriesUdt telemetries) { + var createdAt = createTime != null ? createTime : Instant.now(); + var taskStatus = status == null ? TaskStatus.QUEUED : status; + var gpuSpec = gpu == null ? TEST_GFN_GPU_SPEC : gpu; + var name = "Task-" + taskId; + var containerEnv = Base64.getEncoder() + .encodeToString(jsonMapper + .writeValueAsBytes(TEST_CONTAINER_ENVIRONMENT)); + var taskEntity = TaskEntity.builder() + .taskId(taskId) + .ncaId(ncaId) + .name(name) + .status(taskStatus) + .description("My first task") + .tags(Set.of("tag1", "tag2")) + .containerImage("alpine:3.14") + .containerArgs(TEST_CONTAINER_ARGS) + .containerEnvironment(containerEnv) + .models(models) + .resources(resources) + .gpuSpec(gpuSpec) + .maxRuntimeDuration(Duration.parse("PT1H")) + .maxQueuedDuration(Duration.parse("PT1H")) + .terminalGracePeriodDuration(Duration.parse("PT1H")) + .resultHandlingStrategy(resultHandlingStrategy) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .telemetries(telemetries) + .createdAt(createdAt) + .status(taskStatus) + .hasSecrets(null) // Simulate old tasks before this field existed + .build(); + tasksRepository.save(taskEntity); + } + + public void clearAll() { + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + artifactService.invalidateCache(); + + containerRegistries.forEach(ContainerRegistry::invalidateCache); + helmRegistries.forEach(HelmRegistry::invalidateCache); + modelRegistries.forEach(ModelRegistry::invalidateCache); + resourceRegistries.forEach(ResourceRegistry::invalidateCache); + + MockEssServer.clearSecrets(); + } + + public int getEventCount(UUID taskId) { + return (int)eventsByTaskRepository.findByKeyTaskId(taskId).count(); + } + + public Integer getPercentComplete(UUID taskId) { + return taskService.fetchTask(taskId).getPercentComplete(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/event/EventsByTaskRepositoryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/event/EventsByTaskRepositoryTest.java new file mode 100644 index 000000000..ebe914855 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/event/EventsByTaskRepositoryTest.java @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.event; + +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.event.entity.EventByTaskEntity; +import com.nvidia.nvct.persistence.event.entity.EventByTaskKey; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.util.TestUtil; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class EventsByTaskRepositoryTest { + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EventsByTaskRepository eventsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void init() { + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + } + + @AfterEach + void reset() { + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + @Test + void testTaskEvents() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + + // Create Task. + tasksRepository.save(task); + + // Create Task Events. + var eventId1 = UUID.randomUUID(); + var event1 = EventByTaskEntity.builder() + .key(EventByTaskKey.builder().taskId(taskId).eventId(eventId1).build()) + .ncaId(TEST_NCA_ID) + .message("Task status: QUEUED") + .createdAt(Instant.now()) + .build(); + var eventId2 = UUID.randomUUID(); + var event2 = EventByTaskEntity.builder() + .key(EventByTaskKey.builder().taskId(taskId).eventId(eventId2).build()) + .ncaId(TEST_NCA_ID) + .message("Task status: LAUNCHED") + .createdAt(Instant.now()) + .build(); + eventsByTaskRepository.saveAll(List.of(event1, event2)); + + // Verify Task Events. + assertThat(eventsByTaskRepository.findByKeyTaskId(taskId)).hasSize(2); + + // Delete Task Event. + eventsByTaskRepository.delete(event1); + + // Verify Task Events. + assertThat(eventsByTaskRepository.findByKeyTaskId(taskId)).hasSize(1); + assertThat(eventsByTaskRepository.getByKeyTaskIdAndKeyEventId(taskId, eventId2)) + .isPresent() + .map(entity -> entity.getKey().getEventId()) + .hasValue(eventId2); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepositoryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepositoryTest.java new file mode 100644 index 000000000..48d79c93a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/result/ResultsByTaskRepositoryTest.java @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.result; + +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskKey; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.util.TestUtil; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class ResultsByTaskRepositoryTest { + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private ResultsByTaskRepository resultsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void init() { + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + } + + @AfterEach + void reset() { + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + @Test + void testTaskResults() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + + // Create Task. + tasksRepository.save(task); + + // Create Task Results. + var resultId1 = UUID.randomUUID(); + var result1 = ResultByTaskEntity.builder() + .key(ResultByTaskKey.builder().taskId(taskId).resultId(resultId1).build()) + .ncaId(TEST_NCA_ID) + .name("result-1") + .metadata("{'foo': 'bar'}") + .createdAt(Instant.now()) + .build(); + var resultId2 = UUID.randomUUID(); + var result2 = ResultByTaskEntity.builder() + .key(ResultByTaskKey.builder().taskId(taskId).resultId(resultId2).build()) + .ncaId(TEST_NCA_ID) + .name("result-2") + .metadata("{'foo': 'baz'}") + .createdAt(Instant.now()) + .build(); + resultsByTaskRepository.saveAll(List.of(result1, result2)); + + // Verify Task Results. + assertThat(resultsByTaskRepository.findByKeyTaskId(taskId)).hasSize(2); + + // Delete Task Result. + resultsByTaskRepository.delete(result1); + + // Verify Task Events. + assertThat(resultsByTaskRepository.findByKeyTaskId(taskId)).hasSize(1); + assertThat(resultsByTaskRepository.getByKeyTaskIdAndKeyResultId(taskId, resultId2)) + .isPresent() + .map(entity -> entity.getKey().getResultId()) + .hasValue(resultId2); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/task/TasksRepositoryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/task/TasksRepositoryTest.java new file mode 100644 index 000000000..d2db386be --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/persistence/task/TasksRepositoryTest.java @@ -0,0 +1,326 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.persistence.task; + +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.entity.HealthUdt; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.TestUtil; +import java.time.Instant; +import java.util.UUID; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import tools.jackson.databind.json.JsonMapper; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class TasksRepositoryTest { + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TaskMapperService taskMapperService; + + @Autowired + private TaskService taskService; + + @Autowired + private CqlSession cqlSession; + + @Autowired + private JsonMapper jsonMapper; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void init() { + tasksRepository.deleteAll(); + } + + @AfterEach + void reset() { + tasksRepository.deleteAll(); + testTaskService.clearAll(); + } + + @Test + void createAndDeleteTask() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + + // Save the task. + tasksRepository.save(task); + + // Make sure that the entry was created in all the tables. + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(entity -> entity.getTaskId()) + .hasValue(taskId); + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(entity -> entity.getTaskId()) + .hasValue(taskId); + + assertThat(tasksRepository.findAll()).hasSize(1); + assertThat(tasksRepository.countByNcaId(TEST_NCA_ID)).isEqualTo(1); + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(entity -> entity.getTaskId()) + .hasValue(taskId); + var entity = tasksRepository.getByTaskId(taskId).get(); + assertThat(entity.getMaxRuntimeDuration()).isNotNull(); + + // Delete the task using. + tasksRepository.delete(task); + + // Make sure that the entry was deleted from the table. + assertThat(tasksRepository.getByTaskId(taskId)).isNotPresent(); + } + + @SneakyThrows + @Test + void countTasksByNcaId() { + var taskId1 = UUID.randomUUID(); + var task1 = TestUtil.createTaskEntity(taskId1, TEST_NCA_ID, "task-1", jsonMapper); + + var taskId2 = UUID.randomUUID(); + var task2 = TestUtil.createTaskEntity(taskId2, TEST_NCA_ID, "task-2", jsonMapper); + + var taskId3 = UUID.randomUUID(); + var task3 = TestUtil.createTaskEntity(taskId3, TEST_NCA_ID_2, "task-3", jsonMapper); + + // Save the tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + tasksRepository.save(task3); + + // Confirm that there are three Tasks in the repository. + assertThat(tasksRepository.count()).isEqualTo(3); + + // Confirm there are 2 Tasks belonging to TEST_NCA_ID account. + assertThat(tasksRepository.countByNcaId(TEST_NCA_ID)).isEqualTo(2); + } + + @Test + void updateTask() throws InterruptedException { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + + // Save the task. + tasksRepository.save(task); + + assertThat(tasksRepository.findAll()).hasSize(1); + + // Check the initial status. + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(TaskEntity::getStatus) + .hasValue(TaskStatus.QUEUED); + + // Change the status to LAUNCHED. + task.setStatus(TaskStatus.LAUNCHED); + + // Update the task. + var timestamp = Instant.now(); // Save the timestamp before updating the task. + Thread.sleep(1000); // Sleep for a second before updating the task. + tasksRepository.insert(task); + + // Verify new status. + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(TaskEntity::getStatus) + .hasValue(TaskStatus.LAUNCHED); + + // Verify last_updated_at is after the saved timestamp. + assertThat(tasksRepository.getByTaskId(taskId)) + .isPresent() + .map(TaskEntity::getLastUpdatedAt) + .hasValueSatisfying(updatedAt -> { + assertThat(updatedAt).isAfter(timestamp); + }); + + assertThat(tasksRepository.findAll()).hasSize(1); + } + + @Test + void writesHealthDetailsAsJsonToHealthColumnOnly() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + var healthInfo = taskMapperService.deserializeHealth(task.getHealth()).orElseThrow(); + + tasksRepository.save(task); + + var row = cqlSession.execute( + "SELECT health, health_info FROM tasks_v2 WHERE task_id = ?", taskId).one(); + assertThat(row).isNotNull(); + assertThat(row.getString(TaskEntity.COLUMN_HEALTH)) + .doesNotContain("sis_request_id") + .contains("\"gpu\":\"" + healthInfo.gpu() + "\"") + .contains("\"backend\":\"" + healthInfo.backend() + "\"") + .contains("\"instanceType\":\"" + healthInfo.instanceType() + "\"") + .contains("\"error\":\"" + healthInfo.error() + "\""); + assertThat(row.isNull(TaskEntity.COLUMN_HEALTH_INFO)).isTrue(); + + var updatedHealthInfo = HealthDto.builder() + .backend("GFN") + .gpu("T10") + .instanceType("g6.full") + .error("updated-error") + .build(); + + taskService.updateTask(taskId, TaskStatus.ERRORED, updatedHealthInfo); + + row = cqlSession.execute( + "SELECT health, health_info FROM tasks_v2 WHERE task_id = ?", taskId).one(); + assertThat(row).isNotNull(); + assertThat(row.getString(TaskEntity.COLUMN_HEALTH)).contains("updated-error"); + assertThat(row.isNull(TaskEntity.COLUMN_HEALTH_INFO)).isTrue(); + + var reloadedTask = tasksRepository.getByTaskId(taskId).orElseThrow(); + assertThat(taskMapperService.toTaskDto(reloadedTask).healthInfo()).isEqualTo(updatedHealthInfo); + } + + @Test + void readFromLegacyHealthInfoColumnWhenHealthColumnIsBlank() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + task.setHealth(null); + tasksRepository.save(task); + + var legacyHealthInfo = HealthUdt.builder() + .legacyIcmsRequestId(UUID.randomUUID()) + .backend("legacy-backend") + .gpu("legacy-gpu") + .instanceType("legacy-instance") + .error("legacy-error") + .build(); + updateLegacyHealthInfo(taskId, legacyHealthInfo); + + var reloadedTask = tasksRepository.getByTaskId(taskId); + + assertThat(reloadedTask).isPresent(); + assertThat(reloadedTask.get().getHealth()).isNull(); + assertThat(reloadedTask.get().getLegacyHealthInfo()).isEqualTo(legacyHealthInfo); + assertThat(taskMapperService.toTaskDto(reloadedTask.get()).healthInfo()) + .isEqualTo(HealthDto.builder() + .backend(legacyHealthInfo.getBackend()) + .gpu(legacyHealthInfo.getGpu()) + .instanceType(legacyHealthInfo.getInstanceType()) + .error(legacyHealthInfo.getError()) + .build()); + } + + @Test + @SneakyThrows + void readFirstFromHealthColumnBeforeReadingFromLegacyHealthInfoColumn() { + var taskId = UUID.randomUUID(); + var task = TestUtil.createTaskEntity(taskId, TEST_NCA_ID, "task-1", jsonMapper); + task.setHealth(null); + tasksRepository.save(task); + + var legacyHealthInfo = HealthUdt.builder() + .legacyIcmsRequestId(UUID.randomUUID()) + .backend("legacy-backend") + .gpu("legacy-gpu") + .instanceType("legacy-instance") + .error("legacy-error") + .build(); + var healthInfo = HealthDto.builder() + .backend("new-backend") + .gpu("new-gpu") + .instanceType("new-instance") + .error("new-error") + .build(); + cqlSession.execute( + "UPDATE tasks_v2 SET health = ? WHERE task_id = ?", + """ + { + "sis_request_id": "e807603b-5224-4d7e-af7f-740c22c006e0", + "gpu": "%s", + "backend": "%s", + "instanceType": "%s", + "error": "%s" + } + """.formatted(healthInfo.gpu(), + healthInfo.backend(), + healthInfo.instanceType(), + healthInfo.error()), + taskId); + updateLegacyHealthInfo(taskId, legacyHealthInfo); + + var reloadedTask = tasksRepository.getByTaskId(taskId); + + assertThat(reloadedTask).isPresent(); + assertThat(reloadedTask.get().getHealth()).isNotBlank(); + assertThat(taskMapperService.toTaskDto(reloadedTask.get()).healthInfo()).isEqualTo(healthInfo); + } + + private void updateLegacyHealthInfo(UUID taskId, HealthUdt healthInfo) { + cqlSession.execute(""" + UPDATE tasks_v2 + SET health_info = { + sis_request_id: %s, + gpu: '%s', + backend: '%s', + instance_type: '%s', + error: '%s' + } + WHERE task_id = %s + """.formatted(healthInfo.getLegacyIcmsRequestId(), + healthInfo.getGpu(), + healthInfo.getBackend(), + healthInfo.getInstanceType(), + healthInfo.getError(), + taskId)); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/EventControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/EventControllerTest.java new file mode 100644 index 000000000..824ed53a8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/EventControllerTest.java @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.LAUNCHED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_EVENTS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_RESULTS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_1; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_2; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_3; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OWNER_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.event.EventsByTaskRepository; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.event.dto.EventDto; +import com.nvidia.nvct.rest.event.dto.ListEventsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class EventControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TestEventService testEventService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private EventsByTaskRepository eventsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void beforeEach() { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + // Events for TEST_TASK_ID_1. + testEventService.populateEventForTask(EVENT_ID_1, TEST_NCA_ID, TEST_TASK_ID_1, + STATUS_CHANGE_EVENT_MESSAGE.formatted(EMPTY, QUEUED)); + testEventService.populateEventForTask(EVENT_ID_2, TEST_NCA_ID, TEST_TASK_ID_1, + STATUS_CHANGE_EVENT_MESSAGE.formatted(QUEUED, LAUNCHED)); + testEventService.populateEventForTask(EVENT_ID_3, TEST_NCA_ID, TEST_TASK_ID_1, + STATUS_CHANGE_EVENT_MESSAGE.formatted(LAUNCHED, RUNNING)); + + // Event for TEST_TASK_ID_2 to make sure there is no cross contamination query + testEventService.populateEventForTask(EVENT_ID_4, TEST_NCA_ID, TEST_TASK_ID_2, + STATUS_CHANGE_EVENT_MESSAGE.formatted(EMPTY, RUNNING)); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + Stream taskEventsArgs() { + var jwtCases = Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_2 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_2, + Set.of(EVENT_ID_4), + HttpStatus.OK), + // Task in different account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT_2, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent client. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("non-existent-client", + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent task. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_3, + Set.of(), + HttpStatus.NOT_FOUND), + // Missing scopes. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.UNAUTHORIZED) + ); + + var apiKeysCases = Stream.of( + // api-key with list_events scope and account-tasks resource + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("list_events")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3), + HttpStatus.OK), + // api-key with list_events scope and specific task resource (matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("list_events")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3), + HttpStatus.OK), + // api-key with list_events scope and specific task resource (not matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("list_events")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // api-key with scope but no resources + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("list_events")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // api-key with missing scope + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), List.of()); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("taskEventsArgs") + void shouldListEvents(Object tokenSupplier, + String ncaId, + UUID taskId, + Set eventIds, + HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId + "/events")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.events()).hasSize(eventIds.size()); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + verifyReturnedEventsIntegrity(eventIds, responseBody.events(), ncaId, taskId); + } + + void verifyReturnedEventsIntegrity( + Set expectedEvents, List events, String ncaId, UUID taskId) { + for (var eventDto : events) { + assertThat(eventDto.createdAt()).isNotNull(); + assertThat(eventDto.ncaId()).isEqualTo(ncaId); + assertThat(eventDto.eventId()).isIn(expectedEvents); + assertThat(eventDto.taskId()).isEqualTo(taskId); + assertThat(eventDto.message()).isNotBlank(); + } + } + + Stream listEventsWithPaginationLimitArgs() { + return Stream.of( + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3)), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 100, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3)) + + ); + } + + @ParameterizedTest + @MethodSource("listEventsWithPaginationLimitArgs") + void shouldListEventsWithPaginationLimit(String token, + String ncaId, + UUID taskId, + int limit, + Set expectedEventIds) { + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId + "/events?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + if (limit < 3) { + assertThat(responseBody.events()).hasSize(limit); + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.limit()).isEqualTo(limit); + } else { + assertThat(responseBody.events()).hasSize(3); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + } + + verifyReturnedEventsIntegrity(expectedEventIds, responseBody.events(), ncaId, taskId); + } + + @Test + void shouldListEventsWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100); + var expectedResults = Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3); + + // get the first 2 out of 3 events first + var url = "/v1/nvct/tasks/" + TEST_TASK_ID_1 + "/events?limit=2"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageEvent = responseBody.events(); + assertThat(firstPageEvent).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + + // using the cursor from previous response, continue to listing the rest of the results + url = "/v1/nvct/tasks/" + TEST_TASK_ID_1 + "/events?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last event is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNull(); + assertThat(responseBody.limit()).isNull(); + var secondPageEvent = responseBody.events(); + assertThat(secondPageEvent).hasSize(1); + + // make sure all 3 results have the right metadata + verifyReturnedEventsIntegrity(expectedResults, + Stream.concat(firstPageEvent.stream(), secondPageEvent.stream()).toList(), + TEST_NCA_ID, + TEST_TASK_ID_1); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/TestEventService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/TestEventService.java new file mode 100644 index 000000000..538f0c7bf --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/TestEventService.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import com.nvidia.nvct.persistence.event.EventsByTaskRepository; +import com.nvidia.nvct.persistence.event.entity.EventByTaskEntity; +import com.nvidia.nvct.persistence.event.entity.EventByTaskKey; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TestEventService { + + private final EventsByTaskRepository eventsByTaskRepository; + + public void populateEventForTask(UUID eventId, + String ncaId, + UUID taskId, + String message) { + var event = EventByTaskEntity.builder() + .key(EventByTaskKey.builder().taskId(taskId).eventId(eventId).build()) + .ncaId(ncaId) + .message(message) + .createdAt(Instant.now()) + .build(); + eventsByTaskRepository.save(event); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/XAccountEventControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/XAccountEventControllerTest.java new file mode 100644 index 000000000..a2b98d3c0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/event/XAccountEventControllerTest.java @@ -0,0 +1,394 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.event; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_EVENTS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_RESULTS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_1; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_2; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.event.EventsByTaskRepository; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.event.dto.EventDto; +import com.nvidia.nvct.rest.event.dto.ListEventsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountEventControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TestEventService testEventService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private EventsByTaskRepository eventsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void beforeEach() { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + // Events for TEST_TASK_ID_1. + testEventService.populateEventForTask(EVENT_ID_1, TEST_NCA_ID, TEST_TASK_ID_1, + "Status: QUEUED"); + testEventService.populateEventForTask(EVENT_ID_2, TEST_NCA_ID, TEST_TASK_ID_1, + "Status: LAUNCHED"); + testEventService.populateEventForTask(EVENT_ID_3, TEST_NCA_ID, TEST_TASK_ID_1, + "Status: RUNNING"); + + // Event for TEST_TASK_ID_2 to make sure there is no cross contamination query + testEventService.populateEventForTask(EVENT_ID_1, TEST_NCA_ID, TEST_TASK_ID_2, + "Status: RUNNING"); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + eventsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + Stream taskEventsArgs() { + return Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("another-admin", + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_2 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_2, + Set.of(EVENT_ID_1), + HttpStatus.OK), + // Non-existent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + "non-existent-account", + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Task in different account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID_2, + TEST_TASK_ID_2, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent task. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_3, + Set.of(), + HttpStatus.NOT_FOUND), + // Missing scopes. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("taskEventsArgs") + void shouldListEvents(Object tokenSupplier, + String ncaId, + UUID taskId, + Set eventIds, + HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + var uri = URI.create("/v1/nvct/accounts/" + ncaId + "/tasks/" + taskId + "/events"); + var requestEntity = + RequestEntity.get(uri) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.events()).hasSize(eventIds.size()); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + for (EventDto eventDto : responseBody.events()) { + assertThat(eventDto.createdAt()).isNotNull(); + assertThat(eventDto.ncaId()).isEqualTo(ncaId); + assertThat(eventDto.eventId()).isIn(eventIds); + assertThat(eventDto.taskId()).isEqualTo(taskId); + assertThat(eventDto.message()).isNotBlank(); + } + } + + void verifyReturnedEventsIntegrity( + Set expectedEvents, List events, String ncaId, UUID taskId) { + for (var eventDto : events) { + assertThat(eventDto.createdAt()).isNotNull(); + assertThat(eventDto.ncaId()).isEqualTo(ncaId); + assertThat(eventDto.eventId()).isIn(expectedEvents); + assertThat(eventDto.taskId()).isEqualTo(taskId); + assertThat(eventDto.message()).isNotBlank(); + } + } + + Stream listEventsWithPaginationLimitArgs() { + return Stream.of( + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 1, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3)), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 100, + Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3)) + + ); + } + + @ParameterizedTest + @MethodSource("listEventsWithPaginationLimitArgs") + void shouldListEventsWithPaginationLimit(String token, + String ncaId, + UUID taskId, + int limit, + Set expectedEventIds) { + var requestEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + ncaId + + "/tasks/" + taskId + "/events?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + if (limit < 3) { + assertThat(responseBody.events()).hasSize(limit); + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.limit()).isEqualTo(limit); + } else { + assertThat(responseBody.events()).hasSize(3); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + } + + verifyReturnedEventsIntegrity(expectedEventIds, responseBody.events(), ncaId, taskId); + } + + @Test + void shouldListEventsWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100); + var expectedResults = Set.of(EVENT_ID_1, EVENT_ID_2, EVENT_ID_3); + // get the first 2 out of 3 events first + var url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + TEST_TASK_ID_1 + "/events?limit=2"; + var requestEntity = RequestEntity + .get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageEvent = responseBody.events(); + assertThat(firstPageEvent).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + + // using the cursor from previous response, continue to listing the rest of the results + url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + TEST_TASK_ID_1 + + "/events?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListEventsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last event is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNull(); + assertThat(responseBody.limit()).isNull(); + var secondPageEvent = responseBody.events(); + assertThat(secondPageEvent).hasSize(1); + + // make sure all 3 results have the right metadata + verifyReturnedEventsIntegrity(expectedResults, + Stream.concat(firstPageEvent.stream(), secondPageEvent.stream()).toList(), + TEST_NCA_ID, + TEST_TASK_ID_1); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/misc/MiscEndpointsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/misc/MiscEndpointsTest.java new file mode 100644 index 000000000..b40250356 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/misc/MiscEndpointsTest.java @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS; +import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD; +import static org.springframework.http.HttpHeaders.ORIGIN; +import static org.springframework.http.HttpMethod.POST; + +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.cors.CorsConfiguration; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +@Slf4j +@TestInstance(Lifecycle.PER_CLASS) +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class MiscEndpointsTest { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + private static final Set OBJECT_COMPONENT_SCHEMAS = Set.of( + "CreateTaskRequest", + "HealthDto", + "HelmValidationPolicyDto", + "KubernetesType", + "TelemetriesDto", + "ResultDto", + "GpuSpecificationDto"); + + @Autowired + private TestRestTemplate testRestTemplate; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @SneakyThrows + @Test + void testHealth() { + var requestEntity = RequestEntity.get(URI.create("/health")).build(); + var responseEntity = testRestTemplate.exchange(requestEntity, String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @SneakyThrows + @Test + void testOpenApiDocs() { + var requestEntity = RequestEntity.get(URI.create("/v3/openapi")).build(); + var responseEntity = testRestTemplate.exchange(requestEntity, String.class); + var responseBody = responseEntity.getBody(); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(responseBody).isNotNull(); + + var spec = JSON_MAPPER.readTree(responseBody); + var componentSchemas = spec.at("/components/schemas"); + var nullOnlySchemas = componentSchemas.properties().stream() + .filter(entry -> entry.getValue().path("type").isString()) + .filter(entry -> "null".equals(entry.getValue().path("type").asString())) + .map(entry -> entry.getKey()) + .toList(); + assertThat(nullOnlySchemas).isEmpty(); + + OBJECT_COMPONENT_SCHEMAS.forEach(schemaName -> + assertThat(schema(spec, schemaName).path("type").asString()).isEqualTo("object")); + assertThat(componentSchemas.has("JsonNode")).isFalse(); + assertThat(schema(spec, "CreateTaskRequest").path("properties") + .has("gpuSpecification")).isTrue(); + assertThat(schema(spec, "GpuSpecificationDto").path("properties").has("gpu")).isTrue(); + assertThat(schema(spec, "GpuSpecificationDto").at("/properties/configuration/type") + .asString()).isEqualTo("object"); + var secretSchema = schema(spec, "SecretDto"); + assertThat(secretSchema.at("/properties/value").has("$ref")).isFalse(); + assertThat(jsonStringValues(secretSchema.path("required"))) + .containsExactlyInAnyOrder("name", "value"); + assertThat(jsonStringValues(secretSchema.at("/properties/value/type"))) + .containsExactly("string", "object"); + } + + @ParameterizedTest + @MethodSource("getRecognizedOrigins") + void testCorsPreflightRequest(String origin) { + var requestEntity = RequestEntity.options(URI.create("/v1/nvct/tasks")) + .header(ORIGIN, origin) + .header(ACCESS_CONTROL_REQUEST_METHOD, POST.toString()) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(responseEntity.getHeaders().getVary()).contains(ORIGIN); + assertThat(responseEntity.getHeaders().getVary()).contains(ACCESS_CONTROL_REQUEST_METHOD); + assertThat(responseEntity.getHeaders().getVary()).contains(ACCESS_CONTROL_REQUEST_HEADERS); + assertThat(responseEntity.getHeaders().getAccessControlAllowOrigin()).contains(origin); + assertThat(responseEntity.getHeaders().getAccessControlAllowCredentials()).isTrue(); + assertThat(responseEntity.getHeaders().getAccessControlAllowMethods()).contains(POST); + assertThat(responseEntity.getHeaders().getAccessControlExposeHeaders()) + .contains(CorsConfiguration.ALL); + assertThat(responseEntity.getHeaders().getAccessControlMaxAge()).isEqualTo(86400); // 1d + } + + private static Stream getRecognizedOrigins() { + return Stream.of("http://localhost:3000", + "https://demo.stg.nvct.nvidia.com", + "https://picasso.nvct.nvidia.com", + "https://picasso.stg.nvct.nvidia.com", + "foo.bar.baz", + "*"); + } + + private static JsonNode schema(JsonNode spec, String schemaName) { + return spec.path("components").path("schemas").path(schemaName); + } + + private static List jsonStringValues(JsonNode arrayNode) { + var values = new ArrayList(); + for (var node : arrayNode) { + values.add(node.asString()); + } + return values; + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/ResultControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/ResultControllerTest.java new file mode 100644 index 000000000..a0060aece --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/ResultControllerTest.java @@ -0,0 +1,478 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_EVENTS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_RESULTS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_1; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_2; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OWNER_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.result.ResultsByTaskRepository; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.result.dto.ListResultsResponse; +import com.nvidia.nvct.rest.result.dto.ResultDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class ResultControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TestResultService testResultService; + + @Autowired + private AccountService accountService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private ResultsByTaskRepository resultsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private ApiKeysService apiKeysService; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockApiKeysServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @SneakyThrows + @BeforeEach + void beforeEach() { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, + TEST_TASK_NAME_1, jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, + TEST_TASK_NAME_2, jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var metadataTemplate = """ + { + "fine_tuned_model_checkpoint": "ckpt-step-%s", + "metrics": { + "full_valid_loss": %s, + "full_valid_mean_token_accuracy": %s + }, + "fine_tuning_job_id": "ftjob-abc123", + "step_number": %s, + "percentComplete": %s + } + """; + // Results for TEST_TASK_ID_1. + var metadata1 = metadataTemplate.formatted(1000, 0.2, 0.3, 1000, 10); + testResultService.populateResultForTask(RESULT_ID_1, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata1)); + + var metadata2 = metadataTemplate.formatted(2000, 0.3, 0.4, 2000, 20); + testResultService.populateResultForTask(RESULT_ID_2, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata2)); + + var metadata3 = metadataTemplate.formatted(3000, 0.4, 0.5, 3000, 30); + testResultService.populateResultForTask(RESULT_ID_3, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata3)); + + // Results for TEST_TASK_ID_2 to make sure there is no cross contamination query + var otherMetadata1 = metadataTemplate.formatted(3000, 0.4, 0.5, 3000, 30); + testResultService.populateResultForTask(RESULT_ID_1, TEST_NCA_ID, TEST_TASK_ID_2, + (ObjectNode) jsonMapper.readTree(otherMetadata1)); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + Stream taskResultsArgs() { + var jwtCases = Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_2 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_2, + Set.of(RESULT_ID_1), + HttpStatus.OK), + // Task in different account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT_2, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent client. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("non-existent-client", + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent task. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_3, + Set.of(), + HttpStatus.NOT_FOUND), + // Missing scopes. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.UNAUTHORIZED) + ); + + var apiKeyCases = Stream.of( + // api-key with list_results scope and account-tasks resource + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("list_results")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3), + HttpStatus.OK), + // api-key with list_results scope and specific task resource (matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("list_results")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3), + HttpStatus.OK), + // api-key with list_results scope and specific task resource (not matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("list_results")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // api-key with scope but no resources + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("list_results")); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // api-key with missing scope + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of()); + return "nvapi-stg-some-key"; + }, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeyCases); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource("taskResultsArgs") + void shouldListResults(Object tokenSupplier, + String ncaId, + UUID taskId, + Set resultIds, + HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId + "/results")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + assertThat(responseBody.results()).hasSize(resultIds.size()); + for (var resultDto : responseBody.results()) { + assertThat(resultDto.createdAt()).isNotNull(); + assertThat(resultDto.ncaId()).isEqualTo(ncaId); + assertThat(resultDto.resultId()).isIn(resultIds); + assertThat(resultDto.taskId()).isEqualTo(taskId); + assertThat(resultDto.metadata()).isNotNull(); + assertThat(resultDto.metadata()).hasSize(5); + assertThat(resultDto.metadata().get("step_number").asInt()) + .isIn(Set.of(1000, 2000, 3000)); + assertThat(resultDto.metadata().get("percentComplete").asInt()) + .isIn(Set.of(10, 20, 30)); + assertThat(resultDto.name()).isNotBlank(); + } + } + + Stream taskResultsWithPaginationLimitArgs() { + return Stream.of( + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3)), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 100, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3)) + ); + } + + @ParameterizedTest + @MethodSource("taskResultsWithPaginationLimitArgs") + void shouldListResultsWithPaginationLimit(String token, + String ncaId, + UUID taskId, + int limit, + Set expectedResultIds) { + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId + "/results?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + if (limit < 3) { + assertThat(responseBody.results()).hasSize(limit); + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.limit()).isEqualTo(limit); + } else { + assertThat(responseBody.results()).hasSize(3); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + } + + verifyReturnedResultsIntegrity(expectedResultIds, responseBody.results(), ncaId, taskId); + } + + void verifyReturnedResultsIntegrity( + Set expectedResults, List results, String ncaId, UUID taskId) { + for (var resultDto : results) { + assertThat(resultDto.createdAt()).isNotNull(); + assertThat(resultDto.ncaId()).isEqualTo(ncaId); + assertThat(resultDto.resultId()).isIn(expectedResults); + assertThat(resultDto.taskId()).isEqualTo(taskId); + assertThat(resultDto.metadata()).isNotNull(); + assertThat(resultDto.metadata()).hasSize(5); + assertThat(resultDto.metadata().get("step_number").asInt()) + .isIn(Set.of(1000, 2000, 3000)); + assertThat(resultDto.name()).isNotBlank(); + } + } + + @Test + void shouldListResultsWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LIST_EVENTS, + SCOPE_LIST_TASKS, + SCOPE_LIST_RESULTS), + 100); + var expectedResults = Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3); + + // get the first 2 out of 3 results first + var url = "/v1/nvct/tasks/" + TEST_TASK_ID_1 + "/results?limit=2"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageResult = responseBody.results(); + assertThat(firstPageResult).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + + // using the cursor from previous response, continue to listing the rest of the results + url = "/v1/nvct/tasks/" + TEST_TASK_ID_1 + "/results?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last result is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(responseBody.limit()).isNull(); + assertThat(currentCursor).isNull(); + var secondPageResult = responseBody.results(); + assertThat(secondPageResult).hasSize(1); + + // make sure all 3 results have the right metadata + verifyReturnedResultsIntegrity(expectedResults, + Stream.concat(firstPageResult.stream(), + secondPageResult.stream()).toList(), + TEST_NCA_ID, + TEST_TASK_ID_1); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/TestResultService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/TestResultService.java new file mode 100644 index 000000000..f11655d33 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/TestResultService.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.nvct.persistence.result.ResultsByTaskRepository; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskEntity; +import com.nvidia.nvct.persistence.result.entity.ResultByTaskKey; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TestResultService { + + private final ResultsByTaskRepository resultsByTaskRepository; + + public void populateResultForTask(UUID resultId, + String ncaId, + UUID taskId, + ObjectNode resultMetadata) { + var event = ResultByTaskEntity.builder() + .key(ResultByTaskKey.builder().taskId(taskId).resultId(resultId).build()) + .ncaId(ncaId) + .metadata(resultMetadata.toString()) + .name(taskId.toString()) + .createdAt(Instant.now()) + .build(); + resultsByTaskRepository.save(event); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/XAccountResultControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/XAccountResultControllerTest.java new file mode 100644 index 000000000..ca3782d3e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/result/XAccountResultControllerTest.java @@ -0,0 +1,427 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.result; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_EVENTS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_RESULTS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_1; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_2; +import static com.nvidia.nvct.util.TestConstants.RESULT_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.result.ResultsByTaskRepository; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.result.dto.ListResultsResponse; +import com.nvidia.nvct.rest.result.dto.ResultDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountResultControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TestResultService testResultService; + + @Autowired + private AccountService accountService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private ResultsByTaskRepository resultsByTaskRepository; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private ApiKeysService apiKeysService; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockApiKeysServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @SneakyThrows + @BeforeEach + void beforeEach() { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var metadataTemplate = """ + { + "fine_tuned_model_checkpoint": "ckpt-step-%s", + "metrics": { + "full_valid_loss": %s, + "full_valid_mean_token_accuracy": %s + }, + "fine_tuning_job_id": "ftjob-abc123", + "step_number": %s + } + """; + // Results for TEST_TASK_ID_1. + var metadata1 = metadataTemplate.formatted(1000, 0.2, 0.3, 1000); + testResultService.populateResultForTask(RESULT_ID_1, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata1)); + + var metadata2 = metadataTemplate.formatted(2000, 0.3, 0.4, 2000); + testResultService.populateResultForTask(RESULT_ID_2, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata2)); + + var metadata3 = metadataTemplate.formatted(3000, 0.4, 0.5, 3000); + testResultService.populateResultForTask(RESULT_ID_3, TEST_NCA_ID, TEST_TASK_ID_1, + (ObjectNode) jsonMapper.readTree(metadata3)); + + // Results for TEST_TASK_ID_2 to make sure there is no cross contamination query + var otherMetadata1 = metadataTemplate.formatted(3000, 0.4, 0.5, 3000); + testResultService.populateResultForTask(RESULT_ID_1, TEST_NCA_ID, TEST_TASK_ID_2, + (ObjectNode) jsonMapper.readTree(otherMetadata1)); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + resultsByTaskRepository.deleteAll(); + testTaskService.clearAll(); + } + + Stream taskResultsArgs() { + return Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_1 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("another-admin", + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3), + HttpStatus.OK), + // TEST_TASK_ID_2 belongs to TEST_NCA_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_2, + Set.of(RESULT_ID_1), + HttpStatus.OK), + // Non-existent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + "non-existent-account", + TEST_TASK_ID_1, + Set.of(), + HttpStatus.NOT_FOUND), + // Task in different account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID_2, + TEST_TASK_ID_2, + Set.of(), + HttpStatus.NOT_FOUND), + // Non-existent task. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_3, + Set.of(), + HttpStatus.NOT_FOUND), + // Missing scopes. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + Set.of(), + HttpStatus.UNAUTHORIZED) + ); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource("taskResultsArgs") + void shouldListResults(Object tokenSupplier, + String ncaId, + UUID taskId, + Set resultIds, + HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + var uri = URI.create("/v1/nvct/accounts/" + ncaId + "/tasks/" + taskId + "/results"); + var requestEntity = + RequestEntity.get(uri) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.results()).hasSize(resultIds.size()); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + for (var resultDto : responseBody.results()) { + assertThat(resultDto.createdAt()).isNotNull(); + assertThat(resultDto.ncaId()).isEqualTo(ncaId); + assertThat(resultDto.resultId()).isIn(resultIds); + assertThat(resultDto.taskId()).isEqualTo(taskId); + assertThat(resultDto.metadata()).isNotNull(); + assertThat(resultDto.metadata()).hasSize(4); + assertThat(resultDto.metadata().get("step_number").asInt()) + .isIn(Set.of(1000, 2000, 3000)); + assertThat(resultDto.name()).isNotBlank(); + } + } + + Stream taskResultsWithPaginationLimitArgs() { + return Stream.of( + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 1, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3)), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + 100, + Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3)) + ); + } + + @ParameterizedTest + @MethodSource("taskResultsWithPaginationLimitArgs") + void shouldListResultsWithPaginationLimit(String token, + String ncaId, + UUID taskId, + int limit, + Set expectedResultIds) { + var requestEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + ncaId + + "/tasks/" + taskId + "/results?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + if (limit < 3) { + assertThat(responseBody.results()).hasSize(limit); + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.limit()).isEqualTo(limit); + } else { + assertThat(responseBody.results()).hasSize(3); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + } + + verifyReturnedResultsIntegrity(expectedResultIds, responseBody.results(), ncaId, taskId); + } + + void verifyReturnedResultsIntegrity( + Set expectedResults, List results, String ncaId, UUID taskId) { + for (var resultDto : results) { + assertThat(resultDto.createdAt()).isNotNull(); + assertThat(resultDto.ncaId()).isEqualTo(ncaId); + assertThat(resultDto.resultId()).isIn(expectedResults); + assertThat(resultDto.taskId()).isEqualTo(taskId); + assertThat(resultDto.metadata()).isNotNull(); + assertThat(resultDto.metadata()).hasSize(4); + assertThat(resultDto.metadata().get("step_number").asInt()) + .isIn(Set.of(1000, 2000, 3000)); + assertThat(resultDto.name()).isNotBlank(); + } + } + + @Test + void shouldListResultsWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_EVENTS, + ADMIN_SCOPE_LIST_TASKS, + ADMIN_SCOPE_LIST_RESULTS), + 100); + var expectedResults = Set.of(RESULT_ID_1, RESULT_ID_2, RESULT_ID_3); + + // get the first 2 out of 3 results first + var url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + TEST_TASK_ID_1 + "/results?limit=2"; + var requestEntity = RequestEntity + .get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageResult = responseBody.results(); + assertThat(firstPageResult).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + + // using the cursor from previous response, continue to listing the rest of the tasks + url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + TEST_TASK_ID_1 + + "/results?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListResultsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last result is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNull(); + assertThat(responseBody.limit()).isNull(); + var secondPageResult = responseBody.results(); + assertThat(secondPageResult).hasSize(1); + + // make sure all 3 tasks have the right metadata + verifyReturnedResultsIntegrity(expectedResults, + Stream.concat(firstPageResult.stream(), + secondPageResult.stream()).toList(), + TEST_NCA_ID, + TEST_TASK_ID_1); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/SecretManagementControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/SecretManagementControllerTest.java new file mode 100644 index 000000000..dd72e8b1d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/SecretManagementControllerTest.java @@ -0,0 +1,482 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_NAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_VALUE_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_UPDATE_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OWNER_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestUtil.getToken; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.secret.dto.UpdateSecretsRequest; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class SecretManagementControllerTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestRestTemplate testRestTemplate; + + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EssService essService; + + @Autowired + private TaskService taskService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + + MockApiKeysServer.start(apiKeysBaseUrl); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockEssServer.stop(); + MockNvcfServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + Stream updateSecretArgs() { + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var jwtCases = Stream.of( + // no secrets with default resultHandlingStrategy + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + null, + null, + HttpStatus.BAD_REQUEST), + // no secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + null, + ResultHandlingStrategy.UPLOAD, + HttpStatus.BAD_REQUEST), + // single secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NO_CONTENT), + // update secret of a Task that belongs to a different account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT_2, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NOT_FOUND), + // duplicate secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("secret1") + .value(new StringNode("value2")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.BAD_REQUEST), + // Secret name -- exactly MAX_SECRET_NAME_LENGTH in length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("secret1") + .value(new StringNode("value2")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // secret names with periods, and hyphens + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name("omni.s3.us-west-2.amazonaws.com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni.s3.eu-north-1.amazonaws.com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // secret names with underscores + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name("omni_s3_us-west-2_amazonaws_com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni_s3_eu-north-1_amazonaws_com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // empty secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name("") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // long secret name - exceeds MAX_SECRET_NAME_LENGTH chars + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("secret1", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // empty secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder() + .name("") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // long secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode(StringUtils.repeat("value1", + MAX_SECRET_VALUE_LENGTH))) + .build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // bad secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100), + Set.of(SecretDto.builder().name("*secret1*-\"") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST) + ); + + var apiKeysCases = Stream.of( + // api-key with update_secrets scope and account-tasks resource + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("update_secrets")); + return "nvapi-stg-some-key"; + }, + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NO_CONTENT), + // api-key with update_secrets scope and specific task resource (matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("update_secrets")); + return "nvapi-stg-some-key"; + }, + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NO_CONTENT), + // api-key with update_secrets scope and specific task resource (not matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("update_secrets")); + return "nvapi-stg-some-key"; + }, + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.FORBIDDEN), + // api-key with scope but no resources + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("update_secrets")); + return "nvapi-stg-some-key"; + }, + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.FORBIDDEN), + // api-key with missing scope + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), List.of()); + return "nvapi-stg-some-key"; + }, + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("updateSecretArgs") + void shouldUpdateForTasksWithSecrets( + Object tokenSupplier, + Set secrets, + ResultHandlingStrategy resultHandlingStrategy, + HttpStatus expectedStatus) { + var token = getToken(tokenSupplier); + // Create a task with a secret in TEST_NCA_ID + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, resultHandlingStrategy); + essService.saveSecrets(TEST_TASK_ID_1, + Set.of(SecretDto.builder() + .name("test") + .value(new StringNode("value1")) + .build())); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Update the task with new secrets + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + // Check new secrets are actually added + var newSecretDtos = essService.getSecrets(TEST_TASK_ID_1).orElse(null); + assertThat(newSecretDtos).isNotNull().containsAll(secrets); + } + + @Test + void shouldNotUpdateForTasksWithoutSecrets() { + var tokenSupplier = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100); + var token = getToken(tokenSupplier); + // Create a task without secrets in TEST_NCA_ID + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, ResultHandlingStrategy.UPLOAD); + + // Check there are no secrets + var secretDtos = essService.getSecrets(TEST_TASK_ID_1).orElse(null); + assertThat(secretDtos).isNull(); + + // Update the task with new secrets + var secrets = Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build()); + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + Stream updateSecretTerminalStatusArgs() { + return Stream.of( + Arguments.of(ERRORED), + Arguments.of(CANCELED), + Arguments.of(COMPLETED), + Arguments.of(EXCEEDED_MAX_QUEUED_DURATION), + Arguments.of(EXCEEDED_MAX_RUNTIME_DURATION) + ); + } + + @ParameterizedTest + @MethodSource("updateSecretTerminalStatusArgs") + void shouldNotUpdateForTasksWithTerminalStatus(TaskStatus taskStatus) { + var tokenSupplier = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_UPDATE_SECRETS), + 100); + var token = getToken(tokenSupplier); + // Create a task with secrets in TEST_NCA_ID and status set to terminal + testTaskService.createTask(TEST_NCA_ID, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + taskStatus); + essService.saveSecrets(TEST_TASK_ID_1, + Set.of(SecretDto.builder() + .name("test") + .value(new StringNode("value1")) + .build())); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Update the task with new secrets + var secrets = Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build()); + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementControllerTest.java new file mode 100644 index 000000000..9f7c5f2c4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/secret/XAccountSecretManagementControllerTest.java @@ -0,0 +1,407 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.secret; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_RUNTIME_DURATION; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_UPDATE_SECRETS; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_NAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_VALUE_LENGTH; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestUtil.getToken; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.secret.dto.UpdateSecretsRequest; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountSecretManagementControllerTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestRestTemplate testRestTemplate; + + + @Autowired + private EssService essService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TaskService taskService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + + MockApiKeysServer.start(apiKeysBaseUrl); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockEssServer.stop(); + MockNvcfServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + Stream updateSecretArgs() { + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + return Stream.of( + // no secrets with default resultHandlingStrategy + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + null, + null, + HttpStatus.BAD_REQUEST), + // no secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + null, + ResultHandlingStrategy.UPLOAD, + HttpStatus.BAD_REQUEST), + // update secret for task that belongs to a different account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID_2, + Set.of(SecretDto.builder().name("secret1").value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NOT_FOUND), + // single secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("secret1").value(new StringNode("value1")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.NO_CONTENT), + // duplicate secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("secret1").value(new StringNode("value1")).build(), + SecretDto.builder().name("secret1").value(new StringNode("value2")).build()), + ResultHandlingStrategy.UPLOAD, + HttpStatus.BAD_REQUEST), + // Secret name -- exactly MAX_SECRET_NAME_LENGTH in length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder() + .name(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build(), + SecretDto.builder().name("secret1").value(new StringNode("value2")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // secret names with periods, and hyphens + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder() + .name("omni.s3.us-west-2.amazonaws.com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni.s3.eu-north-1.amazonaws.com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // secret names with underscores + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder() + .name("omni_s3_us-west-2_amazonaws_com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni_s3_eu-north-1_amazonaws_com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + ResultHandlingStrategy.NONE, + HttpStatus.NO_CONTENT), + // empty secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("").value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // long secret name - exceeds MAX_SECRET_NAME_LENGTH char + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder() + .name(StringUtils.repeat("secret1", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // empty secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode("")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // long secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode(StringUtils.repeat("value1", MAX_SECRET_VALUE_LENGTH))) + .build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST), + // bad secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100), + TEST_NCA_ID, + Set.of(SecretDto.builder().name("*secret1*-\"") + .value(new StringNode("value1")).build()), + ResultHandlingStrategy.NONE, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("updateSecretArgs") + void shouldUpdateForTasksWithSecrets( + Object tokenSupplier, + String ncaId, + Set secrets, + ResultHandlingStrategy resultHandlingStrategy, + HttpStatus expectedStatus) { + var token = getToken(tokenSupplier); + // Create a task with a secret in TEST_NCA_ID + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, resultHandlingStrategy); + essService.saveSecrets(TEST_TASK_ID_1, + Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build())); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Update the task with new secrets + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/accounts/" + ncaId + "/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + // Check new secrets are actually added + var newSecretDtos = essService.getSecrets(TEST_TASK_ID_1).orElse(null); + assertThat(newSecretDtos).isNotNull().containsAll(secrets); + } + + @Test + void shouldNotUpdateForTasksWithoutSecrets() { + var tokenSupplier = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100); + var token = getToken(tokenSupplier); + // Create a task without secrets in TEST_NCA_ID + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, ResultHandlingStrategy.UPLOAD); + + // Check there are no secrets + var secretDtos = essService.getSecrets(TEST_TASK_ID_1).orElse(null); + assertThat(secretDtos).isNull(); + + // Update the task with new secrets + var secrets = Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build()); + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + Stream updateSecretTerminalStatusArgs() { + return Stream.of( + Arguments.of(ERRORED), + Arguments.of(CANCELED), + Arguments.of(COMPLETED), + Arguments.of(EXCEEDED_MAX_QUEUED_DURATION), + Arguments.of(EXCEEDED_MAX_RUNTIME_DURATION) + ); + } + + @ParameterizedTest + @MethodSource("updateSecretTerminalStatusArgs") + void shouldNotUpdateForTasksWithTerminalStatus(TaskStatus taskStatus) { + var tokenSupplier = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_UPDATE_SECRETS), + 100); + var token = getToken(tokenSupplier); + // Create a task with secrets in TEST_NCA_ID and status set to terminal + testTaskService.createTask(TEST_NCA_ID, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + taskStatus); + essService.saveSecrets(TEST_TASK_ID_1, + Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build())); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Update the task with new secrets + var secrets = Set.of(SecretDto.builder().name("test").value(new StringNode("value1")).build()); + var updateRequestBody = UpdateSecretsRequest.builder() + .secrets(secrets) + .build(); + var url = URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/secrets/tasks/" + TEST_TASK_ID_1); + var updateRequestEntity = RequestEntity.put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(updateRequestBody); + var updateResponseEntity = testRestTemplate.exchange(updateRequestEntity, Void.class); + assertThat(updateResponseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/FilterTaskTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/FilterTaskTest.java new file mode 100644 index 000000000..1065b4ad7 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/FilterTaskTest.java @@ -0,0 +1,348 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_5; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_3; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class FilterTaskTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockEssServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @Test + void shouldListTasksWithStatus() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100); + var expectedTasks = List.of(TEST_TASK_ID_2, TEST_TASK_ID_3); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID + var task1 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, QUEUED, + jsonMapper); + var task2 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, RUNNING, + jsonMapper); + var task3 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_3, TEST_NCA_ID, TEST_TASK_NAME_3, RUNNING, + jsonMapper); + // Create Tasks in TEST_NCA_ID_2 account to make sure there is no cross contamination query + var otherTask1 = TestUtil.createTaskEntity(TEST_TASK_ID_4, TEST_NCA_ID_2, TEST_TASK_NAME_1, + jsonMapper); + var otherTask2 = TestUtil.createTaskEntity(TEST_TASK_ID_5, TEST_NCA_ID_2, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + tasksRepository.save(task3); + tasksRepository.save(otherTask1); + tasksRepository.save(otherTask2); + + // get the all tasks with status RUNNING + var url = "/v1/nvct/tasks?status=RUNNING"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(tasks).hasSize(2); + verifyReturnedTasksIntegrity(expectedTasks, RUNNING, tasks); + } + + @Test + void shouldListTasksWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100); + var expectedTasks = List.of(TEST_TASK_ID_1, TEST_TASK_ID_2, TEST_TASK_ID_3); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + var task3 = TestUtil.createTaskEntity(TEST_TASK_ID_3, TEST_NCA_ID, TEST_TASK_NAME_3, + jsonMapper); + // Create Tasks in TEST_NCA_ID_2 account to make sure there is no cross contamination query + var otherTask1 = TestUtil.createTaskEntity(TEST_TASK_ID_4, TEST_NCA_ID_2, TEST_TASK_NAME_1, + jsonMapper); + var otherTask2 = TestUtil.createTaskEntity(TEST_TASK_ID_5, TEST_NCA_ID_2, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + tasksRepository.save(task3); + tasksRepository.save(otherTask1); + tasksRepository.save(otherTask2); + + // get the first 2 out of 3 tasks first + var url = "/v1/nvct/tasks?limit=2"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageTasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(firstPageTasks).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + // using the cursor from previous response, continue to listing the rest of the tasks + url = "/v1/nvct/tasks?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last task is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNull(); + assertThat(responseBody.limit()).isNull(); + var secondPageTask = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(secondPageTask).hasSize(1); + + // make sure all 3 tasks have the right metadata + var tasks = new HashMap(); + tasks.putAll(firstPageTasks); + tasks.putAll(secondPageTask); + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + Stream listTasksWithPaginationLimitArgs() { + return Stream.of( + // List tasks in TEST_NCA_ID account with 1 limit + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + List.of(TEST_TASK_ID_2), + 1, + HttpStatus.OK), + + // List tasks in TEST_NCA_ID account with 100 limit + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + 100, + HttpStatus.OK)); + + } + + @ParameterizedTest + @MethodSource("listTasksWithPaginationLimitArgs") + void shouldListTasksWithPaginationLimit( + String token, List expectedTasks, int limit, HttpStatus expectedStatus) { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + if (limit == 1) { + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.tasks()).hasSize(1); + assertThat(responseBody.limit()).isEqualTo(limit); + } else if (limit > 2) { + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.tasks()).hasSize(2); + assertThat(responseBody.limit()).isNull(); + } + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + void verifyReturnedTasksIntegrity(List expectedTasks, TaskStatus expectedStatus, Map tasks) { + for (UUID expectedTaskId : expectedTasks) { + var taskDto = tasks.get(expectedTaskId); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.fromText(expectedStatus.toString())); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + if (taskDto.id().equals(TEST_TASK_ID_1)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + } else if (taskDto.id().equals(TEST_TASK_ID_2)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_2); + } else if (taskDto.id().equals(TEST_TASK_ID_3)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_3); + } else { + fail("unknown function returned"); + } + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()) + .isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/HelmChartBasedTaskCreationTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/HelmChartBasedTaskCreationTest.java new file mode 100644 index 000000000..73556073c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/HelmChartBasedTaskCreationTest.java @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.L40G; +import static com.nvidia.nvct.util.TestConstants.OCI; +import static com.nvidia.nvct.util.TestConstants.OCI_L40G_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART_NOT_EXISTS; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART_NOT_SUPPORTED_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART_UNKNOWN_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART_WITH_CANARY_HOST; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_VALIDATION_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class HelmChartBasedTaskCreationTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream helmBasedTaskArgs() { + return Stream.of( + Arguments.of(null, + null, + null, + TEST_HELM_CHART, + HttpStatus.OK), + Arguments.of(TEST_CONTAINER_IMAGE, + TEST_CONTAINER_ARGS, + TEST_CONTAINER_ENVIRONMENT, + null, + HttpStatus.BAD_REQUEST), + Arguments.of(TEST_CONTAINER_IMAGE, + null, + null, + null, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + TEST_CONTAINER_ARGS, + null, + null, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + null, + TEST_CONTAINER_ENVIRONMENT, + null, + HttpStatus.BAD_REQUEST), + Arguments.of(TEST_CONTAINER_IMAGE, + null, + null, + TEST_HELM_CHART, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + TEST_CONTAINER_ARGS, + null, + TEST_HELM_CHART, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + null, + TEST_CONTAINER_ENVIRONMENT, + TEST_HELM_CHART, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + null, + null, + TEST_HELM_CHART_NOT_EXISTS, + HttpStatus.NOT_FOUND), + Arguments.of(null, + null, + null, + TEST_HELM_CHART_PERMISSION_DENIED, + HttpStatus.FORBIDDEN), + Arguments.of(null, + null, + null, + TEST_HELM_CHART_UNKNOWN_REGISTRY, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + null, + null, + TEST_HELM_CHART_NOT_SUPPORTED_REGISTRY, + HttpStatus.BAD_REQUEST), + Arguments.of(null, + null, + null, + TEST_HELM_CHART_WITH_CANARY_HOST, + HttpStatus.OK) + ); + } + + @ParameterizedTest + @MethodSource("helmBasedTaskArgs") + void shouldLaunchHelmChartBasedTasks( + URI containerImage, + String containerArgs, + List containerEnvironment, + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(containerArgs) + .helmChart(helmChart) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .containerEnvironment(containerEnvironment); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().containerArgs()).isEqualTo(containerArgs); + assertThat(responseBody.task().containerEnvironment()).isEqualTo(containerEnvironment); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().gpuSpecification()).isEqualTo( + TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } + + @Test + void shouldFailToLaunchHelmChartBasedTask() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + // MockRevalServer is setup report validation failure when this configuration is used. + var configuration = jsonMapper.createObjectNode() + .put("serviceAccountName", "nvct") + .put("fail", "fail"); + var gpuSpec = GpuSpecificationDto.builder().gpu(L40G).instanceType(OCI_L40G_INSTANCE_TYPE) + .backend(OCI).configuration(configuration).build(); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .helmChart(TEST_HELM_CHART) + .tags(TEST_TAGS) + .gpuSpecification(gpuSpec) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + Stream invalidHelmChartUriArgs() { + return Stream.of( + Arguments.of(URI.create("ftp://registry.example.com/chart.tgz")), + Arguments.of(URI.create("file:///path/to/chart.tgz")), + Arguments.of(URI.create("ldap://registry.example.com/chart")), + Arguments.of(URI.create("git://registry.example.com/chart.tgz")), + Arguments.of(URI.create( + "helm.stg.ngc.nvidia.com/test-org/charts/test-chart-1.0.0.tgz")), + Arguments.of(URI.create( + "123456789000.dkr.ecr.us-west-2.amazonaws.com/test-repo/test-chart:1.0.0")) + ); + } + + @Test + void shouldStoreAndReturnHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS, + SCOPE_TASK_DETAILS), + 100); + var createRequest = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(createRequest); + var createResponse = testRestTemplate.exchange(createEntity, TaskResponse.class); + + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + var created = createResponse.getBody(); + assertThat(created).isNotNull(); + assertThat(created.task().gpuSpecification()).isEqualTo(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO); + + var taskId = created.task().id(); + var getEntity = RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var getResponse = testRestTemplate.exchange(getEntity, TaskResponse.class); + + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + var retrieved = getResponse.getBody(); + assertThat(retrieved).isNotNull(); + var retrievedSpec = retrieved.task().gpuSpecification(); + assertThat(retrievedSpec.gpu()).isEqualTo(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.gpu()); + assertThat(retrievedSpec.backend()).isEqualTo(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.backend()); + assertThat(retrievedSpec.instanceType()) + .isEqualTo(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.instanceType()); + assertThat(retrievedSpec.helmValidationPolicy()) + .isEqualTo(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.helmValidationPolicy()); + } + + @Test + @lombok.SneakyThrows + void shouldSendHelmValidationPolicyToIcms() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var createRequest = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(createRequest); + var createResponse = testRestTemplate.exchange(createEntity, TaskResponse.class); + + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var expectedPolicyJson = jsonMapper.writeValueAsString(TEST_HELM_VALIDATION_POLICY_DTO); + var policyRaw = MockIcmsServer.getCapturedHelmValidationPolicy(); + var policy = new String(Base64.getDecoder().decode(policyRaw), StandardCharsets.UTF_8); + assertThat(policy).isEqualTo(expectedPolicyJson); + } + + @ParameterizedTest + @MethodSource("invalidHelmChartUriArgs") + void shouldFailWithInvalidHelmChartUri(URI helmChartUri) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChartUri) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/ListTasksWithBasicDetailsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/ListTasksWithBasicDetailsTest.java new file mode 100644 index 000000000..009b8db7e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/ListTasksWithBasicDetailsTest.java @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.task.dto.BasicTaskDto; +import com.nvidia.nvct.rest.task.dto.BulkTaskDetailsRequest; +import com.nvidia.nvct.rest.task.dto.ListBasicTaskDetailsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class ListTasksWithBasicDetailsTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TasksRepository tasksRepository; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, QUEUED); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_2, RUNNING); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream listTasksWithBasicDetailsArgs() { + return Stream.of( + // List tasks with basic details in TEST_NCA_ID account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.OK), + // Empty set of taskIds + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + Set.of(), + HttpStatus.BAD_REQUEST), + // List basic details for a non-existent TEST_TASK_ID_3 in TEST_NCA_ID account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_3), + HttpStatus.NOT_FOUND), + // Unknown client listing tasks in an unknown account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("unknown-client-id", + List.of(SCOPE_LIST_TASKS), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, List.of(), 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("listTasksWithBasicDetailsArgs") + void shouldListTasksWithBasicDetails(String token, + Set expectedTaskIds, + HttpStatus expectedStatus) { + var requestBody = BulkTaskDetailsRequest.builder().taskIds(expectedTaskIds).build(); + var requestEntity = + RequestEntity.post(URI.create("/v1/nvct/tasks/bulk")) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListBasicTaskDetailsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.tasks()).isNotNull().isNotEmpty(); + assertThat(responseBody.tasks()).hasSize(expectedTaskIds.size()); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(BasicTaskDto::id, Function.identity())); + assertThat(tasks.keySet()).containsExactlyInAnyOrderElementsOf(expectedTaskIds); + var dtos = responseBody.tasks(); + dtos.forEach(dto -> { + assertThat(dto.id()).isIn(expectedTaskIds); + assertThat(dto.name()).isNotBlank(); + assertThat(dto.status()).isNotNull(); + }); + assertThat(responseBody.ncaId()).isEqualTo(TEST_NCA_ID); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithAcrRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithAcrRegistryTest.java new file mode 100644 index 000000000..6e22ad017 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithAcrRegistryTest.java @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_TAG; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithAcrRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.acr.hostname}") + private String acrBaseUrl; + + @Value("${nvct.registries.recognized.container.acr.oauth2.base-url}") + private String acrAuthBaseUrl; + + private MockOciRegistryServer mockAcrRegistryServer; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + + // Start ACR mock servers + MockAcrAuthServer.start(acrAuthBaseUrl); + mockAcrRegistryServer = new MockOciRegistryServer(); + mockAcrRegistryServer.start(acrBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockAcrAuthServer.stop(); + mockAcrRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream CreateTaskWithContainerImageArgs() { + return Stream.of( + // existing acr container image + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + // acr container image with digest + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST, HttpStatus.OK), + // acr container image does not exist + Arguments.of(TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS, HttpStatus.NOT_FOUND), + // acr container image no permission + Arguments.of(TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED, HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithContainerImageArgs") + void shouldLaunchContainerBasedTask( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream CreateTaskWithHelmChartArgs() { + return Stream.of( + // existing acr helm chart + Arguments.of(TEST_ACR_HELM_CHART_WITH_TAG, HttpStatus.OK), + // acr helm chart with digest + Arguments.of(TEST_ACR_HELM_CHART_WITH_DIGEST, HttpStatus.OK), + // acr helm chart does not exist + Arguments.of(TEST_ACR_HELM_CHART_NOT_EXISTS, HttpStatus.NOT_FOUND), + // acr helm chart no permission + Arguments.of(TEST_ACR_HELM_CHART_PERMISSION_DENIED, HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithHelmChartArgs") + void shouldLaunchTasksWithHelmCharts( + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChart) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithArtifactoryRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithArtifactoryRegistryTest.java new file mode 100644 index 000000000..bf309f41e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithArtifactoryRegistryTest.java @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_TAG; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.artifactory.MockArtifactoryAuthServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithArtifactoryRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.artifactory.hostname}") + private String artifactoryBaseUrl; + + @Value("${nvct.registries.recognized.container.artifactory.oauth2.base-url}") + private String artifactoryAuthBaseUrl; + + private MockOciRegistryServer mockArtifactoryRegistryServer; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + + // Start Artifactory mock servers + MockArtifactoryAuthServer.start(artifactoryAuthBaseUrl); + mockArtifactoryRegistryServer = new MockOciRegistryServer(); + mockArtifactoryRegistryServer.start(artifactoryBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockArtifactoryAuthServer.stop(); + mockArtifactoryRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream CreateTaskWithContainerImageArgs() { + return Stream.of( + // existing artifactory container image + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + // artifactory container image with digest + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST, HttpStatus.OK), + // artifactory container image does not exist + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS, HttpStatus.NOT_FOUND), + // artifactory container image no permission + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED, HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithContainerImageArgs") + void shouldLaunchContainerBasedTask( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream CreateTaskWithHelmChartArgs() { + return Stream.of( + // existing artifactory helm chart + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_TAG, HttpStatus.OK), + // artifactory helm chart with digest + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST, HttpStatus.OK), + // artifactory helm chart does not exist + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_NOT_EXISTS, HttpStatus.NOT_FOUND), + // artifactory helm chart no permission + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_PERMISSION_DENIED, HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithHelmChartArgs") + void shouldLaunchTasksWithHelmCharts( + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChart) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithDockerHubRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithDockerHubRegistryTest.java new file mode 100644 index 000000000..4404f87cf --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithDockerHubRegistryTest.java @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_HELM_CHART; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.docker.MockDockerRegistryAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithDockerHubRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.docker.hostname}") + private String dockerBaseUrl; + + @Value("${nvct.registries.recognized.container.docker.oauth2.base-url}") + private String dockerAuthBaseUrl; + + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + MockDockerRegistryAuthServer.start(dockerAuthBaseUrl); + MockDockerRegistryServer.start(dockerBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + MockDockerRegistryServer.stop(); + MockDockerRegistryAuthServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream CreateTaskWithDockerImageArgs() { + return Stream.of( + // existing docker image + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE, + HttpStatus.OK), + // docker image with digest + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST, + HttpStatus.OK), + // image doesn't exist + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS, + HttpStatus.NOT_FOUND), + // no permission + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED, + HttpStatus.FORBIDDEN)); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithDockerImageArgs") + void shouldLaunchTasksWithDockerImages( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + + + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream CreateTaskWithDockerHelmArgs() { + return Stream.of( + // existing docker helm + Arguments.of("oci://" + TEST_DOCKER_HELM_CHART, + HttpStatus.OK), + // helm doesn't exist + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS, + HttpStatus.NOT_FOUND) + // the rest situations are the same as docker containers since they share the + // same url pattern + ); + } + @ParameterizedTest + @MethodSource("CreateTaskWithDockerHelmArgs") + void shouldLaunchTasksWithDockerHelm( + URI helmUrl, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + + + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmUrl) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmUrl); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithEcrRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithEcrRegistryTest.java new file mode 100644 index 000000000..92ea7dcfd --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithEcrRegistryTest.java @@ -0,0 +1,319 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ecr.MockEcrPrivateRegistryServer; +import com.nvidia.boot.mock.ecr.MockEcrPublicRegistryServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithEcrRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.ecr.hostname}") + private String ecrPrivateBaseUrl; + + @Value("${nvct.registries.recognized.container.ecr-public.hostname}") + private String ecrPublicBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + MockEcrPrivateRegistryServer.start(ecrPrivateBaseUrl); + MockEcrPublicRegistryServer.start(ecrPublicBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + MockEcrPrivateRegistryServer.stop(); + MockEcrPublicRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream CreateTaskWithContainerImageArgs() { + return Stream.of( + // existing ecr private image + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + // ecr private image with digest + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST, HttpStatus.OK), + // ecr private image tag does not exist + Arguments.of(TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private image digest does not exist + Arguments.of(TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private image no permission + Arguments.of(TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED, HttpStatus.FORBIDDEN), + // existing ecr public image + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + // ecr public image with digest + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST, HttpStatus.OK), + // ecr public image tag does not exist + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr public image digest does not exist + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND, + HttpStatus.BAD_REQUEST), + // ecr public image no permission + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED, + HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithContainerImageArgs") + void shouldLaunchContainerBasedTask( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream CreateTaskWithHelmChartArgs() { + return Stream.of( + // existing ecr private helm chart + Arguments.of(TEST_ECR_HELM_CHART_WITH_TAG, HttpStatus.OK), + // ecr private helm chart with digest + Arguments.of(TEST_ECR_HELM_CHART_WITH_DIGEST, HttpStatus.OK), + // ecr private helm chart tag does not exist + Arguments.of(TEST_ECR_HELM_CHART_TAG_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private helm chart digest does not exist + Arguments.of(TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private helm chart no permission + Arguments.of(TEST_ECR_HELM_CHART_PERMISSION_DENIED, HttpStatus.FORBIDDEN), + + // existing ecr public helm chart + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG, HttpStatus.OK), + // ecr private helm chart with digest + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST, HttpStatus.OK), + // ecr private helm chart tag does not exist + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_TAG_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private helm chart digest does not exist + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_DIGEST_NOT_FOUND, HttpStatus.BAD_REQUEST), + // ecr private helm chart no permission + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_PERMISSION_DENIED, HttpStatus.FORBIDDEN) + ); + } + + @ParameterizedTest + @MethodSource("CreateTaskWithHelmChartArgs") + void shouldLaunchTasksWithHelmCharts( + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChart) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithHarborRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithHarborRegistryTest.java new file mode 100644 index 000000000..85c6ed321 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithHarborRegistryTest.java @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_TAG; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.harbor.MockHarborAuthServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithHarborRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.harbor.hostname}") + private String harborBaseUrl; + + @Value("${nvct.registries.recognized.container.harbor.oauth2.base-url}") + private String harborAuthBaseUrl; + + MockOciRegistryServer mockHarborRegistryServer; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + mockHarborRegistryServer = new MockOciRegistryServer(); + mockHarborRegistryServer.start(harborBaseUrl); + MockHarborAuthServer.start(harborAuthBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + mockHarborRegistryServer.stop(); + MockHarborAuthServer.stop(); + + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream createTaskWithContainerImageArgs() { + return Stream.of( + // existing harbor image + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST, HttpStatus.OK), + // harbor image tag does not exist + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS, HttpStatus.NOT_FOUND), + // harbor image mismatch with secret + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED, + HttpStatus.FORBIDDEN)); + } + + @ParameterizedTest + @MethodSource("createTaskWithContainerImageArgs") + void shouldLaunchContainerBasedTask( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream createTaskWithHelmChartArgs() { + return Stream.of( + // existing harbor helm chart + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_TAG, HttpStatus.OK), + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_DIGEST, HttpStatus.OK), + // harbor helm chart tag does not exist + Arguments.of(TEST_HARBOR_HELM_CHART_NOT_EXISTS, HttpStatus.NOT_FOUND), + // harbor helm chart mismatch with secret + Arguments.of(TEST_HARBOR_HELM_CHART_PERMISSION_DENIED, HttpStatus.FORBIDDEN)); + } + + @ParameterizedTest + @MethodSource("createTaskWithHelmChartArgs") + void shouldLaunchTasksWithHelmCharts( + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChart) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithVolcengineRegistryTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithVolcengineRegistryTest.java new file mode 100644 index 000000000..04b5832b3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithVolcengineRegistryTest.java @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_HELM_CHART_WITH_TAG; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.boot.mock.volcengine.MockVolcengineRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithVolcengineRegistryTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.volcengine.hostname}") + private String volcengineBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + MockVolcengineRegistryServer.start(volcengineBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + MockVolcengineRegistryServer.stop(); + + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream createTaskWithContainerImageArgs() { + return Stream.of( + // existing volcengine image + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG, HttpStatus.OK), + // volcengine image tag does not exist + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_TAG_NOT_FOUND, HttpStatus.NOT_FOUND) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithContainerImageArgs") + void shouldLaunchContainerBasedTask( + URI containerImage, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + } + + Stream createTaskWithHelmChartArgs() { + return Stream.of( + // existing volcengine helm chart + Arguments.of(TEST_VOLCENGINE_HELM_CHART_WITH_TAG, HttpStatus.OK), + // volcengine helm chart tag does not exist + Arguments.of(TEST_VOLCENGINE_HELM_CHART_TAG_NOT_FOUND, HttpStatus.NOT_FOUND) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithHelmChartArgs") + void shouldLaunchTasksWithHelmCharts( + URI helmChart, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(helmChart) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().helmChart()).isEqualTo(helmChart); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithoutRegistryCredentialsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithoutRegistryCredentialsTest.java new file mode 100644 index 000000000..8a3ef13dc --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskCreationWithoutRegistryCredentialsTest.java @@ -0,0 +1,260 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CUSTOM_CONTAINER_IMAGE_WITH_TAG_1; +import static com.nvidia.nvct.util.TestConstants.TEST_CUSTOM_HELM_CHART_WITH_TAG_1; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_MODEL_DTOS; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCE_DTOS; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskCreationWithoutRegistryCredentialsTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + @Test + void shouldCreateContainerTaskWithoutRegistryCredentials() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), 100); + + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CUSTOM_CONTAINER_IMAGE_WITH_TAG_1) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().containerImage()) + .isEqualTo(TEST_CUSTOM_CONTAINER_IMAGE_WITH_TAG_1); + } + + @Test + void shouldCreateHelmTaskWithoutRegistryCredentials() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), 100); + + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_CUSTOM_HELM_CHART_WITH_TAG_1) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().helmChart()) + .isEqualTo(TEST_CUSTOM_HELM_CHART_WITH_TAG_1); + } + + @Test + void shouldCreateContainerTaskWithNgcModelAndResourceWithoutContainerCredentials() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), 100); + + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CUSTOM_CONTAINER_IMAGE_WITH_TAG_1) + .models(TEST_MODEL_DTOS) + .resources(TEST_RESOURCE_DTOS) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(TEST_SECRETS) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskManagementControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskManagementControllerTest.java new file mode 100644 index 000000000..62d0c7695 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskManagementControllerTest.java @@ -0,0 +1,2335 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.removeArtifactHostName; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAGS_COUNT; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_CANCEL_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.BASE_ARTIFACT_URL; +import static com.nvidia.nvct.util.TestConstants.GFN; +import static com.nvidia.nvct.util.TestConstants.L40G; +import static com.nvidia.nvct.util.TestConstants.L40G_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_2; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_3; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_4; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_MISSING_PROTOCOL_1; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_NOT_EXISTS_1; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_UNKNOWN_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_WITH_CANARY_HOST; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_2; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_3; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_4; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_MISSING_PROTOCOL_1; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_NOT_EXISTS_1; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_UNKNOWN_REGISTRY_1; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_WITH_CANARY_HOST_1; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_5; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_NOT_SUPPORTED; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_WITHOUT_TAG; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_WITH_CANARY_HOST; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE_WITH_INVALID_TAG; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_MODEL_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_OWNER_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_3; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_ORG_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_TEAM_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_ORG_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.ArtifactDto; +import com.nvidia.nvct.rest.task.dto.ContainerEnvironmentEntryDto; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskManagementControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + + Stream createTaskArgs() { + var validGfnSpec = GpuSpecificationDto.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE).backend(GFN) + .clusters(Set.of("cluster01", "cluster02")).build(); + var specWithMissingInstanceType = GpuSpecificationDto.builder() + .gpu(L40G).backend(GFN).clusters(Set.of("cluster01", "cluster02")).build(); + var specWithInvalidInstanceType = GpuSpecificationDto.builder() + .gpu(L40G).backend(GFN).instanceType("invalid-instance-type").build(); + var specWithMissingBackend = GpuSpecificationDto.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithClusters = GpuSpecificationDto.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE) + .clusters(Set.of("cluster01", "cluster02")).build(); + var specWithMissingGpu = GpuSpecificationDto.builder() + .backend(GFN).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithInvalidGpu = GpuSpecificationDto.builder() + .gpu("invalid-gpu").backend(GFN).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithNonEmptyConfiguration = + GpuSpecificationDto.builder().gpu(L40G).instanceType(L40G_INSTANCE_TYPE) + .clusters(Set.of("cluster01", "cluster02")) + .configuration(jsonMapper.createObjectNode().put("foo", "bar")) + .build(); + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("shhh!shhh!")) + .build(), + SecretDto.builder() + .name("secret2") + .value(secretJsonNodeValue) + .build()); + var containerEnvKeyWithHyphen = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY-1").value("VALUE_1") + .build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2") + .build()); + var containerEnvKeyWithSpace = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY 1").value("VALUE_1") + .build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2") + .build()); + var containerEnvKeyWithDollar = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY$1").value("VALUE_1") + .build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2") + .build()); + + + var jwtCases = Stream.of( + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // URIs containing team name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_2, + RESOURCE_ARTIFACTS_URL_2, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // non-fully qualified model/resource URLS + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + removeArtifactHostName(MODEL_ARTIFACTS_URL_3), + removeArtifactHostName(RESOURCE_ARTIFACTS_URL_3), + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model URI containing version name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_4, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model artifact with canary hostname + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_WITH_CANARY_HOST, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Model artifact with unknown registry + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_UNKNOWN_REGISTRY_1, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model artifact url without protocol + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_MISSING_PROTOCOL_1, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model artifact with not supported registry + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model artifact with invalid token + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + // Model artifact not exists + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_NOT_EXISTS_1, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Resource URI containing version name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_4, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource artifact with canary registry + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_WITH_CANARY_HOST_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Resource artifact with unknown registry + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_UNKNOWN_REGISTRY_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource artifact url without protocol + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_MISSING_PROTOCOL_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource artifact with not supported registry + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource artifact with invalid token + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + // Resource artifact not exists + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_NOT_EXISTS_1, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Optional model. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + null, + RESOURCE_ARTIFACTS_URL_2, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Optional resource. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + null, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Model URI does not end with "/files". + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + BASE_ARTIFACT_URL + "/v2/org/ajwc672qsbdd/models/svc/bis-test:v1", + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null GpuSpecificationDto. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + null, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing instanceType in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingInstanceType, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid instanceType in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithInvalidInstanceType, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing gpu in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingGpu, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid gpu in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithInvalidGpu, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing backend in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingBackend, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Clusters only in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithClusters, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Unknown client trying to create a task in an unknown account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("some-unknown-client", + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + null, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, List.of(), 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.UNAUTHORIZED), + // Too many tags (limit=64). + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + IntStream.range(0, MAX_TAGS_COUNT + 1).mapToObj(i -> "tag" + i) + .collect(Collectors.toSet()), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Too long tags (limit=128). + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of(StringUtils.repeat("tag1", MAX_TAG_LENGTH), + StringUtils.repeat("tag2", MAX_TAG_LENGTH)), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid tag character. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("\n&abc123["), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null maxRuntimeDuration with non-GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + null, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null maxRuntimeDuration with GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + null, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Greater than 8hours of maxRuntimeDuration with GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(9), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero maxRuntimeDuration + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ZERO, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero maxQueuedDuration + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(4), + Duration.ZERO, + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero terminationGacePeriodDuration + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(4), + Duration.ofHours(3), + Duration.ZERO, + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null maxQueuedDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(2), + null, + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null terminationGracePeriodDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(2), + Duration.ofHours(3), + null, + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // terminationGracePeriodDuration greater than maxRuntimeDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(4), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Default terminationGracePeriodDuration greater than maxRuntimeDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + validGfnSpec, + Duration.ofMinutes(30), // 30minutes + Duration.ofHours(3), + null, // Default terminationGracePeriodDuration 1h + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + null, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // No NGC_API_KEY in secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + Set.of(SecretDto.builder() + .name("secret-key") + .value(new StringNode("shhh!shhh!")) + .build()), + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with just org name and resultsHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with just trailing slash after org name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with trailing slash after team name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with trailing slash after model name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/modelC/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with more than org, team, and model names in the path. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/modelC/extraD", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid/unknown org name in resultsLocation. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_UNKNOWN_ORG_NAME + "/" + TEST_MODEL_NAME, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Invalid/unknown team name in resultsLocation. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_VALID_ORG_NAME + "/" + TEST_UNKNOWN_TEAM_NAME + "/" + + TEST_MODEL_NAME, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Invalid resultsLocation with just org name and resultsHandlingStrategy NONE. + // resultsLocation is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + secrets, + "orgA", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Blank resultsLocation with resultsHandlingStrategy NONE. + // resultsLocation is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + secrets, + null, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // No NGC_API_KEY in secrets with resultHandlingStrategy NONE. Presence of + // NGC_API_KEY in the secrets is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + Set.of(SecretDto.builder() + .name("secret-key") + .value(new StringNode("shhh!shhh!")) + .build()), + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null secrets with resultHandlingStrategy NONE. Presence of NGC_API_KEY in + // the secrets is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + null, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Environment key with hyphen. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithHyphen, + HttpStatus.BAD_REQUEST), + // Environment key with space. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithSpace, + HttpStatus.BAD_REQUEST), + // Environment key with dollar. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithDollar, + HttpStatus.BAD_REQUEST), + // bad task tags + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("\n&abc123["), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Tags with special namespace:key=value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("namespace:key=value"), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_WITH_CANARY_HOST, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_WITHOUT_TAG, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_WITH_DIGEST, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_NOT_EXISTS, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_WITH_INVALID_TAG, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_PERMISSION_DENIED, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE_NOT_SUPPORTED, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithNonEmptyConfiguration, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST)); + + var apiKeysCases = Stream.of( + // api-key with launch_task scope and account-tasks resource + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("launch_task")); + return "nvapi-stg-some-key"; + }, + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // api-key with scope but no resources + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("launch_task")); + return "nvapi-stg-some-key"; + }, + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + // api-key with missing scope + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), List.of()); + return "nvapi-stg-some-key"; + }, + TEST_CONTAINER_IMAGE, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("createTaskArgs") + void shouldCreateTask(Object tokenSupplier, + URI containerImage, + String modelUri, + String resourceUri, + Set tags, + GpuSpecificationDto gpuSpecificationDto, + Duration maxRuntimeDuration, + Duration maxQueuedDuration, + Duration terminationGracePeriodDuration, + ResultHandlingStrategyEnum resultHandlingStrategy, + Set secrets, + String resultsLocation, + List containerEnvironment, + HttpStatus expectedStatus) { + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(containerImage) + .tags(tags) + .gpuSpecification(gpuSpecificationDto) + .maxRuntimeDuration(maxRuntimeDuration) + .maxQueuedDuration(maxQueuedDuration) + .terminationGracePeriodDuration(terminationGracePeriodDuration) + .description(TEST_DESCRIPTION) + .resultsLocation(resultsLocation) + .resultHandlingStrategy(resultHandlingStrategy) + .secrets(secrets) + .containerEnvironment(containerEnvironment); + + if (StringUtils.isNotBlank(modelUri)) { + requestBodyBuilder.models(Set.of( + ArtifactDto.builder().name("model-1") + .version("1.0") + .uri(URI.create(modelUri)) + .build())); + } + + if (StringUtils.isNotBlank(resourceUri)) { + requestBodyBuilder.resources(Set.of( + ArtifactDto.builder().name("resource-1") + .version("1.0") + .uri(URI.create(resourceUri)) + .build())); + } + var token = TestUtil.getToken(tokenSupplier); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().containerArgs()).isEqualTo(TEST_CONTAINER_ARGS); + assertThat(responseBody.task().helmChart()).isNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(containerImage); + assertThat(responseBody.task().containerEnvironment()).isEqualTo(containerEnvironment); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().gpuSpecification()).isEqualTo(gpuSpecificationDto); + assertThat(responseBody.task().telemetries()).isNull(); + + if (StringUtils.isNotBlank(resultsLocation)) { + assertThat(responseBody.task().resultsLocation()).isEqualTo(resultsLocation); + } else { + assertThat(responseBody.task().resultsLocation()).isBlank(); + } + + if (resultHandlingStrategy != null) { + assertThat(responseBody.task().resultHandlingStrategy()) + .isEqualTo(resultHandlingStrategy); + } else { + assertThat(responseBody.task().resultHandlingStrategy()).isNull(); + } + + if (maxRuntimeDuration != null) { + assertThat(responseBody.task().maxRuntimeDuration()).isNotNull(); + assertThat(responseBody.task().maxRuntimeDuration().toString()) + .hasToString(maxRuntimeDuration.toString()); + } else { + assertThat(responseBody.task().maxRuntimeDuration()).isNull(); + } + assertThat(responseBody.task().maxQueuedDuration()).isNotNull(); + if (maxQueuedDuration != null) { + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString(maxQueuedDuration.toString()); + } else { + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString("PT72H"); // Default value. + } + assertThat(responseBody.task().terminationGracePeriodDuration()).isNotNull(); + if (terminationGracePeriodDuration != null) { + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString(terminationGracePeriodDuration.toString()); + } else { + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString("PT1H"); // Default value + } + + if (StringUtils.isNotBlank(modelUri)) { + assertThat(responseBody.task().models()).isNotNull().hasSize(1); + var model = responseBody.task().models().stream().findFirst().get(); + if (modelUri.startsWith("http")) { + assertThat(model.getUri()).isEqualTo(URI.create(modelUri)); + } else { + // Make sure it already be transformed to fully-qualified url. + // Since we are reading the artifact hostname from + assertThat(model.getUri()).isEqualTo(URI.create("https://localhost-ngc" + modelUri)); + } + } else { + assertThat(responseBody.task().models()).isNull(); + } + if (StringUtils.isNotBlank(resourceUri)) { + assertThat(responseBody.task().resources()).isNotNull().hasSize(1); + var resource = responseBody.task().resources().stream().findFirst().get(); + if (resourceUri.startsWith("http")) { + assertThat(resource.getUri()).isEqualTo(URI.create(resourceUri)); + } else { + // Make sure it already be transformed to fully-qualified url. + assertThat(resource.getUri()).isEqualTo( + URI.create("https://localhost-ngc" + resourceUri)); + } + } else { + assertThat(responseBody.task().resources()).isNull(); + } + + assertThat(responseBody.task().tags()).isEqualTo(tags); + assertThat(responseBody.task().description()).isEqualTo(TEST_DESCRIPTION); + + var taskId = responseBody.task().id(); + var entity = tasksRepository + .getByTaskId(taskId) + .orElseThrow(() -> new NotFoundException("Task not found")); + assertThat(entity).isNotNull(); + assertThat(entity.getHealth()).isBlank(); + } + + @Test + void shouldThrowExceptionForTooLongDescription() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(3)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + // Create a description that exceeds MAX_DESCRIPTION_LENGTH (256) + .description(StringUtils.repeat("a", 257)) + .resultHandlingStrategy(ResultHandlingStrategyEnum.NONE) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @Disabled + void shouldThrowTooManyTasksException() { + // Create 1st Task in TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5 account that is + // associated with client TEST_CLIENT_ID_5. TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5 is + // set up with maxTasksAllowed = 1. + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_5, + List.of(SCOPE_LAUNCH_TASK), + 100); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(GpuSpecificationDto.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE).backend(GFN) + .clusters(Set.of("cluster01", "cluster02")).build()) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(2)) + .terminationGracePeriodDuration(Duration.ofHours(2)) + .description(TEST_DESCRIPTION) + .resultHandlingStrategy(ResultHandlingStrategyEnum.NONE) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Create 2nd Task, expecting BAD_REQUEST. + var secondRequestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + responseEntity = testRestTemplate.exchange(secondRequestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + Stream listTasksArgs() { + return Stream.of( + // List tasks in TEST_NCA_ID account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_LIST_TASKS), + 100), + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.OK), + // List non-existent tasks in TEST_NCA_ID_2 account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_2, + List.of(SCOPE_LIST_TASKS), + 100), + List.of(), + HttpStatus.OK), + // Unknown client listing tasks in an unknown account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("unknown-client-id", + List.of(SCOPE_LIST_TASKS), + 100), + List.of(), + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, List.of(), 100), + List.of(), + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + List.of(), + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("listTasksArgs") + void shouldListTasks(String token, List expectedTasks, HttpStatus expectedStatus) { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + assertThat(expectedTasks).isNotNull(); + assertThat(responseBody.tasks()).hasSize(expectedTasks.size()); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(tasks.keySet()).isEqualTo(new HashSet<>(expectedTasks)); + + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + void verifyReturnedTasksIntegrity( + List expectedTasks, + TaskStatus expectedStatus, + Map tasks) { + for (UUID expectedTaskId : expectedTasks) { + var taskDto = tasks.get(expectedTaskId); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo( + TaskStatusEnum.fromText(expectedStatus.toString())); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + if (taskDto.id().equals(TEST_TASK_ID_1)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + } else if (taskDto.id().equals(TEST_TASK_ID_2)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_2); + } else if (taskDto.id().equals(TEST_TASK_ID_3)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_3); + } else { + fail("unknown function returned"); + } + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()) + .isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + } + + Stream taskArgs() { + var jwtCases = Stream.of( + // TEST_TASK_ID_1 belongs to NCA ID tied to TEST_CLIENT_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.OK), + // TEST_TASK_ID_3 does not exist. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_3, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the NCA ID tied to TEST_CLIENT_ID_2. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_2, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Unknown subject/client. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("unknown-client-id", + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, List.of(), 100), + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_TASK_ID_1, + HttpStatus.UNAUTHORIZED) + ); + + var apiKeysCases = Stream.of( + // api-key with task_details scope and account-tasks resource + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("task_details")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.OK), + // api-key with task_details scope and specific task resource (matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("task_details")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.OK), + // api-key with task_details scope and specific task resource (not matching) + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("task_details")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // api-key with scope but no resources + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("task_details")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // api-key with missing scope + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), List.of()); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("taskArgs") + void shouldGetTaskDetails(Object tokenSupplier, UUID taskId, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + testTaskService.createTaskWithModelAndResources(TEST_NCA_ID, TEST_TASK_ID_1, + UUID.randomUUID()); + testTaskService.createTaskWithModelAndResources(TEST_NCA_ID, TEST_TASK_ID_2, + UUID.randomUUID()); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/tasks/" + taskId)) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task()).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + assertThat(taskDto.name()).isEqualTo("Task-" + taskDto.id()); + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()).isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + assertThat(taskDto.resultsLocation()).isNotEmpty(); + assertThat(taskDto.resultHandlingStrategy()).isEqualTo(ResultHandlingStrategyEnum.UPLOAD); + assertThat(taskDto.instances()).isNotEmpty(); + } + + Stream cancelTaskArgs() { + var jwtCases = Stream.of( + // TEST_TASK_ID_1 belongs to NCA ID tied to TEST_CLIENT_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + QUEUED, + HttpStatus.OK), + // TEST_TASK_ID_3 does not exist. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_3, + QUEUED, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the NCA ID tied to TEST_CLIENT_ID_2. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_2, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + QUEUED, + HttpStatus.NOT_FOUND), + // Unknown subject/client. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("unknown-client-id", + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + QUEUED, + HttpStatus.NOT_FOUND), + // Task status is CANCELED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + CANCELED, + HttpStatus.BAD_REQUEST), + // Task status is ERRORED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + ERRORED, + HttpStatus.BAD_REQUEST), + // Task status is COMPLETED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + COMPLETED, + HttpStatus.BAD_REQUEST), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, List.of(), 100), + TEST_TASK_ID_1, + QUEUED, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.UNAUTHORIZED) + ); + + var apiKeysCases = Stream.of( + // api-key authorized to cancel any private tasks of TEST_NCA_ID account. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("cancel_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.OK), + // api-key authorized to cancel specific task of TEST_NCA_ID account. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("cancel_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.OK), + // api-key attempt to cancel a task that does not have a matching resource entry. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("cancel_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.FORBIDDEN), + // api-key attempt to cancel with no resource entries in the policy result. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("cancel_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("cancelTaskArgs") + void shouldCancelTask(Object tokenSupplier, UUID taskId, TaskStatus status, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + task1.setStatus(status); + task2.setStatus(status); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.post(URI.create("/v1/nvct/tasks/" + taskId + "/cancel")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task()).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.CANCELED); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()).isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + + Stream deleteTaskArgs() { + var jwtCases = Stream.of( + // TEST_TASK_ID_1 belongs to NCA ID tied to TEST_CLIENT_ID. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.NO_CONTENT), + // TEST_TASK_ID_3 does not exist. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_3, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the NCA ID tied to TEST_CLIENT_ID_2. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_2, + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Unknown subject/client. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("unknown-client-id", + List.of(SCOPE_TASK_DETAILS, + SCOPE_CANCEL_TASK, + SCOPE_DELETE_TASK), + 100), + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID, List.of(), 100), + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_TASK_ID_1, + HttpStatus.UNAUTHORIZED) + ); + + var apiKeysCases = Stream.of( + // api-key authorized to delete any private tasks of TEST_NCA_ID account. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("account-tasks", "*")), + List.of("delete_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.NO_CONTENT), + // api-key authorized to delete specific task of TEST_NCA_ID account. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_1.toString())), + List.of("delete_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.NO_CONTENT), + // api-key attempt to delete a task that does not have a matching resource entry. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(new Resource("task", TEST_TASK_ID_2.toString())), + List.of("delete_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // api-key attempt to delete with no resource entries in the policy result. + Arguments.of((Supplier) () -> { + MockApiKeysServer.setResponse(TEST_NCA_ID, TEST_OWNER_ID, + List.of(), + List.of("delete_task")); + return "nvapi-stg-some-key"; + }, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN) + ); + + return Stream.concat(jwtCases, apiKeysCases); + } + + @ParameterizedTest + @MethodSource("deleteTaskArgs") + void shouldDeleteTask(Object tokenSupplier, UUID taskId, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.delete(URI.create("/v1/nvct/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + var entity = tasksRepository.getByTaskId(taskId); + if (expectedStatus.isError()) { + if (responseEntity.getStatusCode() != HttpStatus.NOT_FOUND) { + assertThat(entity).isNotEmpty(); + } + return; + } + assertThat(entity).isEmpty(); + } + + @Test + void shouldFailWhenGracePeriodIsGreaterThanMaxRuntimeDuration() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100); + var modelUri = URI.create(BASE_ARTIFACT_URL + + "/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files"); + var modelDtos = Set.of(ArtifactDto.builder().name("model-1") + .version("1.0").uri(modelUri).build()); + var resourceUri = URI.create(RESOURCE_ARTIFACTS_URL_3); + var resourceDtos = Set.of(ArtifactDto.builder().name("resource-1") + .version("1.0") + .uri(resourceUri) + .build()); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .models(modelDtos) + .resources(resourceDtos) + .tags(TEST_TAGS) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(3)) + .terminationGracePeriodDuration(Duration.ofHours(5)) + .description(TEST_DESCRIPTION); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithHelmValidationPolicyTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithHelmValidationPolicyTest.java new file mode 100644 index 000000000..8851a01f9 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithHelmValidationPolicyTest.java @@ -0,0 +1,411 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_VALIDATION_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.ValidationPolicyNameEnum; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskWithHelmValidationPolicyTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockCasServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + } + + Stream createTaskWithHelmValidationPolicyArgs() { + var validPolicyDefault = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var validPolicyUnrestricted = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.UNRESTRICTED) + .build(); + var validPolicyNoExtraTypes = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .build(); + var invalidPolicyNullName = HelmValidationPolicyDto.builder() + .name(null) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankGroup = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankVersion = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankKind = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("") + .build())) + .build(); + var invalidPolicyEmptyExtraTypes = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of()) + .build(); + + return Stream.of( + // Valid policy with DEFAULT name and extraKubernetesTypes. + Arguments.of(validPolicyDefault, HttpStatus.OK), + // Valid policy with UNRESTRICTED name and no extraKubernetesTypes. + Arguments.of(validPolicyUnrestricted, HttpStatus.OK), + // Valid policy with DEFAULT name and no extraKubernetesTypes. + Arguments.of(validPolicyNoExtraTypes, HttpStatus.OK), + // Invalid policy with null name. + Arguments.of(invalidPolicyNullName, HttpStatus.BAD_REQUEST), + // Invalid policy with blank group in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankGroup, HttpStatus.BAD_REQUEST), + // Invalid policy with blank version in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankVersion, HttpStatus.BAD_REQUEST), + // Invalid policy with blank kind in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankKind, HttpStatus.BAD_REQUEST), + // Invalid policy with empty extraKubernetesTypes list. + Arguments.of(invalidPolicyEmptyExtraTypes, HttpStatus.BAD_REQUEST)); + } + + @ParameterizedTest + @MethodSource("createTaskWithHelmValidationPolicyArgs") + void shouldCreateTaskWithHelmValidationPolicy( + HelmValidationPolicyDto helmValidationPolicy, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_LIST_TASKS), + 100); + var gpuSpec = GpuSpecificationDto.builder() + .gpu(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.gpu()) + .instanceType(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.instanceType()) + .backend(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.backend()) + .helmValidationPolicy(helmValidationPolicy) + .build(); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .tags(TEST_TAGS) + .gpuSpecification(gpuSpec) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().gpuSpecification()).isNotNull(); + assertThat(responseBody.task().gpuSpecification().helmValidationPolicy()) + .isEqualTo(helmValidationPolicy); + } + + @Test + void shouldListTasksWithHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_LIST_TASKS), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var createResponse = + testRestTemplate.exchange(createEntity, TaskResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var listEntity = RequestEntity.get(URI.create("/v1/nvct/tasks")) + .header("Authorization", "Bearer " + token) + .build(); + var listResponse = + testRestTemplate.exchange(listEntity, ListTasksResponse.class); + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = listResponse.getBody(); + assertThat(responseBody).isNotNull(); + + var tasks = responseBody.tasks(); + assertThat(tasks).isNotEmpty(); + + tasks.forEach(task -> { + assertThat(task.gpuSpecification()).isNotNull(); + assertThat(task.gpuSpecification().helmValidationPolicy()) + .isEqualTo(TEST_HELM_VALIDATION_POLICY_DTO); + }); + } + + @Test + void shouldGetTaskDetailsWithHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_TASK_DETAILS), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var createResponse = + testRestTemplate.exchange(createEntity, TaskResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = createResponse.getBody().task().id(); + + var getEntity = RequestEntity + .get(URI.create("/v1/nvct/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var getResponse = + testRestTemplate.exchange(getEntity, TaskResponse.class); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = getResponse.getBody(); + assertThat(responseBody).isNotNull(); + + var task = responseBody.task(); + assertThat(task).isNotNull(); + assertThat(task.id()).isEqualTo(taskId); + assertThat(task.gpuSpecification()).isNotNull(); + assertThat(task.gpuSpecification().helmValidationPolicy()) + .isEqualTo(TEST_HELM_VALIDATION_POLICY_DTO); + } + + @Test + void shouldRejectContainerBasedTaskWithHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100); + var gpuSpec = GpuSpecificationDto.builder() + .gpu(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.gpu()) + .instanceType(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.instanceType()) + .backend(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.backend()) + .helmValidationPolicy(TEST_HELM_VALIDATION_POLICY_DTO) + .build(); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(gpuSpec) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithTelemetriesTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithTelemetriesTest.java new file mode 100644 index 000000000..bc9d473e6 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithTelemetriesTest.java @@ -0,0 +1,398 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_TELEMETRIES_4; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_LOGS_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_METRICS_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_TRACES_ID; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.telemetry.dto.TelemetriesDto; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; +import tools.jackson.databind.json.JsonMapper; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskWithTelemetriesTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @SneakyThrows + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockCasServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + } + + Stream createTaskWithTelemetriesArgs() { + var validCompleteTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + var validPartialTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .build(); + var mismatchedTelemtriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_TRACES_ID) + .metricsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .tracesTelemetryId(TEST_TELEMETRY_METRICS_ID) + .build(); + var mismatchedPartialTelemtriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + + return Stream.of( + // Valid telemetries with token for a client that is associated with an account + // with telemetries. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + validCompleteTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + TEST_TELEMETRY_METRICS_ID, + TEST_TELEMETRY_TRACES_ID, + HttpStatus.OK), + // Valid telemetries with token for a client that is associated with an account + // with telemetries. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + validPartialTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + null, + null, + HttpStatus.OK), + // Valid telemetries with token for a client that is NOT associated with an account + // with telemetries. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + validCompleteTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + TEST_TELEMETRY_METRICS_ID, + TEST_TELEMETRY_TRACES_ID, + HttpStatus.BAD_REQUEST), + // Invalid telemetries with token for a client that is associated with an account + // with telemetries. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + mismatchedTelemtriesDto, + null, + null, + null, + HttpStatus.BAD_REQUEST), + // Invalid telemetries with token for a client that is associated with an account + // with telemetries. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_LAUNCH_TASK, SCOPE_LIST_TASKS), + 100), + mismatchedPartialTelemtriesDto, + null, + null, + null, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithTelemetriesArgs") + void shouldCreateTaskWithTelemetries( + String token, + TelemetriesDto telemetriesDto, + UUID logsTelemetryId, + UUID metricsTelemetryId, + UUID tracesTelemetryId, + HttpStatus expectedStatus) { + var maxRuntimeDuration = Duration.ofHours(2); + var maxQueuedDuration = Duration.ofHours(3); + var terminationGracePeriodDuration = Duration.ofHours(1); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(maxRuntimeDuration) + .maxQueuedDuration(maxQueuedDuration) + .terminationGracePeriodDuration(terminationGracePeriodDuration) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT) + .telemetries(telemetriesDto) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID_WITH_TELEMETRIES_4); + assertThat(responseBody.task().containerArgs()).isEqualTo(TEST_CONTAINER_ARGS); + assertThat(responseBody.task().helmChart()).isNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(TEST_CONTAINER_IMAGE); + assertThat(responseBody.task().containerEnvironment()).isEqualTo( + TEST_CONTAINER_ENVIRONMENT); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().gpuSpecification()).isEqualTo(TEST_OCI_GPU_SPEC_DTO); + assertThat(responseBody.task().resultsLocation()).isEqualTo(TEST_RESULTS_LOCATION_1); + assertThat(responseBody.task().resultHandlingStrategy()) + .isEqualTo(ResultHandlingStrategyEnum.UPLOAD); + assertThat(responseBody.task().maxRuntimeDuration()).isNotNull(); + assertThat(responseBody.task().maxRuntimeDuration().toString()) + .hasToString(maxRuntimeDuration.toString()); + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString(maxQueuedDuration.toString()); + assertThat(responseBody.task().terminationGracePeriodDuration()).isNotNull(); + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString(terminationGracePeriodDuration.toString()); + assertThat(responseBody.task().tags()).isEqualTo(TEST_TAGS); + assertThat(responseBody.task().description()).isEqualTo(TEST_DESCRIPTION); + + var actualTelemetriesDto = responseBody.task().telemetries(); + assertThat(actualTelemetriesDto).isNotNull(); + assertThat(actualTelemetriesDto.logsTelemetryId()).isEqualTo(logsTelemetryId); + assertThat(actualTelemetriesDto.metricsTelemetryId()).isEqualTo(metricsTelemetryId); + assertThat(actualTelemetriesDto.tracesTelemetryId()).isEqualTo(tracesTelemetryId); + + var taskId = responseBody.task().id(); + var entity = tasksRepository + .getByTaskId(taskId) + .orElseThrow(() -> new NotFoundException("Task not found")); + assertThat(entity).isNotNull(); + assertThat(entity.getHealth()).isBlank(); + } + + @Test + void shouldListTasksWithTelemetries() { + // Create Task in TEST_NCA_ID_WITH_TELEMETRIES_4 account with telemetries. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + + // Create token for the client that is associated with TEST_NCA_ID_WITH_TELEMETRIES_4 + // account. + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_LIST_TASKS), + 100); + var requestEntity = RequestEntity.get(URI.create("/v1/nvct/tasks")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var tasks = responseBody.tasks(); + assertThat(tasks).isNotEmpty(); + + tasks.forEach(task -> { + assertThat(task.telemetries()).isNotNull(); + assertThat(task.telemetries().logsTelemetryId()).isEqualTo(TEST_TELEMETRY_LOGS_ID); + assertThat(task.telemetries().metricsTelemetryId()).isEqualTo( + TEST_TELEMETRY_METRICS_ID); + assertThat(task.telemetries().tracesTelemetryId()).isEqualTo(TEST_TELEMETRY_TRACES_ID); + }); + } + + @Test + void shouldGetTaskDetailsWithTelemetries() { + // Create Task in TEST_NCA_ID_WITH_TELEMETRIES_4 account with telemetries. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + + // Create token for the client that is associated with TEST_NCA_ID_WITH_TELEMETRIES_4 + // account. + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_ID_4, + List.of(SCOPE_TASK_DETAILS), + 100); + var requestEntity = RequestEntity.get(URI.create("/v1/nvct/tasks/" + TEST_TASK_ID_1)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var task = responseBody.task(); + assertThat(task).isNotNull(); + assertThat(task.id()).isEqualTo(TEST_TASK_ID_1); + + assertThat(task.telemetries()).isNotNull(); + assertThat(task.telemetries().logsTelemetryId()).isEqualTo(TEST_TELEMETRY_LOGS_ID); + assertThat(task.telemetries().metricsTelemetryId()).isEqualTo(TEST_TELEMETRY_METRICS_ID); + assertThat(task.telemetries().tracesTelemetryId()).isEqualTo(TEST_TELEMETRY_TRACES_ID); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithUserSecretsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithUserSecretsTest.java new file mode 100644 index 000000000..286e02cc9 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/TaskWithUserSecretsTest.java @@ -0,0 +1,578 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_NAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_VALUE_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class TaskWithUserSecretsTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private EssService essService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TasksRepository tasksRepository; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockEssServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + Stream createTaskWithSecretsArgs() { + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token") + .put(NGC_API_KEY, "value2");; + return Stream.of( + // no secrets with default resultHandlingStrategy + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + null, + null, + null, + HttpStatus.BAD_REQUEST), + // no secrets with resultHandlingStrategy UPLOAD(default) + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + null, + null, + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.BAD_REQUEST), + // Only NGC_API_KEY secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY) + .value(new StringNode("value1")).build()), + Set.of(NGC_API_KEY), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // single secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY) + .value(new StringNode("value1")).build()), + Set.of(NGC_API_KEY), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // multiple secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY) + .value(new StringNode("value1")).build(), + SecretDto.builder().name("secret2") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("secret3") + .value(secretJsonNodeValue).build() + ), + Set.of(NGC_API_KEY, "secret2", "secret3"), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // Secret name -- exactly MAX_SECRET_NAME_LENGTH in length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + Set.of(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // secret names with periods, and hyphens + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("omni.s3.us-west-2.amazonaws.com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni.s3.eu-north-1.amazonaws.com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build() + ), + Set.of("omni.s3.us-west-2.amazonaws.com", + "omni.s3.eu-north-1.amazonaws.com", + "omni.s3.ap-northeast-1.amazonaws.com"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // secret names with underscores + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("omni_s3_us-west-2_amazonaws_com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni_s3_eu-north-1_amazonaws_com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni_s3.ap-northeast-1_amazonaws_com") + .value(secretJsonNodeValue).build()), + Set.of("omni_s3_us-west-2_amazonaws_com", + "omni_s3_eu-north-1_amazonaws_com", + "omni_s3.ap-northeast-1_amazonaws_com"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // resultHandlingStrategy NONE and missing NGC_API_KEY + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("secret2") + .value(new StringNode("value2")).build()), + Set.of("secret2"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // resultHandlingStrategy UPLOAD and missing NGC_API_KEY + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("secret2") + .value(new StringNode("value2")).build()), + Set.of("secret2"), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.BAD_REQUEST), + // duplicate secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value2")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // empty secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("") + .value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // long secret name - exceeds MAX_SECRET_NAME_LENGTH length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("secret1", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // empty secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode("")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // long secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode(StringUtils.repeat("value1", + MAX_SECRET_VALUE_LENGTH))) + .build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // bad secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name("*secret1*-\"") + .value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithSecretsArgs") + void shouldCreateTaskWithSecrets( + String token, + Set secrets, + Set expectedSecretNames, + ResultHandlingStrategyEnum resultHandlingStrategy, + HttpStatus expectedStatus) { + // Create the task in TEST_NCA_ID + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(resultHandlingStrategy) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var taskId = responseBody.task().id(); + var taskDto = responseBody.task(); + if (expectedSecretNames == null) { + assertThat(taskDto.secrets()).isNull(); + } else { + assertThat(taskDto.secrets()) + .containsExactlyInAnyOrderElementsOf(expectedSecretNames); + + // Confirm secrets are saved in ESS + var returnedNames = essService.getSecretNames(taskId).orElse(null); + assertThat(returnedNames).isNotNull(); + assertThat(returnedNames).containsExactlyInAnyOrderElementsOf(expectedSecretNames); + + // Confirm hasSecrets is set + var taskEntity = tasksRepository.getByTaskId(taskId).orElse(null); + assertThat(taskEntity).isNotNull(); + assertThat(taskEntity.hasSecrets()).isTrue(); + } + + } + + @Test + void shouldListTasksWithSecretsByAccount() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_LIST_TASKS), + 100); + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // After task creation, list task + var endpoint = "/v1/nvct/tasks"; + var listRequestEntity = RequestEntity.get(URI.create(endpoint)) + .header("Authorization", "Bearer " + token) + .build(); + var listResponseEntity = testRestTemplate.exchange(listRequestEntity, ListTasksResponse.class); + var responseBody = listResponseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.tasks()).hasSize(1); + + var taskDto = responseBody.tasks().getFirst(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.secrets()).isNull(); + } + + Stream listTasksWithSecretsArgs() { + return Stream.of( + Arguments.of(Boolean.TRUE), + Arguments.of(Boolean.FALSE), + Arguments.of(Boolean.TRUE), + Arguments.of(Boolean.FALSE)); + } + + @ParameterizedTest + @MethodSource("listTasksWithSecretsArgs") + void shouldGetTaskDetailsWithSecretsByAccount(Boolean includeSecrets) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_TASK_DETAILS), + 100); + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var expectedSecretNames = Set.of("AWS_SECRET_ACCESS_KEY", + "NGC_API_KEY", "OV.US-WEST-2.CONTENT"); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = responseEntity.getBody().task().id(); + + // After task creation, get task details + var endpoint = "/v1/nvct/tasks/" + taskId; + if (includeSecrets != null) { + endpoint += "?includeSecrets=" + includeSecrets; + } + var listRequestEntity = RequestEntity.get(URI.create(endpoint)) + .header("Authorization", "Bearer " + token) + .build(); + var listResponseEntity = testRestTemplate.exchange(listRequestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = listResponseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + if ((includeSecrets == null) || includeSecrets) { + assertThat(taskDto.secrets()).isNotNull(); + assertThat(taskDto.secrets()).containsExactlyInAnyOrderElementsOf(expectedSecretNames); + } else { + assertThat(taskDto.secrets()).isNull(); + } + } + + @Test + void shouldDeleteTaskWithSecrets() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_LAUNCH_TASK, + SCOPE_DELETE_TASK), + 100); + + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .secrets(secrets) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .build(); + var requestEntity = + RequestEntity.post(URI.create("/v1/nvct/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = responseEntity.getBody().task().id(); + + // Make sure there are secrets first before deleting a task + var secretDtos = essService.getSecrets(taskId).orElse(null); + assertThat(secretDtos).isNotNull().hasSize(3); + + // Confirm hasSecrets is set + var taskEntity = tasksRepository.getByTaskId(taskId).orElse(null); + assertThat(taskEntity).isNotNull(); + assertThat(taskEntity.hasSecrets()).isTrue(); + + var deleteRequestEntity = + RequestEntity.delete(URI.create("/v1/nvct/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var deleteResponseEntity = testRestTemplate.exchange(deleteRequestEntity, Void.class); + assertThat(deleteResponseEntity.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + var entity = tasksRepository + .getByTaskId(taskId); + assertThat(entity).isEmpty(); + + // Make sure secrets no longer exist after task deletion + secretDtos = essService.getSecrets(taskId).orElse(null); + assertThat(secretDtos).isNull(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountFilterTaskTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountFilterTaskTest.java new file mode 100644 index 000000000..1357a5b78 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountFilterTaskTest.java @@ -0,0 +1,350 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_5; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_3; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountFilterTaskTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockEssServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + Stream listTasksWithPaginationLimitArgs() { + return Stream.of( + // List tasks in TEST_NCA_ID account with 1 limit + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + List.of(TEST_TASK_ID_2), + 1, + HttpStatus.OK), + + // List tasks in TEST_NCA_ID account with 100 limit + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + 100, + HttpStatus.OK)); + + } + + @ParameterizedTest + @MethodSource("listTasksWithPaginationLimitArgs") + void shouldListTasksWithPaginationLimit( + String token, String ncaId, List expectedTasks, int limit, HttpStatus expectedStatus) { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks?limit=" + limit)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + if (limit == 1) { + assertThat(responseBody.cursor()).isNotNull(); + assertThat(responseBody.tasks()).hasSize(1); + assertThat(responseBody.limit()).isEqualTo(limit); + } else if (limit > 2) { + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + assertThat(responseBody.tasks()).hasSize(2); + } + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + @Test + void shouldListTasksWithPaginationCursor() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100); + var expectedTasks = List.of(TEST_TASK_ID_1, TEST_TASK_ID_2, TEST_TASK_ID_3); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + var task3 = TestUtil.createTaskEntity(TEST_TASK_ID_3, TEST_NCA_ID, TEST_TASK_NAME_3, + jsonMapper); + // Create Tasks in TEST_NCA_ID_2 account to make sure there is no cross contamination query + var otherTask1 = TestUtil.createTaskEntity(TEST_TASK_ID_4, TEST_NCA_ID_2, TEST_TASK_NAME_1, + jsonMapper); + var otherTask2 = TestUtil.createTaskEntity(TEST_TASK_ID_5, TEST_NCA_ID_2, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + tasksRepository.save(task3); + tasksRepository.save(otherTask1); + tasksRepository.save(otherTask2); + + // get the first 2 out of 3 tasks first + var url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks?limit=2"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNotNull(); + var firstPageTasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(firstPageTasks).hasSize(2); + assertThat(responseBody.limit()).isEqualTo(2); + + // using the cursor from previous response, continue to listing the rest of the tasks + url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks?limit=2&cursor=" + currentCursor; + requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // make sure the last task is correct + responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + currentCursor = responseBody.cursor(); + assertThat(currentCursor).isNull(); + assertThat(responseBody.limit()).isNull(); + var secondPageTask = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(secondPageTask).hasSize(1); + + // make sure all 3 tasks have the right metadata + var tasks = new HashMap(); + tasks.putAll(firstPageTasks); + tasks.putAll(secondPageTask); + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + @Test + void shouldListTasksWithStatus() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100); + var expectedTasks = List.of(TEST_TASK_ID_2, TEST_TASK_ID_3); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID + var task1 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, QUEUED, + jsonMapper); + var task2 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, RUNNING, + jsonMapper); + var task3 = TestUtil.createContainerBasedTaskEntity(TEST_TASK_ID_3, TEST_NCA_ID, TEST_TASK_NAME_3, RUNNING, + jsonMapper); + // Create Tasks in TEST_NCA_ID_2 account to make sure there is no cross contamination query + var otherTask1 = TestUtil.createTaskEntity(TEST_TASK_ID_4, TEST_NCA_ID_2, TEST_TASK_NAME_1, + jsonMapper); + var otherTask2 = TestUtil.createTaskEntity(TEST_TASK_ID_5, TEST_NCA_ID_2, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + tasksRepository.save(task3); + tasksRepository.save(otherTask1); + tasksRepository.save(otherTask2); + + // get all the tasks with RUNNING status + var url = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks?status=RUNNING"; + var requestEntity = + RequestEntity.get(URI.create(url)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(tasks).hasSize(2); + verifyReturnedTasksIntegrity(expectedTasks, RUNNING, tasks); + } + + void verifyReturnedTasksIntegrity(List expectedTasks, TaskStatus expectedStatus, Map tasks) { + for (UUID expectedTaskId : expectedTasks) { + var taskDto = tasks.get(expectedTaskId); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.fromText(expectedStatus.toString())); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + if (taskDto.id().equals(TEST_TASK_ID_1)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + } else if (taskDto.id().equals(TEST_TASK_ID_2)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_2); + } else if (taskDto.id().equals(TEST_TASK_ID_3)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_3); + } else { + fail("unknown function returned"); + } + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()) + .isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountListTasksWithBasicDetailsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountListTasksWithBasicDetailsTest.java new file mode 100644 index 000000000..a11e49d86 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountListTasksWithBasicDetailsTest.java @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.BasicTaskDto; +import com.nvidia.nvct.rest.task.dto.BulkTaskDetailsRequest; +import com.nvidia.nvct.rest.task.dto.ListBasicTaskDetailsResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountListTasksWithBasicDetailsTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private AccountService accountService; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, QUEUED); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_2, RUNNING); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream listTasksWithBasicDetailsArgs() { + return Stream.of( + // List tasks with basic details in TEST_NCA_ID account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.OK), + // Empty set of taskIds + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + Set.of(), + HttpStatus.BAD_REQUEST), + // List basic details for a non-existent TEST_TASK_ID_3 in TEST_NCA_ID account + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_3), + HttpStatus.NOT_FOUND), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(), + 100), + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + Set.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("listTasksWithBasicDetailsArgs") + void shouldListTasksWithBasicDetails(String token, + Set expectedTaskIds, + HttpStatus expectedStatus) { + var requestBody = BulkTaskDetailsRequest.builder().taskIds(expectedTaskIds).build(); + var requestEntity = + RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/bulk")) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListBasicTaskDetailsResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.tasks()).isNotNull().isNotEmpty(); + assertThat(responseBody.tasks()).hasSize(expectedTaskIds.size()); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(BasicTaskDto::id, Function.identity())); + assertThat(tasks.keySet()).containsExactlyInAnyOrderElementsOf(expectedTaskIds); + var dtos = responseBody.tasks(); + dtos.forEach(dto -> { + assertThat(dto.id()).isIn(expectedTaskIds); + assertThat(dto.name()).isNotBlank(); + assertThat(dto.status()).isNotNull(); + }); + assertThat(responseBody.ncaId()).isEqualTo(TEST_NCA_ID); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountMiscEndpointsControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountMiscEndpointsControllerTest.java new file mode 100644 index 000000000..6bec8812c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountMiscEndpointsControllerTest.java @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.misc.dto.GpuPlacementDto; +import com.nvidia.nvct.rest.misc.dto.GpuUsageDto; +import com.nvidia.nvct.rest.misc.dto.ListGpuUsageResponse; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@TestInstance(Lifecycle.PER_CLASS) +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountMiscEndpointsControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + } + + Stream gpuUsageArgs() { + return Stream.of( + Arguments.of(null, HttpStatus.UNAUTHORIZED), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(), 100), HttpStatus.FORBIDDEN), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), 100), + HttpStatus.OK) + ); + } + + @ParameterizedTest + @MethodSource("gpuUsageArgs") + void shouldListGpuUsageForAccount(Object tokenSupplier, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + var gpuSpec1 = GpuSpecUdt.builder() + .backend("BYOC-OCI-1").gpu("A100_80GB").instanceType("BM.GPU.A100-v2.8") + .build(); + var gpuSpec2 = GpuSpecUdt.builder() + .backend("GFN").gpu("T10").instanceType("g6.full") + .build(); + var gpuSpec3 = GpuSpecUdt.builder() + .backend("nvcf-dgxc-k8s-aws-use1-dev1").gpu("H100").instanceType("AWS.GPU.H100_4x") + .build(); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, TaskStatus.QUEUED, gpuSpec1); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_2, TaskStatus.LAUNCHED, gpuSpec2); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_3, TEST_ICMS_REQ_ID_3, TaskStatus.RUNNING, gpuSpec3); + + // Invoke endpoint to get GPU usage in TEST_NCA_ID account + var requestEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/usage/gpus")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListGpuUsageResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var dtos = responseBody.gpus(); + assertThat(dtos).hasSize(3); + dtos.forEach(dto -> { + assertThat(dto.gpu()).isIn("A100_80GB", "T10", "H100"); + assertThat(dto.instanceType()).isNotBlank(); + if (dto.gpu().equals("H100")) { + assertThat(dto.placements()).isNotEmpty(); + } + }); + } + + @Test + void gpuUsageWithNoTaskCreated() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), 100); + // Invoke endpoint to get GPU usage in TEST_NCA_ID account + var requestEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/usage/gpus")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = testRestTemplate.exchange(requestEntity, ListGpuUsageResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var dtos = responseBody.gpus(); + assertThat(dtos).isEmpty(); + } + + Stream gpuUsageFlow() { + return Stream.of( + // 1. Old flow, backend is specified + Arguments.of(GpuSpecUdt.builder() + .backend("nvcf-dgxc-k8s-forge-az24-dev6").gpu("AD102GL") + .instanceType("DGX-CLOUD.GPU.AD102GL_2x").build(), + 1, + Map.of("nvcf-dgxc-k8s-forge-az24-dev6", 1)), + // 2. New flow, cluster list is specified + Arguments.of(GpuSpecUdt.builder() + .gpu("AD102GL") + .instanceType("DGX-CLOUD.GPU.AD102GL_2x") + .clusters(Set.of( + "nvcf-dgxc-k8s-forge-az24-dev6", + "dgxc-k8saas-forge-dev2-az24")).build(), + 1, + Map.of("nvcf-dgxc-k8s-forge-az24-dev6", 1, + "dgxc-k8saas-forge-dev2-az24", 1)), + // 3. New flow, no constraints + Arguments.of(GpuSpecUdt.builder() + .gpu("AD102GL") + .instanceType("DGX-CLOUD.GPU.AD102GL_2x").build(), + 1, + Map.of("nvcf-dgxc-k8s-forge-az24-dev6", 1, + "dgxc-k8saas-forge-dev2-az24", 1, + "dgxc-k8saas-forge-az24-ct1", 1)) + ); + } + + @ParameterizedTest + @MethodSource("gpuUsageFlow") + void gpuUsage(GpuSpecUdt gpuSpec, int totalInstances, + Map cluster2Instances) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), 100); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + TaskStatus.QUEUED, gpuSpec); + + // Invoke endpoint to get GPU usage in TEST_NCA_ID account + var requestEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/usage/gpus")) + .header("Authorization", "Bearer " + token) + .build(); + + var responseEntity = testRestTemplate.exchange(requestEntity, ListGpuUsageResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var dtos = responseBody.gpus(); + assertThat(dtos).hasSize(1); + GpuUsageDto gpuUsageDto = dtos.stream().findFirst().get(); + assertThat(gpuUsageDto.instanceType()).isEqualTo("DGX-CLOUD.GPU.AD102GL_2x"); + assertThat(gpuUsageDto.currentMaxUsage()).isEqualTo(totalInstances); + assertThat(gpuUsageDto.currentMinUsage()).isEqualTo(totalInstances); + var clusterList = gpuUsageDto.placements().stream().map( + GpuPlacementDto::cluster).collect(Collectors.toSet()); + assertThat(clusterList).containsAll(cluster2Instances.keySet()); + gpuUsageDto.placements().forEach(placement -> { + assertThat(cluster2Instances).containsKey(placement.cluster()); + assertThat(placement.currentMaxUsage()) + .isEqualTo(cluster2Instances.get(placement.cluster())); + assertThat(placement.currentMinUsage()) + .isEqualTo(cluster2Instances.get(placement.cluster())); + }); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskManagementControllerTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskManagementControllerTest.java new file mode 100644 index 000000000..4bd6b0395 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskManagementControllerTest.java @@ -0,0 +1,1877 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.removeArtifactHostName; +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_CANCEL_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAGS_COUNT; +import static com.nvidia.nvct.util.NvctConstants.MAX_TAG_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.TestConstants.BASE_ARTIFACT_URL; +import static com.nvidia.nvct.util.TestConstants.GFN; +import static com.nvidia.nvct.util.TestConstants.L40G; +import static com.nvidia.nvct.util.TestConstants.L40G_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_3; +import static com.nvidia.nvct.util.TestConstants.MODEL_ARTIFACTS_URL_4; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_2; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_3; +import static com.nvidia.nvct.util.TestConstants.RESOURCE_ARTIFACTS_URL_4; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_GFN_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_MODEL_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_3; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_ORG_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_TEAM_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_ORG_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.ArtifactDto; +import com.nvidia.nvct.rest.task.dto.ContainerEnvironmentEntryDto; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.TaskDto; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.apikeys.ApiKeysService; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.util.MockApiKeysServer; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountTaskManagementControllerTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private AccountService accountService; + + @Autowired + private ApiKeysService apiKeysService; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TaskMapperService taskMapperService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockApiKeysServer.start(apiKeysBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + tasksRepository.deleteAll(); + } + + @AfterAll + void cleanup() { + MockApiKeysServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + // use of MockApiKeysServer with different scopes causes the api-keys cache to dirty + apiKeysService.invalidateCache(); + accountService.invalidateCache(); + tasksRepository.deleteAll(); + testTaskService.clearAll(); + } + + + Stream createTaskArgs() { + var specWithMissingInstanceType = GpuSpecificationDto.builder() + .gpu(L40G).backend(GFN).build(); + var specWithInvalidInstanceType = GpuSpecificationDto.builder() + .gpu(L40G).backend(GFN).instanceType("invalid-instance-type").build(); + var specWithMissingBackend = GpuSpecificationDto.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithMissingGpu = GpuSpecificationDto.builder() + .backend(GFN).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithInvalidGpu = GpuSpecificationDto.builder() + .gpu("invalid-gpu").backend(GFN).instanceType(L40G_INSTANCE_TYPE).build(); + var specWithNonEmptyConfiguration = + GpuSpecificationDto.builder().gpu(L40G).instanceType(L40G_INSTANCE_TYPE) + .clusters(Set.of("cluster01", "cluster02")) + .configuration(jsonMapper.createObjectNode().put("foo", "bar")) + .build(); + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("shhh!shhh!")) + .build(), + SecretDto.builder() + .name("secret2") + .value(secretJsonNodeValue) + .build() + ); + var containerEnvKeyWithHyphen = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY-1").value("VALUE_1").build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2").build()); + var containerEnvKeyWithSpace = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY 1").value("VALUE_1").build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2").build()); + var containerEnvKeyWithDollar = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY$1").value("VALUE_1").build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2").build()); + + return Stream.of( + // Admin creating task in TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Admin creating task in TEST_NCA_ID_2 account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_2, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Another admin creating task in TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("another-admin", + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Admin creating task in an unknown account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + "unknown-account", + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // URIs containing team name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL, + RESOURCE_ARTIFACTS_URL_2, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // non-fully qualified model/resource URLS + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + removeArtifactHostName(MODEL_ARTIFACTS_URL_3), + removeArtifactHostName(RESOURCE_ARTIFACTS_URL_3), + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Model URI containing version name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_4, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource URI containing version name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_4, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Optional model. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + null, + RESOURCE_ARTIFACTS_URL_2, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Optional resource. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + null, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Model URI does not end with "/files". + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + "/v2/org/ajwc672qsbdd/models/svc/bis-test:v1", + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Resource URI does not end with "/files". + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + "/v2/org/whw3rcpsilnj/resources/svc/bis-test:v1", + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null GpuSpecificationDto. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + null, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing instanceType in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingInstanceType, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid instanceType in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithInvalidInstanceType, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing gpu in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingGpu, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid gpu in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithInvalidGpu, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Missing backend in GpuSpec. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + specWithMissingBackend, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, List.of(), 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.UNAUTHORIZED), + // Too many tags (limit=64). + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + IntStream.range(0, MAX_TAGS_COUNT + 1).mapToObj(i -> "tag" + i) + .collect(Collectors.toSet()), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Too long tags (limit=128). + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of(StringUtils.repeat("tag1", MAX_TAG_LENGTH), + StringUtils.repeat("tag2", MAX_TAG_LENGTH)), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid tag character. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("\n&abc123["), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null maxRuntimeDuration with non-GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + null, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null maxRuntimeDuration with GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + null, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Greater than 8hours of maxRuntimeDuration with GFN backend. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(9), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero maxRuntimeDuration + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ZERO, + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero maxQueuedDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(6), + Duration.ZERO, + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Zero terminationGracePeriodDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(6), + Duration.ofHours(3), + Duration.ZERO, + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null maxQueuedDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(2), + null, + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null terminationGracePeriodDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + null, + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // terminationGracePeriodDuration greater than maxRuntimeDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(4), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Default terminationGracePeriodDuration greater than maxRuntimeDuration. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_GFN_GPU_SPEC_DTO, + Duration.ofMinutes(30), // 30minutes + Duration.ofHours(3), + null, // Default terminationGracePeriodDuration 1h + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Null secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + null, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // No NGC_API_KEY in secrets with resultHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + Set.of(SecretDto.builder() + .name("secret-key") + .value(new StringNode("shhh!shhh!")) + .build()), + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with just org name and resultsHandlingStrategy UPLOAD + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with just slash after org name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with trailing slash after team name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with trailing slash after model name. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/modelC/", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid resultsLocation with more than org, team, and model names in the path. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + "orgA/teamB/modelC/extraD", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // Invalid/unknown org name in resultsLocation. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_UNKNOWN_ORG_NAME + "/" + TEST_MODEL_NAME, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Invalid/unknown team name in resultsLocation. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_VALID_ORG_NAME + "/" + TEST_UNKNOWN_TEAM_NAME + "/" + TEST_MODEL_NAME, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.NOT_FOUND), + // Invalid resultsLocation with just org name and resultsHandlingStrategy NONE. + // resultsLocation is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + secrets, + "orgA", + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Blank resultsLocation with resultsHandlingStrategy NONE. + // resultsLocation is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + secrets, + null, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // No NGC_API_KEY in secrets with resultHandlingStrategy NONE. Presence of + // NGC_API_KEY in the secrets is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + Set.of(SecretDto.builder() + .name("secret-key") + .value(new StringNode("shhh!shhh!")) + .build()), + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Null secrets with resultHandlingStrategy NONE. Presence of NGC_API_KEY in + // the secrets is not validated if resultHandlingStrategy is NONE. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.NONE, + null, + TEST_RESULTS_LOCATION_1, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + // Environment key with hyphen. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithHyphen, + HttpStatus.BAD_REQUEST), + // Environment key with space. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithSpace, + HttpStatus.BAD_REQUEST), + // Environment key with dollar. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + TEST_TAGS, + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + containerEnvKeyWithDollar, + HttpStatus.BAD_REQUEST), + // bad task tags + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("\n&abc123["), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST), + // tags with special namespace:key=value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("namespace:key=value"), + TEST_OCI_GPU_SPEC_DTO, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.OK), + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + MODEL_ARTIFACTS_URL_3, + RESOURCE_ARTIFACTS_URL_3, + Set.of("namespace:key=value"), + specWithNonEmptyConfiguration, + Duration.ofHours(2), + Duration.ofHours(3), + Duration.ofHours(1), + ResultHandlingStrategyEnum.UPLOAD, + secrets, + TEST_RESULTS_LOCATION_2, + TEST_CONTAINER_ENVIRONMENT, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("createTaskArgs") + void shouldCreateTask(Object tokenSupplier, + String ncaId, + String modelUri, + String resourceUri, + Set tags, + GpuSpecificationDto gpuSpecificationDto, + Duration maxRuntimeDuration, + Duration maxQueuedDuration, + Duration terminationGracePeriodDuration, + ResultHandlingStrategyEnum resultHandlingStrategy, + Set secrets, + String resultsLocation, + List containerEnvironment, + HttpStatus expectedStatus) { + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(tags) + .maxRuntimeDuration(maxRuntimeDuration) + .maxQueuedDuration(maxQueuedDuration) + .terminationGracePeriodDuration(terminationGracePeriodDuration) + .gpuSpecification(gpuSpecificationDto) + .description(TEST_DESCRIPTION) + .resultsLocation(resultsLocation) + .resultHandlingStrategy(resultHandlingStrategy) + .secrets(secrets) + .containerEnvironment(containerEnvironment); + + if (StringUtils.isNotBlank(modelUri)) { + requestBodyBuilder.models(Set.of( + ArtifactDto.builder().name("model-1") + .version("1.0") + .uri(URI.create(modelUri)) + .build())); + } + + if (StringUtils.isNotBlank(resourceUri)) { + requestBodyBuilder.resources(Set.of( + ArtifactDto.builder().name("resource-1") + .version("1.0") + .uri(URI.create(resourceUri)) + .build())); + } + var token = TestUtil.getToken(tokenSupplier); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(ncaId); + assertThat(responseBody.task().containerArgs()).isEqualTo(TEST_CONTAINER_ARGS); + assertThat(responseBody.task().containerImage()).isEqualTo(TEST_CONTAINER_IMAGE); + assertThat(responseBody.task().containerEnvironment()).isEqualTo(containerEnvironment); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().gpuSpecification()).isEqualTo(gpuSpecificationDto); + + if (StringUtils.isNotBlank(resultsLocation)) { + assertThat(responseBody.task().resultsLocation()).isEqualTo(resultsLocation); + } else { + assertThat(responseBody.task().resultsLocation()).isBlank(); + } + + if (resultHandlingStrategy != null) { + assertThat(responseBody.task().resultHandlingStrategy()) + .isEqualTo(resultHandlingStrategy); + } else { + assertThat(responseBody.task().resultHandlingStrategy()).isNull(); + } + + if (maxRuntimeDuration != null) { + assertThat(responseBody.task().maxRuntimeDuration()).isNotNull(); + assertThat(responseBody.task().maxRuntimeDuration().toString()) + .hasToString(maxRuntimeDuration.toString()); + } else { + assertThat(responseBody.task().maxRuntimeDuration()).isNull(); + } + assertThat(responseBody.task().maxQueuedDuration()).isNotNull(); + if (maxQueuedDuration != null) { + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString(maxQueuedDuration.toString()); + } else { + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString("PT72H"); // Default value. + } + assertThat(responseBody.task().terminationGracePeriodDuration()).isNotNull(); + if (terminationGracePeriodDuration != null) { + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString(terminationGracePeriodDuration.toString()); + } else { + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString("PT1H"); // Default value + } + + if (StringUtils.isNotBlank(modelUri)) { + assertThat(responseBody.task().models()).isNotNull().hasSize(1); + var model = responseBody.task().models().stream().findFirst().get(); + if (modelUri.startsWith(BASE_ARTIFACT_URL)) { + assertThat(model.getUri()).isEqualTo(URI.create(modelUri)); + } else { + // Make sure it already be transformed to fully-qualified url. + assertThat(model.getUri()).isEqualTo(URI.create("https://localhost-ngc" + modelUri)); + } + } else { + assertThat(responseBody.task().models()).isNull(); + } + if (StringUtils.isNotBlank(resourceUri)) { + assertThat(responseBody.task().resources()).isNotNull().hasSize(1); + var resource = responseBody.task().resources().stream().findFirst().get(); + if (resourceUri.startsWith(BASE_ARTIFACT_URL)) { + assertThat(resource.getUri()).isEqualTo(URI.create(resourceUri)); + } else { + // Make sure it already be transformed to fully-qualified url. + assertThat(resource.getUri()).isEqualTo( + URI.create("https://localhost-ngc" + resourceUri)); + } + } else { + assertThat(responseBody.task().resources()).isNull(); + } + + assertThat(responseBody.task().tags()).isEqualTo(tags); + assertThat(responseBody.task().description()).isEqualTo(TEST_DESCRIPTION); + + var taskId = responseBody.task().id(); + var entity = tasksRepository + .getByTaskId(taskId) + .orElseThrow(() -> new NotFoundException("Task not found")); + assertThat(entity).isNotNull(); + assertThat(entity.getHealth()).isNull(); + } + + Stream listTasksArgs() { + return Stream.of( + // List tasks in TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.OK), + // List non-existent tasks in TEST_NCA_ID_2 account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_2, + List.of(), + HttpStatus.OK), + // List non-existent tasks in non-existent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + "unknown-account", + List.of(), + HttpStatus.NOT_FOUND), + // Some admin list tasks in TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("some-admin", + List.of(ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.OK), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, List.of(), 100), + TEST_NCA_ID, + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.FORBIDDEN), + // Missing token. + Arguments.of(null, + TEST_NCA_ID, + List.of(TEST_TASK_ID_1, TEST_TASK_ID_2), + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("listTasksArgs") + void shouldListTasks(String token, + String ncaId, + List expectedTasks, + HttpStatus expectedStatus) { + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks")) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.cursor()).isNull(); + assertThat(responseBody.limit()).isNull(); + assertThat(expectedTasks).isNotNull(); + assertThat(responseBody.tasks()).hasSize(expectedTasks.size()); + var tasks = responseBody.tasks().stream() + .collect(Collectors.toMap(TaskDto::id, Function.identity())); + assertThat(tasks.keySet()).isEqualTo(new HashSet<>(expectedTasks)); + verifyReturnedTasksIntegrity(expectedTasks, QUEUED, tasks); + } + + void verifyReturnedTasksIntegrity(List expectedTasks, TaskStatus expectedStatus, Map tasks) { + for (UUID expectedTaskId : expectedTasks) { + var taskDto = tasks.get(expectedTaskId); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.fromText(expectedStatus.toString())); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + if (taskDto.id().equals(TEST_TASK_ID_1)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + } else if (taskDto.id().equals(TEST_TASK_ID_2)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_2); + } else if (taskDto.id().equals(TEST_TASK_ID_3)) { + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_3); + } else { + fail("unknown function returned"); + } + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()) + .isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + } + + + Stream taskArgs() { + return Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.OK), + // Unknown task using TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + UUID.randomUUID(), + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1(known task) with unknown account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + "unknown-account", + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the TEST_NCA_ID_2 account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID_2, + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Some other super admin retrieving task details. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("some-other-super-admin", + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.OK), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, List.of(), 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("taskArgs") + void shouldGetTaskDetails(Object tokenSupplier, String ncaId, UUID taskId, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.get(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks/" + taskId)) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task()).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()).isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + assertThat(taskDto.resultsLocation()).isNotEmpty(); + assertThat(taskDto.resultHandlingStrategy()).isEqualTo(ResultHandlingStrategyEnum.UPLOAD); + } + + Stream cancelTaskArgs() { + return Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.OK), + // Unknown task using TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + UUID.randomUUID(), + QUEUED, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1(known task) with unknown account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + "unknown-account", + TEST_TASK_ID_1, + QUEUED, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the TEST_NCA_ID_2 account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID_2, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.NOT_FOUND), + // Some other super admin retrieving task details. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("some-other-super-admin", + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.OK), + // Task status is CANCELED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + CANCELED, + HttpStatus.BAD_REQUEST), + // Task status is ERRORED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + ERRORED, + HttpStatus.BAD_REQUEST), + // Task status is COMPLETED already + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + COMPLETED, + HttpStatus.BAD_REQUEST), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, List.of(), 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + QUEUED, + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("cancelTaskArgs") + void shouldCancelTask(Object tokenSupplier, String ncaId, UUID taskId, TaskStatus status, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + task1.setStatus(status); + task2.setStatus(status); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var uri = URI.create("/v1/nvct/accounts/" + ncaId + "/tasks/" + taskId + "/cancel"); + var requestEntity = + RequestEntity.post(uri) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task()).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.createdAt()).isNotNull(); + assertThat(taskDto.status()).isEqualTo(TaskStatusEnum.CANCELED); + assertThat(taskDto.containerImage()).isNotNull(); + assertThat(taskDto.containerArgs()).isNotBlank(); + assertThat(taskDto.containerEnvironment()).hasSize(3); + assertThat(taskDto.name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(taskDto.description()).isNotEmpty(); + assertThat(taskDto.tags()).isNotEmpty(); + assertThat(taskDto.gpuSpecification()).isNotNull(); + assertThat(taskDto.models()).isEqualTo(taskMapperService.getModelDtos(TEST_MODELS).get()); + assertThat(taskDto.resources()) + .isEqualTo(taskMapperService.getResourceDtos(TEST_RESOURCES).get()); + } + + Stream deleteTaskArgs() { + return Stream.of( + // TEST_TASK_ID_1 belongs to TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.NO_CONTENT), + // Unknown task using TEST_NCA_ID account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + UUID.randomUUID(), + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1(known task) with unknown account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + "unknown-account", + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // TEST_TASK_ID_1 does not belong to the TEST_NCA_ID_2 account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID_2, + TEST_TASK_ID_1, + HttpStatus.NOT_FOUND), + // Some other super admin retrieving task details. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt("some-other-super-admin", + List.of(ADMIN_SCOPE_TASK_DETAILS, + ADMIN_SCOPE_CANCEL_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.NO_CONTENT), + // Missing scope. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, List.of(), 100), + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.FORBIDDEN), + // No token. + Arguments.of(null, + TEST_NCA_ID, + TEST_TASK_ID_1, + HttpStatus.UNAUTHORIZED) + ); + } + + @ParameterizedTest + @MethodSource("deleteTaskArgs") + void shouldDeleteTask(Object tokenSupplier, String ncaId, UUID taskId, HttpStatus expectedStatus) { + var token = TestUtil.getToken(tokenSupplier); + + // Create Tasks in TEST_NCA_ID account tied to TEST_CLIENT_ID. + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + var task2 = TestUtil.createTaskEntity(TEST_TASK_ID_2, TEST_NCA_ID, TEST_TASK_NAME_2, + jsonMapper); + + // Save tasks. + tasksRepository.save(task1); + tasksRepository.save(task2); + + var requestEntity = + RequestEntity.delete(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + var entity = tasksRepository.getByTaskId(taskId); + if (expectedStatus.isError()) { + if (responseEntity.getStatusCode() != HttpStatus.NOT_FOUND) { + assertThat(entity).isNotEmpty(); + } + return; + } + assertThat(entity).isEmpty(); + } + + @Test + void shouldFailWhenGracePeriodIsGreaterThanMaxRuntimeDuration() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, ADMIN_SCOPE_LIST_TASKS), + 100); + var modelUri = URI.create(MODEL_ARTIFACTS_URL_3); + var modelDtos = Set.of(ArtifactDto.builder().name("model-1") + .version("1.0").uri(modelUri).build()); + var resourceUri = URI.create(RESOURCE_ARTIFACTS_URL_3); + var resourceDtos = Set.of(ArtifactDto.builder().name("resource-1") + .version("1.0") + .uri(resourceUri) + .build()); + var requestBodyBuilder = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .models(modelDtos) + .resources(resourceDtos) + .tags(TEST_TAGS) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(3)) + .terminationGracePeriodDuration(Duration.ofHours(5)) + .description(TEST_DESCRIPTION); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBodyBuilder.build()); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void shouldThrowExceptionForTooLongDescription() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(3)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + // Create a description that exceeds MAX_DESCRIPTION_LENGTH (256) + .description(StringUtils.repeat("a", 257)) + .resultHandlingStrategy(ResultHandlingStrategyEnum.NONE) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithHelmValidationPolicyTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithHelmValidationPolicyTest.java new file mode 100644 index 000000000..364448160 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithHelmValidationPolicyTest.java @@ -0,0 +1,379 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_VALIDATION_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.ValidationPolicyNameEnum; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountTaskWithHelmValidationPolicyTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.reval.base-url}") + private URI revalBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockRevalServer.start(revalBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockCasServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockRevalServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + } + + Stream createTaskWithHelmValidationPolicyArgs() { + var validPolicyDefault = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var validPolicyUnrestricted = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.UNRESTRICTED) + .build(); + var validPolicyNoExtraTypes = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .build(); + var invalidPolicyNullName = HelmValidationPolicyDto.builder() + .name(null) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankGroup = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("") + .version("v1") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankVersion = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("") + .kind("Deployment") + .build())) + .build(); + var invalidPolicyBlankKind = HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("") + .build())) + .build(); + + return Stream.of( + // Valid policy with DEFAULT name and extraKubernetesTypes. + Arguments.of(validPolicyDefault, HttpStatus.OK), + // Valid policy with UNRESTRICTED name and no extraKubernetesTypes. + Arguments.of(validPolicyUnrestricted, HttpStatus.OK), + // Valid policy with DEFAULT name and no extraKubernetesTypes. + Arguments.of(validPolicyNoExtraTypes, HttpStatus.OK), + // Invalid policy with null name. + Arguments.of(invalidPolicyNullName, HttpStatus.BAD_REQUEST), + // Invalid policy with blank group in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankGroup, HttpStatus.BAD_REQUEST), + // Invalid policy with blank version in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankVersion, HttpStatus.BAD_REQUEST), + // Invalid policy with blank kind in extraKubernetesTypes. + Arguments.of(invalidPolicyBlankKind, HttpStatus.BAD_REQUEST)); + } + + @ParameterizedTest + @MethodSource("createTaskWithHelmValidationPolicyArgs") + void shouldCreateTaskWithHelmValidationPolicy( + HelmValidationPolicyDto helmValidationPolicy, + HttpStatus expectedStatus) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100); + var gpuSpec = GpuSpecificationDto.builder() + .gpu(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.gpu()) + .instanceType(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.instanceType()) + .backend(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO.backend()) + .helmValidationPolicy(helmValidationPolicy) + .build(); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .tags(TEST_TAGS) + .gpuSpecification(gpuSpec) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + + var requestEntity = RequestEntity + .post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(responseBody.task().gpuSpecification()).isNotNull(); + assertThat(responseBody.task().gpuSpecification().helmValidationPolicy()) + .isEqualTo(helmValidationPolicy); + } + + @Test + void shouldListTasksWithHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity + .post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var createResponse = + testRestTemplate.exchange(createEntity, TaskResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var listEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .header("Authorization", "Bearer " + token) + .build(); + var listResponse = + testRestTemplate.exchange(listEntity, ListTasksResponse.class); + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = listResponse.getBody(); + assertThat(responseBody).isNotNull(); + + var tasks = responseBody.tasks(); + assertThat(tasks).isNotEmpty(); + + tasks.forEach(task -> { + assertThat(task.gpuSpecification()).isNotNull(); + assertThat(task.gpuSpecification().helmValidationPolicy()) + .isEqualTo(TEST_HELM_VALIDATION_POLICY_DTO); + }); + } + + @Test + void shouldGetTaskDetailsWithHelmValidationPolicy() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_TASK_DETAILS), + 100); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .helmChart(TEST_HELM_CHART) + .gpuSpecification(TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO) + .maxRuntimeDuration(Duration.ofHours(2)) + .maxQueuedDuration(Duration.ofHours(1)) + .terminationGracePeriodDuration(Duration.ofHours(1)) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .build(); + var createEntity = RequestEntity + .post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var createResponse = + testRestTemplate.exchange(createEntity, TaskResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = createResponse.getBody().task().id(); + + var getEntity = RequestEntity + .get(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + + "/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var getResponse = + testRestTemplate.exchange(getEntity, TaskResponse.class); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = getResponse.getBody(); + assertThat(responseBody).isNotNull(); + + var task = responseBody.task(); + assertThat(task).isNotNull(); + assertThat(task.id()).isEqualTo(taskId); + assertThat(task.gpuSpecification()).isNotNull(); + assertThat(task.gpuSpecification().helmValidationPolicy()) + .isEqualTo(TEST_HELM_VALIDATION_POLICY_DTO); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithTelemetriesTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithTelemetriesTest.java new file mode 100644 index 000000000..6ab9003fa --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithTelemetriesTest.java @@ -0,0 +1,403 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_TELEMETRIES_4; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_SECRETS; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_LOGS_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_METRICS_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TELEMETRY_TRACES_ID; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TelemetriesUdt; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.rest.task.dto.TaskStatusEnum; +import com.nvidia.nvct.service.telemetry.dto.TelemetriesDto; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest(classes = {NvctTestApp.class, + IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountTaskWithTelemetriesTest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @SneakyThrows + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockCasServer.stop(); + MockIcmsServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + log.info("{} reset", this.getClass().getSimpleName()); + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + @BeforeEach + void setup() { + log.info("{} setup", this.getClass().getSimpleName()); + } + + Stream createTaskWithTelemetriesArgs() { + var validCompleteTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + var validPartialTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .build(); + var mismatchedTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_TRACES_ID) + .metricsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .tracesTelemetryId(TEST_TELEMETRY_METRICS_ID) + .build(); + var mismatchedPartialTelemetriesDto = TelemetriesDto.builder() + .logsTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + + return Stream.of( + // Valid telemetries from correct parent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_WITH_TELEMETRIES_4, + validCompleteTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + TEST_TELEMETRY_METRICS_ID, + TEST_TELEMETRY_TRACES_ID, + HttpStatus.OK), + // Valid telemetries from correct parent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_WITH_TELEMETRIES_4, + validPartialTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + null, + null, + HttpStatus.OK), + // Valid telemetries but not the correct account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID, + validCompleteTelemetriesDto, + TEST_TELEMETRY_LOGS_ID, + TEST_TELEMETRY_METRICS_ID, + TEST_TELEMETRY_TRACES_ID, + HttpStatus.BAD_REQUEST), + // Mismatched telemetries from the correct parent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_WITH_TELEMETRIES_4, + mismatchedTelemetriesDto, + null, + null, + null, + HttpStatus.BAD_REQUEST), + // Mismatched telemetries from the correct parent account. + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100), + TEST_NCA_ID_WITH_TELEMETRIES_4, + mismatchedPartialTelemetriesDto, + null, + null, + null, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithTelemetriesArgs") + void shouldCreateTaskWithTelemetries( + String token, + String ncaId, + TelemetriesDto telemetriesDto, + UUID logsTelemetryId, + UUID metricsTelemetryId, + UUID tracesTelemetryId, + HttpStatus expectedStatus) { + var maxRuntimeDuration = Duration.ofHours(2); + var maxQueuedDuration = Duration.ofHours(3); + var terminationGracePeriodDuration = Duration.ofHours(1); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .maxRuntimeDuration(maxRuntimeDuration) + .maxQueuedDuration(maxQueuedDuration) + .terminationGracePeriodDuration(terminationGracePeriodDuration) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .resultHandlingStrategy(ResultHandlingStrategyEnum.UPLOAD) + .secrets(TEST_SECRETS) + .containerEnvironment(TEST_CONTAINER_ENVIRONMENT) + .telemetries(telemetriesDto) + .build(); + + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + ncaId + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.task().id()).isNotNull(); + assertThat(responseBody.task().name()).isEqualTo(TEST_TASK_NAME_1); + assertThat(responseBody.task().status()).isEqualTo(TaskStatusEnum.QUEUED); + assertThat(responseBody.task().ncaId()).isEqualTo(ncaId); + assertThat(responseBody.task().containerArgs()).isEqualTo(TEST_CONTAINER_ARGS); + assertThat(responseBody.task().helmChart()).isNull(); + assertThat(responseBody.task().containerImage()).isEqualTo(TEST_CONTAINER_IMAGE); + assertThat(responseBody.task().containerEnvironment()).isEqualTo(TEST_CONTAINER_ENVIRONMENT); + assertThat(responseBody.task().createdAt()).isNotNull(); + assertThat(responseBody.task().gpuSpecification()).isEqualTo(TEST_OCI_GPU_SPEC_DTO); + assertThat(responseBody.task().resultsLocation()).isEqualTo(TEST_RESULTS_LOCATION_1); + assertThat(responseBody.task().resultHandlingStrategy()) + .isEqualTo(ResultHandlingStrategyEnum.UPLOAD); + assertThat(responseBody.task().maxRuntimeDuration()).isNotNull(); + assertThat(responseBody.task().maxRuntimeDuration().toString()) + .hasToString(maxRuntimeDuration.toString()); + assertThat(responseBody.task().maxQueuedDuration().toString()) + .hasToString(maxQueuedDuration.toString()); + assertThat(responseBody.task().terminationGracePeriodDuration()).isNotNull(); + assertThat(responseBody.task().terminationGracePeriodDuration().toString()) + .hasToString(terminationGracePeriodDuration.toString()); + assertThat(responseBody.task().tags()).isEqualTo(TEST_TAGS); + assertThat(responseBody.task().description()).isEqualTo(TEST_DESCRIPTION); + + var actualTelemetriesDto = responseBody.task().telemetries(); + assertThat(actualTelemetriesDto).isNotNull(); + assertThat(actualTelemetriesDto.logsTelemetryId()).isEqualTo(logsTelemetryId); + assertThat(actualTelemetriesDto.metricsTelemetryId()).isEqualTo(metricsTelemetryId); + assertThat(actualTelemetriesDto.tracesTelemetryId()).isEqualTo(tracesTelemetryId); + + var taskId = responseBody.task().id(); + var entity = tasksRepository + .getByTaskId(taskId) + .orElseThrow(() -> new NotFoundException("Task not found")); + assertThat(entity).isNotNull(); + assertThat(entity.getHealth()).isBlank(); + } + + @Test + void shouldListTasksWithTelemetries() { + // Create Task in TEST_NCA_ID_WITH_TELEMETRIES_4 account with telemetries. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + + // Create token for the admin. + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LIST_TASKS), + 100); + var uri = URI.create("/v1/nvct/accounts/" + TEST_NCA_ID_WITH_TELEMETRIES_4 + "/tasks"); + var requestEntity = RequestEntity.get(uri) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, ListTasksResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var tasks = responseBody.tasks(); + assertThat(tasks).isNotEmpty(); + + tasks.forEach(task -> { + assertThat(task.telemetries()).isNotNull(); + assertThat(task.telemetries().logsTelemetryId()).isEqualTo(TEST_TELEMETRY_LOGS_ID); + assertThat(task.telemetries().metricsTelemetryId()).isEqualTo(TEST_TELEMETRY_METRICS_ID); + assertThat(task.telemetries().tracesTelemetryId()).isEqualTo(TEST_TELEMETRY_TRACES_ID); + }); + } + + @Test + void shouldGetTaskDetailsWithTelemetries() { + // Create Task in TEST_NCA_ID_WITH_TELEMETRIES_4 account with telemetries. + var telemetriesUdt = TelemetriesUdt.builder() + .logsTelemetryId(TEST_TELEMETRY_LOGS_ID) + .metricsTelemetryId(TEST_TELEMETRY_METRICS_ID) + .tracesTelemetryId(TEST_TELEMETRY_TRACES_ID) + .build(); + testTaskService.createTaskWithTelemetries(TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + telemetriesUdt); + + // Create token for the admin. + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_TASK_DETAILS), + 100); + var uri = URI.create("/v1/nvct/accounts/" + TEST_NCA_ID_WITH_TELEMETRIES_4 + + "/tasks/" + TEST_TASK_ID_1); + var requestEntity = RequestEntity.get(uri) + .header("Authorization", "Bearer " + token) + .build(); + var responseEntity = + testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var task = responseBody.task(); + assertThat(task).isNotNull(); + assertThat(task.id()).isEqualTo(TEST_TASK_ID_1); + + assertThat(task.telemetries()).isNotNull(); + assertThat(task.telemetries().logsTelemetryId()).isEqualTo(TEST_TELEMETRY_LOGS_ID); + assertThat(task.telemetries().metricsTelemetryId()).isEqualTo(TEST_TELEMETRY_METRICS_ID); + assertThat(task.telemetries().tracesTelemetryId()).isEqualTo(TEST_TELEMETRY_TRACES_ID); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithUserSecretsTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithUserSecretsTest.java new file mode 100644 index 000000000..2e1d335c0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/rest/task/XAccountTaskWithUserSecretsTest.java @@ -0,0 +1,571 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.rest.task; + +import static com.nvidia.nvct.IntegrationTestConfiguration.MOCK_OAUTH2_TOKEN_SERVER; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_DELETE_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LAUNCH_TASK; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.NvctConstants.ADMIN_SCOPE_TASK_DETAILS; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_NAME_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.MAX_SECRET_VALUE_LENGTH; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.TestConstants.TEST_ADMIN_SUBJECT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_DESCRIPTION; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OCI_GPU_SPEC_DTO; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TAGS; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.rest.task.dto.CreateTaskRequest; +import com.nvidia.nvct.rest.task.dto.ListTasksResponse; +import com.nvidia.nvct.rest.task.dto.ResultHandlingStrategyEnum; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.TaskResponse; +import com.nvidia.nvct.service.ess.EssService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class XAccountTaskWithUserSecretsTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private EssService essService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TasksRepository tasksRepository; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockEssServer.start(essBaseUrl); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + } + + @AfterAll + void cleanup() { + MockEssServer.stop(); + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockNotaryServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + MockEssServer.clearSecrets(); + } + + Stream createTaskWithSecretsArgs() { + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + return Stream.of( + // no secrets with default resultHandlingStrategy + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + null, + null, + null, + HttpStatus.BAD_REQUEST), + // no secrets with resultHandlingStrategy UPLOAD(default) + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + null, + null, + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.BAD_REQUEST), + // Only NGC_API_KEY secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY).value(new StringNode("value1")).build()), + Set.of(NGC_API_KEY), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // single secret + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY).value(new StringNode("value1")).build()), + Set.of(NGC_API_KEY), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // multiple secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name(NGC_API_KEY).value(new StringNode("value1")).build(), + SecretDto.builder().name("secret2").value(new StringNode("value2")).build(), + SecretDto.builder().name("secret3").value(secretJsonNodeValue).build()), + Set.of(NGC_API_KEY, "secret2", "secret3"), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.OK), + // Secret name -- exactly MAX_SECRET_NAME_LENGTH in length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + Set.of(StringUtils.repeat("x", MAX_SECRET_NAME_LENGTH)), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // secret names with periods, and hyphens + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("omni.s3.us-west-2.amazonaws.com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni.s3.eu-north-1.amazonaws.com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + Set.of("omni.s3.us-west-2.amazonaws.com", + "omni.s3.eu-north-1.amazonaws.com", + "omni.s3.ap-northeast-1.amazonaws.com"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // secret names with underscores + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("omni_s3_us-west-2_amazonaws_com") + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name("omni_s3_eu-north-1_amazonaws_com") + .value(new StringNode("value2")).build(), + SecretDto.builder() + .name("omni.s3.ap-northeast-1.amazonaws.com") + .value(secretJsonNodeValue).build()), + Set.of("omni_s3_us-west-2_amazonaws_com", + "omni_s3_eu-north-1_amazonaws_com", + "omni.s3.ap-northeast-1.amazonaws.com"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // resultHandlingStrategy NONE and missing NGC_API_KEY + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("secret2") + .value(new StringNode("value2")).build()), + Set.of("secret2"), + ResultHandlingStrategyEnum.NONE, + HttpStatus.OK), + // resultHandlingStrategy UPLOAD and missing NGC_API_KEY + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("secret2") + .value(new StringNode("value2")).build()), + Set.of("secret2"), + ResultHandlingStrategyEnum.UPLOAD, + HttpStatus.BAD_REQUEST), + // duplicate secrets + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value1")).build(), + SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("value2")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // empty secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("") + .value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // long secret name - exceeds MAX_SECRET_NAME_LENGTH length + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name(StringUtils.repeat("secret1", MAX_SECRET_NAME_LENGTH)) + .value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // empty secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder() + .name("secret1") + .value(new StringNode("")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // long secret value + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name("secret1") + .value(new StringNode(StringUtils.repeat("value1", + MAX_SECRET_VALUE_LENGTH))) + .build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST), + // bad secret name + Arguments.of(MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK), + 100), + Set.of(SecretDto.builder().name("*secret1*-\"").value(new StringNode("value1")).build()), + null, + ResultHandlingStrategyEnum.NONE, + HttpStatus.BAD_REQUEST) + ); + } + + @ParameterizedTest + @MethodSource("createTaskWithSecretsArgs") + void shouldCreateTaskWithSecrets( + String token, + Set secrets, + Set expectedSecretNames, + ResultHandlingStrategyEnum resultHandlingStrategy, + HttpStatus expectedStatus) { + // Create the task in TEST_NCA_ID + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .tags(TEST_TAGS) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .description(TEST_DESCRIPTION) + .resultsLocation(TEST_RESULTS_LOCATION_2) + .resultHandlingStrategy(resultHandlingStrategy) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(expectedStatus); + if (expectedStatus.isError()) { + return; + } + + var responseBody = responseEntity.getBody(); + assertThat(responseBody).isNotNull(); + var taskId = responseBody.task().id(); + var taskDto = responseBody.task(); + if (expectedSecretNames == null) { + assertThat(taskDto.secrets()).isNull(); + } else { + assertThat(taskDto.secrets()) + .containsExactlyInAnyOrderElementsOf(expectedSecretNames); + + // Confirm secrets are saved in ESS + var returnedNames = essService.getSecretNames(taskId).orElse(null); + assertThat(returnedNames).isNotNull(); + assertThat(returnedNames).containsExactlyInAnyOrderElementsOf(expectedSecretNames); + + // Confirm hasSecrets is set + var taskEntity = tasksRepository.getByTaskId(taskId).orElse(null); + assertThat(taskEntity).isNotNull(); + assertThat(taskEntity.hasSecrets()).isTrue(); + } + + } + + @Test + void shouldListTasksWithSecretsByAccount() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_LIST_TASKS), + 100); + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + + // After task creation, list task + var endpoint = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks"; + var listRequestEntity = RequestEntity.get(URI.create(endpoint)) + .header("Authorization", "Bearer " + token) + .build(); + var listResponseEntity = testRestTemplate.exchange(listRequestEntity, ListTasksResponse.class); + var responseBody = listResponseEntity.getBody(); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.tasks()).hasSize(1); + + var taskDto = responseBody.tasks().getFirst(); + assertThat(taskDto).isNotNull(); + assertThat(taskDto.secrets()).isNull(); + } + + Stream listTasksWithSecretsArgs() { + return Stream.of( + Arguments.of(Boolean.TRUE), + Arguments.of(Boolean.FALSE), + Arguments.of(Boolean.TRUE), + Arguments.of(Boolean.FALSE)); + } + + @ParameterizedTest + @MethodSource("listTasksWithSecretsArgs") + void shouldGetTaskDetailsWithSecretsByAccount(Boolean includeSecrets) { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_TASK_DETAILS), + 100); + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var expectedSecretNames = Set.of("AWS_SECRET_ACCESS_KEY", + "NGC_API_KEY", "OV.US-WEST-2.CONTENT"); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .secrets(secrets) + .build(); + var requestEntity = RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = responseEntity.getBody().task().id(); + + // After task creation, get task details + var endpoint = "/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + taskId; + if (includeSecrets != null) { + endpoint += "?includeSecrets=" + includeSecrets; + } + var listRequestEntity = RequestEntity.get(URI.create(endpoint)) + .header("Authorization", "Bearer " + token) + .build(); + var listResponseEntity = testRestTemplate.exchange(listRequestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var responseBody = listResponseEntity.getBody(); + assertThat(responseBody).isNotNull(); + + var taskDto = responseBody.task(); + assertThat(taskDto).isNotNull(); + if ((includeSecrets == null) || includeSecrets) { + assertThat(taskDto.secrets()).isNotNull(); + assertThat(taskDto.secrets()).containsExactlyInAnyOrderElementsOf(expectedSecretNames); + } else { + assertThat(taskDto.secrets()).isNull(); + } + } + + @Test + void shouldDeleteTaskWithSecrets() { + var token = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_LAUNCH_TASK, + ADMIN_SCOPE_DELETE_TASK), + 100); + + // Create a task with secrets in TEST_NCA_ID + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + var secrets = Set.of(SecretDto.builder().name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("value1")).build(), + SecretDto.builder().name("NGC_API_KEY") + .value(new StringNode("value2")).build(), + SecretDto.builder().name("OV.US-WEST-2.CONTENT") + .value(secretJsonNodeValue).build()); + var requestBody = CreateTaskRequest.builder() + .name(TEST_TASK_NAME_1) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE) + .gpuSpecification(TEST_OCI_GPU_SPEC_DTO) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .secrets(secrets) + .build(); + var requestEntity = + RequestEntity.post(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks")) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .body(requestBody); + var responseEntity = testRestTemplate.exchange(requestEntity, TaskResponse.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + var taskId = responseEntity.getBody().task().id(); + + // Make sure there are secrets first before deleting a task + var secretDtos = essService.getSecrets(taskId).orElse(null); + assertThat(secretDtos).isNotNull().hasSize(3); + + // Confirm hasSecrets is set + var taskEntity = tasksRepository.getByTaskId(taskId).orElse(null); + assertThat(taskEntity).isNotNull(); + assertThat(taskEntity.hasSecrets()).isTrue(); + + var deleteRequestEntity = + RequestEntity.delete(URI.create("/v1/nvct/accounts/" + TEST_NCA_ID + "/tasks/" + taskId)) + .header("Authorization", "Bearer " + token) + .build(); + var deleteResponseEntity = testRestTemplate.exchange(deleteRequestEntity, Void.class); + assertThat(deleteResponseEntity.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + var entity = tasksRepository + .getByTaskId(taskId); + assertThat(entity).isEmpty(); + + // Make sure secrets no longer exist after task deletion + secretDtos = essService.getSecrets(taskId).orElse(null); + assertThat(secretDtos).isNull(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/account/AccountServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/account/AccountServiceTest.java new file mode 100644 index 000000000..62c8956c8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/account/AccountServiceTest.java @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.account; + +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_TELEMETRIES_4; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.util.MockNvcfServer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.net.URL; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class AccountServiceTest { + + private static final String HTTP_CLIENT_REQUESTS_METRIC = "http.client.requests"; + private static final String NVCF_ACCOUNT_URI_PREFIX = "/v2/nvcf/accounts"; + + @Autowired + private AccountService accountService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private MeterRegistry meterRegistry; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + MockNvcfServer.stop(); + } + + @AfterEach + void afterEach() { + accountService.invalidateCache(); + testTaskService.clearAll(); + } + + @Test + void shouldGetAccount() { + var accountDto = accountService.getAccount(TEST_NCA_ID); + assertThat(accountDto).isNotNull(); + assertThat(accountDto.ncaId()).isEqualTo(TEST_NCA_ID); + assertThat(accountDto.clientIds()).hasSize(1); + assertThat(accountDto.name()).isNotBlank(); + assertThat(accountDto.telemetries()).isNull(); + assertThat(accountDto.registryCredentials()).hasSize(13); + } + + @Test + void shouldGetAccountWithTelemetries() { + var accountDto = accountService.getAccount(TEST_NCA_ID_WITH_TELEMETRIES_4); + assertThat(accountDto).isNotNull(); + assertThat(accountDto.ncaId()).isEqualTo(TEST_NCA_ID_WITH_TELEMETRIES_4); + assertThat(accountDto.clientIds()).hasSize(1); + assertThat(accountDto.name()).isNotBlank(); + assertThat(accountDto.telemetries()).isNotEmpty(); + var telemetryDtos = accountDto.telemetries(); + telemetryDtos.forEach(telemetryDto -> { + assertThat(telemetryDto.telemetryId()).isNotNull(); + assertThat(telemetryDto.name()).isNotBlank(); + assertThat(telemetryDto.protocol()).isNotNull(); + assertThat(telemetryDto.provider()).isNotNull(); + assertThat(telemetryDto.endpoint()).isNotBlank(); + assertThat(telemetryDto.types()).isNotEmpty(); + assertThat(telemetryDto.createdAt()).isNotNull(); + }); + } + + @Test + void shouldGetAccountWithoutRegistryCredentials() { + var accountDto = accountService.getAccount(TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6); + assertThat(accountDto).isNotNull(); + assertThat(accountDto.ncaId()).isEqualTo(TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6); + assertThat(accountDto.clientIds()).hasSize(1); + assertThat(accountDto.name()).isNotBlank(); + assertThat(accountDto.registryCredentials()).isNull(); + } + + @Test + void shouldFailToGetAccount() { + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> accountService.getAccount("non-existent-account")); + } + + @Test + void shouldFailToLookupAccount() { + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> accountService.lookupAccountUsingNcaIdOrThrow("non-existent-account")); + } + + @Test + void shouldReturnCachedAccountOnSecondCallWithinTtl() { + MockNvcfServer.getMockNvcfServer().resetRequests(); + + accountService.getAccount(TEST_NCA_ID); + accountService.getAccount(TEST_NCA_ID); + + MockNvcfServer.getMockNvcfServer().verify(1, + getRequestedFor(urlMatching("/v2/nvcf/accounts/" + TEST_NCA_ID))); + } + + @Test + void shouldRefetchAccountAfterTtlExpires() throws InterruptedException { + MockNvcfServer.getMockNvcfServer().resetRequests(); + + accountService.getAccount(TEST_NCA_ID); + Thread.sleep(3000); + accountService.getAccount(TEST_NCA_ID); + + MockNvcfServer.getMockNvcfServer().verify(2, + getRequestedFor(urlMatching("/v2/nvcf/accounts/" + TEST_NCA_ID))); + } + + @Test + void shouldRecordMetricsForNvcfResourceServerCall() { + accountService.invalidateCache(); + MockNvcfServer.getMockNvcfServer().resetRequests(); + + var resourceServerRequestCountBefore = nvcfAccountRequestCount(); + + accountService.getAccount(TEST_NCA_ID); + + assertThat(nvcfAccountRequestCount()).isGreaterThan(resourceServerRequestCountBefore); + MockNvcfServer.getMockNvcfServer().verify(1, + getRequestedFor(urlMatching("/v2/nvcf/accounts/" + TEST_NCA_ID))); + } + + private long nvcfAccountRequestCount() { + return meterRegistry.find(HTTP_CLIENT_REQUESTS_METRIC) + .timers() + .stream() + .filter(this::isNvcfAccountRequestTimer) + .mapToLong(Timer::count) + .sum(); + } + + private boolean isNvcfAccountRequestTimer(Timer timer) { + return isNvcfAccountTagValue(timer, "http.route") + || isNvcfAccountTagValue(timer, "uri"); + } + + private boolean isNvcfAccountTagValue(Timer timer, String tagKey) { + var tagValue = timer.getId().getTag(tagKey); + return tagValue != null && tagValue.contains(NVCF_ACCOUNT_URI_PREFIX); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResultTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResultTest.java new file mode 100644 index 000000000..192e4ed3c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeyValidationResultTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ApiKeyValidationResultTest { + + static Stream resourceMatchesTaskArgs() { + var TID1 = UUID.fromString("3c000ce0-87a7-4a15-bf32-c77f088f2975"); + var TID2 = UUID.fromString("b58d839c-3cd6-4ea9-ae73-87de33dc0787"); + return Stream.of( + Arguments.of(TID1.toString(), TID1, true), + Arguments.of(TID1.toString(), TID2, false), + Arguments.of(TID2.toString(), TID2, true), + Arguments.of(TID2.toString(), TID1, false), + Arguments.of("*", TID1, false), // invalid pattern + Arguments.of(null, TID1, false), // invalid pattern + Arguments.of("invalid-uuid", TID1, false) // invalid pattern + ); + } + + @ParameterizedTest + @MethodSource("resourceMatchesTaskArgs") + void allAllowedTasks(String resourceId, UUID taskId, boolean expected) { + var access = ApiKeyValidationResult.allAllowedTasks( + List.of(new Resource("task", resourceId))); + var actual = access.hasResourcesScopedForTask(taskId); + assertEquals(expected, actual); + } + + @Test + void allAllowedTasksAccount() { + var access = ApiKeyValidationResult.allAllowedTasks( + List.of(new Resource("account-tasks", "*"))); + assertTrue(access.privateTasksAllowed()); + } + + @Test + void allAllowedTasksAccountWrongID() { + var access = ApiKeyValidationResult.allAllowedTasks( + List.of(new Resource("account-tasks", UUID.randomUUID().toString()))); + assertFalse(access.privateTasksAllowed()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeysClientTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeysClientTest.java new file mode 100644 index 000000000..bd438189c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/apikeys/ApiKeysClientTest.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.apikeys; + +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.nvct.util.MockApiKeysServer.EVALUATION_URI_PATH; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration.StaticClientApiKeysProperties; +import com.nvidia.nvct.util.MockApiKeysServer; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.reactive.function.client.WebClient; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class ApiKeysClientTest { + + @Value("${nvct.api-keys.base-url}") + private String apiKeysBaseUrl; + + @BeforeEach + void setUp() { + MockApiKeysServer.start(apiKeysBaseUrl); + } + + @AfterEach + void reset() { + MockApiKeysServer.resetToDefault(); + } + + @AfterAll + void tearDown() { + MockApiKeysServer.stop(); + } + + @Test + void fetchApiKeyValidationResultPostsToConfiguredEvaluationUri() { + var baseUrl = MockApiKeysServer.getMockApiKeysServer().baseUrl(); + var staticProps = new StaticClientApiKeysProperties(); + staticProps.setToken("static-token"); + var client = new ApiKeysClient( + baseUrl, + EVALUATION_URI_PATH, + "apiKey", + "unused-client", + "unused-secret", + "", + "http://localhost:1/unused-token", + Optional.of(staticProps), + WebClient.builder(), + JsonMapper.builder().build()); + + client.fetchApiKeyValidationResult("my-api-key"); + + MockApiKeysServer.getMockApiKeysServer() + .verify(1, postRequestedFor(urlPathEqualTo(EVALUATION_URI_PATH))); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/client/ClientServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/client/ClientServiceTest.java new file mode 100644 index 000000000..12f75771c --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/client/ClientServiceTest.java @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class ClientServiceTest { + + @Autowired + private ClientService clientService; + + @Autowired + private TestTaskService testTaskService; + + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void afterEach() { + clientService.invalidateCache(); + testTaskService.clearAll(); + } + + @Test + void shouldGetClient() { + var dto = clientService.getClient(TEST_CLIENT_ID); + assertThat(dto).isNotNull(); + assertThat(dto.clientId()).isEqualTo(TEST_CLIENT_ID); + assertThat(dto.ncaId()).isNotBlank(); + assertThat(dto.name()).isNotBlank(); + } + + @Test + void shouldFailToGetClient() { + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> clientService.getClient("non-existent-client")); + } + + @Test + void shouldReturnCachedClientOnSecondCallWithinTtl() { + MockNvcfServer.getMockNvcfServer().resetRequests(); + + clientService.getClient(TEST_CLIENT_ID); + clientService.getClient(TEST_CLIENT_ID); + + MockNvcfServer.getMockNvcfServer().verify(1, + getRequestedFor(urlMatching("/v2/nvcf/clients/" + TEST_CLIENT_ID))); + } + + @Test + void shouldRefetchClientAfterTtlExpires() throws InterruptedException { + MockNvcfServer.getMockNvcfServer().resetRequests(); + + clientService.getClient(TEST_CLIENT_ID); + Thread.sleep(3000); + clientService.getClient(TEST_CLIENT_ID); + + MockNvcfServer.getMockNvcfServer().verify(2, + getRequestedFor(urlMatching("/v2/nvcf/clients/" + TEST_CLIENT_ID))); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ess/EssServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ess/EssServiceTest.java new file mode 100644 index 000000000..ca7f863d8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ess/EssServiceTest.java @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ess; + +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.node.StringNode; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockEssServer; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@ExtendWith(MockitoExtension.class) +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class EssServiceTest { + + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EssService essService; + + @Autowired + private TaskService taskService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockEssServer.start(essBaseUrl); + } + + @AfterAll + void cleanup() { + MockEssServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + MockEssServer.clearSecrets(); + testTaskService.clearAll(); + } + + @Test + void testSavingSecrets() { + // Create a task + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Save secrets for a task. + var secrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("shhh!")) + .build()); + var version = essService.saveSecrets(TEST_TASK_ID_1, secrets); + assertThat(version).isNotNull(); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Verify saved secrets for the task. + essService.getSecrets(TEST_TASK_ID_1) + .map(dtos -> { + assertThat(dtos).isNotNull().hasSize(1); + dtos.forEach(dto -> { + assertThat(dto.name()).isEqualTo("AWS_SECRET_ACCESS_KEY"); + assertThat(dto.value()).isEqualTo(new StringNode("shhh!")); + }); + return dtos; + }) + .orElseGet(() -> Assertions.fail("Failed to save secrets")); + } + + @Test + void testUpdatingSecrets() { + // Create a task + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Save secrets for a task. + var secrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("shhh!")) + .build()); + var version = essService.saveSecrets(TEST_TASK_ID_1, secrets); + assertThat(version).isNotNull(); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Verify saved secrets for the task. + essService.getSecrets(TEST_TASK_ID_1) + .map(dtos -> { + assertThat(dtos).isNotNull().hasSize(1); + dtos.forEach(dto -> { + assertThat(dto.name()).isEqualTo("AWS_SECRET_ACCESS_KEY"); + assertThat(dto.value()).isEqualTo(new StringNode("shhh!")); + }); + return dtos; + }) + .orElseGet(() -> Assertions.fail("Failed to save secrets")); + + // Update secrets for the task. + var updatedSecrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("confidential!")) + .build(), + SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("shhh!shhh!")) + .build()); + var newVersion = essService.saveSecrets(TEST_TASK_ID_1, updatedSecrets); + assertThat(newVersion).isNotNull(); + + // Verify updated secrets for the task. + var expectedSecretValues = Set.of(new StringNode("confidential!"), + new StringNode("shhh!shhh!")); + essService.getSecrets(TEST_TASK_ID_1) + .map(dtos -> { + assertThat(dtos).isNotNull().hasSize(2); + dtos.forEach(dto -> { + assertThat(dto.name()).isIn(Set.of("AWS_SECRET_ACCESS_KEY", NGC_API_KEY)); + assertThat(dto.value()).isIn(expectedSecretValues); + }); + return dtos; + }) + .orElseGet(() -> Assertions.fail("Failed to update secrets")); + } + + @Test + void testFetchingSecretNames() { + // Create a task + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Save secrets for a task. + var secrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("shhh!")) + .build(), + SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode("shhh!shhh!")) + .build()); + var version = essService.saveSecrets(TEST_TASK_ID_1, secrets); + assertThat(version).isNotNull(); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Get secret names for the task. + var result = essService.getSecretNames(TEST_TASK_ID_1) + .orElse(null); + assertThat(result).isNotNull().hasSize(2); + assertThat(result).containsExactlyInAnyOrder("AWS_SECRET_ACCESS_KEY", NGC_API_KEY); + } + + @Test + void testFetchingNonExistingSecretNames() { + // Fetch secret names without saving secrets for a task. + var result = essService.getSecretNames(TEST_TASK_ID_1); + assertThat(result).isNotNull().isEmpty(); + } + + @Test + void testFetchingNonExistingSecrets() { + // Fetch secrets without saving secrets for a task. + var secretDtos = essService.getSecrets(TEST_TASK_ID_1); + assertThat(secretDtos).isNotNull().isEmpty(); + } + + @Test + void testDeletingSecrets() { + // Create a task + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Save secrets for a task. + var secrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("shhh!")) + .build()); + var version = essService.saveSecrets(TEST_TASK_ID_1, secrets); + assertThat(version).isNotNull(); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Verify saved secrets for the task. + essService.getSecrets(TEST_TASK_ID_1) + .map(dtos -> { + assertThat(dtos).isNotNull().hasSize(1); + dtos.forEach(dto -> { + assertThat(dto.name()).isEqualTo("AWS_SECRET_ACCESS_KEY"); + assertThat(dto.value()).isEqualTo(new StringNode("shhh!")); + }); + return dtos; + }) + .orElseGet(() -> Assertions.fail("Failed to save secrets")); + + // Delete secrets saved for the task. + essService.deleteSecrets(TEST_TASK_ID_1); + + // Fetch secrets after deleting them. + var secretDtos = essService.getSecrets(TEST_TASK_ID_1); + assertThat(secretDtos).isNotNull().isEmpty(); + } + + @Test + void testDeletingSecretsPath() { + // Create a task + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Save secrets for a task. + var secrets = Set.of(SecretDto.builder() + .name("AWS_SECRET_ACCESS_KEY") + .value(new StringNode("shhh!")) + .build()); + var version = essService.saveSecrets(TEST_TASK_ID_1, secrets); + assertThat(version).isNotNull(); + + // Update task to indicate it has secrets + var taskEntity = taskService.fetchTask(TEST_TASK_ID_1); + taskEntity.setHasSecrets(true); + taskService.updateTask(taskEntity); + + // Verify saved secrets for the task. + essService.getSecrets(TEST_TASK_ID_1) + .map(dtos -> { + assertThat(dtos).isNotNull().hasSize(1); + dtos.forEach(dto -> { + assertThat(dto.name()).isEqualTo("AWS_SECRET_ACCESS_KEY"); + assertThat(dto.value()).isEqualTo(new StringNode("shhh!")); + }); + return dtos; + }) + .orElseGet(() -> Assertions.fail("Failed to save secrets")); + + // Delete secrets path. + essService.deleteSecretsPath(TEST_TASK_ID_1); + + // Fetch secrets after deleting the path. + var secretDtos = essService.getSecrets(TEST_TASK_ID_1); + assertThat(secretDtos).isNotNull().isEmpty(); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClientTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClientTest.java new file mode 100644 index 000000000..2500001cb --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsClusterGroupClientTest.java @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.COMPLETE; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.MISSING_CLUSTER_GROUP; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.MISSING_GPU; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.MISSING_GPUS; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.MISSING_INSTANCE_TYPES; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.MISSING_INSTANCE_TYPE_DEFAULT; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.WITHOUT_ERROR_BODY_500; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.WITH_ERROR_BODY_400; +import static com.nvidia.nvct.util.MockIcmsServer.IcmsRequestContext; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.HEALTHY; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class IcmsClusterGroupClientTest { + + @Autowired + private IcmsClusterGroupClient icmsClusterGroupClient; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private JsonMapper jsonMapper; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void afterEach() { + MockIcmsServer.stop(); + icmsClusterGroupClient.clearClusterGroupCache(); + testTaskService.clearAll(); + } + + Stream clusterGroupArgs() { + return Stream.of( + Arguments.of(COMPLETE), + Arguments.of(MISSING_CLUSTER_GROUP), + Arguments.of(MISSING_GPUS), + Arguments.of(MISSING_GPU), + Arguments.of(MISSING_INSTANCE_TYPES), + Arguments.of(MISSING_INSTANCE_TYPE_DEFAULT), + Arguments.of(WITH_ERROR_BODY_400), + Arguments.of(WITHOUT_ERROR_BODY_500) + ); + } + + @ParameterizedTest + @MethodSource("clusterGroupArgs") + void shouldGetClusterGroups(ClusterGroupsResponseState clusterGroupResponseState) { + var contexts = List.of(IcmsRequestContext.builder().instanceState(HEALTHY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts, clusterGroupResponseState); + switch (clusterGroupResponseState) { + case WITH_ERROR_BODY_400: + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> icmsClusterGroupClient.getClusterGroups( + TEST_NCA_ID, InstanceUsageTypeEnum.DEFAULT)) + .withMessageContaining("pretend bad deployment spec"); + break; + + case WITHOUT_ERROR_BODY_500: + assertThatExceptionOfType(UpstreamException.class) + .isThrownBy(() -> icmsClusterGroupClient.getClusterGroups( + TEST_NCA_ID, InstanceUsageTypeEnum.DEFAULT)) + .withMessageContaining("Failed to get response from 'ICMS' after retries"); + break; + + default: + var results = icmsClusterGroupClient.getClusterGroups( + TEST_NCA_ID, InstanceUsageTypeEnum.DEFAULT); + assertThat(results).isNotNull(); + } + } + + @ParameterizedTest + @MethodSource("clusterGroupArgs") + void shouldGetDefaultInstanceType(ClusterGroupsResponseState clusterGroupResponseState) { + var contexts = List.of(IcmsRequestContext.builder().instanceState(HEALTHY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts, clusterGroupResponseState); + switch (clusterGroupResponseState) { + case COMPLETE: + var ncaId = "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo"; + var clusterGroupName = "GFN"; + var gpuName = "T10"; + var result = icmsClusterGroupClient.getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, clusterGroupName, gpuName); + assertThat(result).isEqualTo("g6.full"); + break; + + case MISSING_CLUSTER_GROUP: + ncaId = "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw"; + clusterGroupName = "OCI"; + gpuName = "A100_80GB"; + result = icmsClusterGroupClient.getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, clusterGroupName, gpuName); + assertThat(result).isEqualTo("BM.GPU.A100-v2.8"); + break; + + case MISSING_GPUS: + ncaId = "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw"; + clusterGroupName = "OCI"; + gpuName = "A100_80GB"; + result = icmsClusterGroupClient.getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, clusterGroupName, gpuName); + assertThat(result).isEqualTo("BM.GPU.A100-v2.8"); + break; + + case MISSING_GPU: + ncaId = "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo"; + clusterGroupName = "GFN"; + gpuName = "A10G"; + result = icmsClusterGroupClient.getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, clusterGroupName, gpuName); + assertThat(result).isEqualTo("ga10g_1.br20_2xlarge"); + break; + + case MISSING_INSTANCE_TYPE_DEFAULT: + ncaId = "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo"; + clusterGroupName = "GFN"; + gpuName = "T10"; + assertThatExceptionOfType(UpstreamException.class) + .isThrownBy(() -> icmsClusterGroupClient + .getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, + clusterGroupName, gpuName)); + break; + + case MISSING_INSTANCE_TYPES: + ncaId = "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo"; + clusterGroupName = "GFN"; + gpuName = "T10"; + assertThatExceptionOfType(UpstreamException.class) + .isThrownBy(() -> icmsClusterGroupClient + .getDefaultInstanceType( + ncaId, InstanceUsageTypeEnum.DEFAULT, + clusterGroupName, gpuName)); + break; + + default: + break; + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsServiceTest.java new file mode 100644 index 000000000..2590601d9 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/icms/IcmsServiceTest.java @@ -0,0 +1,487 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.icms; + +import static com.nvidia.nvct.util.TestConstants.BASE64_CONTAINER_REGISTRY_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_ACR_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_ARTIFACTORY_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_DOCKER_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_ECR_PRIVATE_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_ECR_PUBLIC_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_HARBOR_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_ENCODED_VOLCENGINE_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_HELM_REGISTRY_CRED; +import static com.nvidia.nvct.util.TestConstants.BASE64_SIDECAR_REGISTRY_CRED; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_CHART; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_ORG_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_TEAM_NAME; +import static com.nvidia.nvct.util.TestUtil.buildHelmValidationPolicyDto; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import com.nvidia.nvct.service.registry.RegistryArtifactService; +import com.nvidia.nvct.service.registry.dto.K8sSecretsDto; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.TestUtil; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class IcmsServiceTest { + + @Autowired + private IcmsService icmsService; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private IcmsClient icmsClient; + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private TaskService taskService; + + @Autowired + private RegistryArtifactService artifactService; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockEssServer.start(essBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + MockNotaryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void afterEach() { + artifactService.invalidateCache(); + testTaskService.clearAll(); + } + + @Test + void shouldScheduleInstanceForContainerBasedTask() { + var task1 = TestUtil.createTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + taskService.saveTask(task1); + + // Act + var requestId = icmsService.scheduleInstance(task1); + + // Assert + assertThat(requestId).isNotNull(); + List allServeEvents = MockIcmsServer.getMockIcmsServer().getAllServeEvents(); + String formUrlEncodedBody = allServeEvents.getFirst().getRequest().getBodyAsString(); + validateTaskInstancePayload(formUrlEncodedBody, task1); + + taskService.deleteTask(task1); + } + + @Test + void shouldScheduleInstanceForHelmBasedTask() { + var task1 = + TestUtil.createHelmBasedTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + taskService.saveTask(task1); + + // Act + var requestId = icmsService.scheduleInstance(task1); + + // Assert + assertThat(requestId).isNotNull(); + List allServeEvents = MockIcmsServer.getMockIcmsServer().getAllServeEvents(); + String formUrlEncodedBody = allServeEvents.getFirst().getRequest().getBodyAsString(); + validateTaskInstancePayload(formUrlEncodedBody, task1); + + taskService.deleteTask(task1); + } + + @Test + void shouldDeleteInstances() { + icmsClient.deleteInstances(List.of("ids")); + } + + @Test + void shouldTolerateTerminateWhenIcmsWorkloadNotFound() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + MockIcmsServer.stubTerminateInstanceNotFound(); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + var result = icmsService.terminateInstanceByTaskId(TEST_NCA_ID, TEST_TASK_ID_1); + + assertThat(result).isTrue(); + + taskService.deleteTask(TEST_TASK_ID_1); + } + + @SneakyThrows + private void validateTaskInstancePayload(String formUrlEncodedBody, TaskEntity task) { + Map paramMap = Arrays.stream(formUrlEncodedBody.split("&")) + .map(s -> s.split("=", 2)) + .collect(Collectors.toMap( + key -> decode(key[0]), + value -> decode(value.length > 1 ? value[1] : ""), + (existing, incoming) -> { + if (existing instanceof String) { + List list = new ArrayList<>(); + list.add((String) existing); + list.add((String) incoming); + return list; + } else { + ((List) existing).add((String) incoming); + return existing; + } + })); + + assertThat(paramMap) + .containsEntry("TaskDetails.TaskName", task.getName()) + .containsEntry("LaunchSpecification.NcaId", TEST_NCA_ID) + .containsEntry("LaunchSpecification.TerminationGracePeriodDuration", "PT1H") + .containsEntry("LaunchSpecification.CacheArtifacts", "true") + .containsEntry("LaunchSpecification.GpuSpecificationId", TEST_TASK_ID_1.toString()) + .containsEntry("LaunchSpecification.Gpu", "T10") + .containsEntry("LaunchSpecification.Backend", "GFN") + .containsEntry("LaunchSpecification.InstanceType", "g6.full") + .containsEntry("TaskDetails.AccountName", "test-nca-id-name") + .containsEntry("InstanceCount", "1") + .containsEntry("LaunchSpecification.CacheHandle", + "da56d7b9f5b145646f32b571d19ae4bf") + .containsEntry("LaunchSpecification.CacheSize", "232476667482") + .containsEntry("TaskDetails.OwnerNcaId", TEST_NCA_ID) + .containsEntry("TaskDetails.TaskId", TEST_TASK_ID_1.toString()) + .containsEntry("LaunchSpecification.MaxRuntimeDuration", "PT7H") + .containsEntry("LaunchSpecification.MaxQueuedDuration", "PT24H") + .containsEntry("LaunchSpecification.ResultHandlingStrategy", + ResultHandlingStrategy.UPLOAD.name()) + .containsEntry("LaunchSpecification.Telemetries", "") + .containsEntry("LaunchSpecification.DeploymentId", TEST_TASK_ID_1.toString()); + + // validate helm validation policy + var policyRaw = (String) paramMap.get("LaunchSpecification.HelmValidationPolicy"); + assertThat(policyRaw).isNotBlank(); + var policyDecoded = + new String(Base64.getDecoder().decode(policyRaw), StandardCharsets.UTF_8); + var policy = jsonMapper.readValue(policyDecoded, HelmValidationPolicyDto.class); + assertThat(policy).isEqualTo(buildHelmValidationPolicyDto()); + + // Validate environment variables + String environment = (String) paramMap.get("LaunchSpecification.Environment"); + assertThat(environment).isNotNull(); + Map envVars = decodeEnvironmentVariables(environment); + + assertThat(envVars) + .containsEntry("NCA_ID", TEST_NCA_ID) + .containsEntry("ACCOUNT_NAME", "test-nca-id-name") + .containsEntry("TASK_ID", TEST_TASK_ID_1.toString()) + .containsEntry("TASK_NAME", task.getName()) + .containsEntry("TERMINATION_GRACE_PERIOD", "PT1H") + .containsEntry("TASK_SECRETS_PRESENT", "false") + .containsEntry("NVCT_FQDN", "http://localhost:0") + .containsEntry("NVCT_FQDN_GRPC", "http://localhost:9090") + .containsEntry("INIT_CONTAINER", + "stg.nvcr.io/nv-cf/nvcf-core/nvcf_worker_init:0.7.0") + .containsEntry("OTEL_CONTAINER", "docker.io/otel/opentelemetry-collector:0.74.0") + .containsEntry("UTILS_CONTAINER", + "stg.nvcr.io/nv-cf/nvcf-core/nvcf_worker_utils:2.2.1") + .containsEntry("ESS_AGENT_CONTAINER", "stg.nvcr.io/nv-cf/nvcf-core/ess-agent:0.0.4") + .containsEntry("OTEL_EXPORTER_OTLP_ENDPOINT", "https://dummy:8282") + .containsEntry("TASK_CONTAINER_ENV", + "W3sia2V5IjoiS0VZXzEiLCJ2YWx1ZSI6IlZBTFVFXzEifSx7ImtleSI6IktFWV8yIiwidmFsdWUiOiJWQUxVRV8yIn0seyJrZXkiOiJLRVlfMyIsInZhbHVlIjoiVkFMVUVfMyJ9XQ==") + .containsEntry("RESULTS_LOCATION", + TEST_VALID_ORG_NAME + "/" + TEST_VALID_TEAM_NAME + + "/test-model-name") + .containsEntry("TRACING_ACCESS_TOKEN", "dummy-worker-lightstep-access-token") + .containsEntry("BYOO_OTEL_COLLECTOR_CONTAINER", + "stg.nvcr.io/nv-cf/nvcf-core/byoo-otel-collector:1.2.3") + .containsEntry("SECRETS_ASSERTION_TOKEN", "") + .containsKey("NVCT_WORKER_TOKEN") + .containsKey("CONTAINER_REGISTRIES_CREDENTIALS") + .containsKey("HELM_REGISTRIES_CREDENTIALS") + .containsKey("SIDECAR_REGISTRY_CREDENTIAL"); + + var containerRegistriesCredentials = jsonMapper.readValue( + Base64.getDecoder().decode(envVars.get("CONTAINER_REGISTRIES_CREDENTIALS")), + K8sSecretsDto.class); + + var helmRegistriesCredentials = jsonMapper.readValue( + Base64.getDecoder().decode(envVars.get("HELM_REGISTRIES_CREDENTIALS")), + K8sSecretsDto.class); + + if (Strings.isBlank(task.getHelmChart())) { + var expectedContainerRegistriesCredentialsRaw = """ + { + "k8sSecrets": [ + { + "auths": { + "stg.nvcr.io": { + "auth": "%s" + } + } + } + ] + } + """.formatted(BASE64_CONTAINER_REGISTRY_CRED); + var expectedContainerRegistriesCredentials = + jsonMapper.readValue(expectedContainerRegistriesCredentialsRaw, + K8sSecretsDto.class); + assertThat(containerRegistriesCredentials).isEqualTo( + expectedContainerRegistriesCredentials); + assertThat(paramMap) + .containsEntry("LaunchSpecification.ContainerImage", + TEST_CONTAINER_IMAGE.toString()) + .containsEntry("LaunchSpecification.HelmChart", ""); + assertThat(envVars) + .containsEntry("TASK_CONTAINER", TEST_CONTAINER_IMAGE.toString()) + .containsEntry("TASK_CONTAINER_ARGS", TEST_CONTAINER_ARGS); + String expectedHelmRegistriesCredentialsRaw = """ + { + "k8sSecrets": [] + } + """; + var expectedHelmRegistriesCredentials = + jsonMapper.readValue(expectedHelmRegistriesCredentialsRaw, + K8sSecretsDto.class); + assertThat(helmRegistriesCredentials).isEqualTo(expectedHelmRegistriesCredentials); + } else { + var expectedContainerRegistriesCredentialsRaw = """ + { + "k8sSecrets": [ + { + "auths": { + "docker.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "779846807323.dkr.ecr.us-west-2.amazonaws.com": { + "auth": "%s" + } + } + }, + { + "auths": { + "public.ecr.aws": { + "auth": "%s" + } + } + }, + { + "auths": { + "test-volcengine-registry-cn-beijing.cr.volces.com": { + "auth": "%s" + } + } + }, + { + "auths": { + "test1-bmfvajfxgfcrhba5.azurecr.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "demo.goharbor.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "artifactorytest12345.jfrog.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "stg.nvcr.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "canary.nvcr.io": { + "auth": "%s" + } + } + }, + { + "auths": { + "localhost-ngc": { + "auth": "%s" + } + } + } + ] + } + """.formatted(BASE64_ENCODED_DOCKER_CRED, + BASE64_ENCODED_ECR_PRIVATE_CRED, + BASE64_ENCODED_ECR_PUBLIC_CRED, + BASE64_ENCODED_VOLCENGINE_CRED, + BASE64_ENCODED_ACR_CRED, + BASE64_ENCODED_HARBOR_CRED, + BASE64_ENCODED_ARTIFACTORY_CRED, + BASE64_CONTAINER_REGISTRY_CRED, + BASE64_CONTAINER_REGISTRY_CRED, + BASE64_CONTAINER_REGISTRY_CRED); + var expectedContainerRegistriesCredentials = + jsonMapper.readValue(expectedContainerRegistriesCredentialsRaw, + K8sSecretsDto.class); + assertThat(containerRegistriesCredentials).isEqualTo( + expectedContainerRegistriesCredentials); + assertThat(paramMap) + .containsEntry("LaunchSpecification.ContainerImage", "") + .containsEntry("LaunchSpecification.HelmChart", TEST_HELM_CHART.toString()); + assertThat(envVars) + .containsEntry("TASK_CONTAINER", "") + .containsEntry("TASK_CONTAINER_ARGS", ""); + var expectedHelmRegistriesCredentialsRaw = """ + { + "k8sSecrets": [ + { + "auths": { + "helm.stg.ngc.nvidia.com": { + "auth": "%s" + } + } + } + ] + } + """.formatted(BASE64_HELM_REGISTRY_CRED); + var expectedHelmRegistriesCredentials = + jsonMapper.readValue(expectedHelmRegistriesCredentialsRaw, + K8sSecretsDto.class); + assertThat(helmRegistriesCredentials).isEqualTo(expectedHelmRegistriesCredentials); + + var sidecarRegistryCredential = jsonMapper.readValue( + Base64.getDecoder().decode(envVars.get("SIDECAR_REGISTRY_CREDENTIAL")), + K8sSecretsDto.class); + var expectedSidecarRegistryCredentialRaw = """ + { + "auths": { + "stg.nvcr.io": { + "auth": "%s" + } + } + } + """.formatted(BASE64_SIDECAR_REGISTRY_CRED); + var expectedSidecarRegistryCredential = + jsonMapper.readValue(expectedSidecarRegistryCredentialRaw, + K8sSecretsDto.class); + assertThat(sidecarRegistryCredential).isEqualTo(expectedSidecarRegistryCredential); + } + } + + private static String decode(String s) { + return URLDecoder.decode(s, StandardCharsets.UTF_8); + } + + private static Map decodeEnvironmentVariables(String base64EncodedEnv) { + String decodedEnv = + new String(Base64.getDecoder().decode(base64EncodedEnv), StandardCharsets.UTF_8); + return Arrays.stream(decodedEnv.split("\n")) + .filter(line -> line.contains("=")) + .map(line -> line.split("=", 2)) + .collect(Collectors.toMap( + keyValue -> keyValue[0], + keyValue -> keyValue.length > 1 ? keyValue[1] : "")); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/instance/InstanceServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/instance/InstanceServiceTest.java new file mode 100644 index 000000000..2eec5d292 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/instance/InstanceServiceTest.java @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.instance; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.TestConstants.GFN; +import static com.nvidia.nvct.util.TestConstants.T10; +import static com.nvidia.nvct.util.TestConstants.T10_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.InstanceStateEnum; +import com.nvidia.nvct.service.icms.IcmsClient; +import com.nvidia.nvct.service.icms.IcmsStubService.Instance; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.InstanceState; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest.Placement; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class InstanceServiceTest { + + private static final String INSTANCE_ID = "instance-1"; + private static final String LOCATION = "NP-LAX-03"; + private static final Instant CREATED_AT = Instant.parse("2026-05-28T12:00:00Z"); + + @Mock + private IcmsClient icmsClient; + + private InstanceService instanceService; + + @BeforeEach + void beforeEach() { + instanceService = new InstanceService(icmsClient); + } + + @Test + void getInstancesUsesTaskIdEndpoint() { + var task = createTask(TEST_NCA_ID); + when(icmsClient.getInstancesByTaskId(TEST_NCA_ID, TEST_TASK_ID_1)) + .thenReturn(List.of(createDeploymentInstance())); + + var instances = instanceService.getInstances(task); + + assertThat(instances).isPresent(); + assertThat(instances.get()).singleElement().satisfies(instance -> { + assertThat(instance.getInstanceId()).isEqualTo(INSTANCE_ID); + assertThat(instance.getTaskId()).isEqualTo(TEST_TASK_ID_1); + assertThat(instance.getInstanceType()).isEqualTo(T10_INSTANCE_TYPE); + assertThat(instance.getInstanceState()).isEqualTo(InstanceStateEnum.RUNNING); + assertThat(instance.getIcmsRequestId()).isEqualTo(TEST_ICMS_REQ_ID_1); + assertThat(instance.getNcaId()).isEqualTo(TEST_NCA_ID); + assertThat(instance.getGpu()).isEqualTo(T10); + assertThat(instance.getBackend()).isEqualTo(GFN); + assertThat(instance.getLocation()).isEqualTo(LOCATION); + assertThat(instance.getInstanceCreatedAt()).isEqualTo(CREATED_AT); + assertThat(instance.getInstanceUpdatedAt()).isEqualTo(CREATED_AT); + }); + verify(icmsClient).getInstancesByTaskId(TEST_NCA_ID, TEST_TASK_ID_1); + } + + private static TaskEntity createTask(String ncaId) { + return TaskEntity.builder() + .taskId(TEST_TASK_ID_1) + .ncaId(ncaId) + .name(TEST_TASK_NAME_1) + .status(RUNNING) + .gpuSpec(GpuSpecUdt.builder() + .backend(GFN) + .gpu(T10) + .instanceType(T10_INSTANCE_TYPE) + .build()) + .maxQueuedDuration(Duration.ofHours(24)) + .terminalGracePeriodDuration(Duration.ofHours(1)) + .build(); + } + + private static Instance createDeploymentInstance() { + return Instance.builder() + .createTime(CREATED_AT) + .instanceId(INSTANCE_ID) + .cloudProvider(GFN) + .instanceType(T10_INSTANCE_TYPE) + .placement(Placement.builder() + .availabilityZone(LOCATION) + .build()) + .state(InstanceState.builder() + .name("running") + .build()) + .launchRequestId(TEST_ICMS_REQ_ID_1.toString()) + .build(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ngc/NgcRegistryClientTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ngc/NgcRegistryClientTest.java new file mode 100644 index 000000000..eff7a3b85 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ngc/NgcRegistryClientTest.java @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ngc; + +import static com.nvidia.nvct.util.TestConstants.TEST_NGC_API_KEY; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_ORG_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_UNKNOWN_TEAM_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_ORG_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_ORG_NAME_INVALID_KEY; +import static com.nvidia.nvct.util.TestConstants.TEST_VALID_TEAM_NAME; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class NgcRegistryClientTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private NgcRegistryClient ngcRegistryClient; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + + Stream jsonNodeArgs() { + var secretJsonNodeValue = jsonMapper.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + return Stream.of( + Arguments.of(secretJsonNodeValue), + Arguments.of(new StringNode(TEST_NGC_API_KEY))); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testValidOrg(JsonNode jsonNode) { + var resultsLocation = TEST_VALID_ORG_NAME + "/test-model-2"; + + try { + ngcRegistryClient.validate(jsonNode, resultsLocation); + } catch (Exception ex) { + Assertions.fail(ex.getMessage()); + } + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testValidOrgAndValidTeam(JsonNode jsonNode) { + var resultsLocation = TEST_VALID_ORG_NAME + "/" + TEST_VALID_TEAM_NAME + "/test-model-2"; + + try { + ngcRegistryClient.validate(jsonNode, resultsLocation); + } catch (Exception ex) { + Assertions.fail(ex.getMessage()); + } + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testNonExistentOrg(JsonNode jsonNode) { + var resultsLocation = TEST_UNKNOWN_ORG_NAME + "/test-model-2"; + + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> ngcRegistryClient.validate(jsonNode, resultsLocation)); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testNonExistentTeam(JsonNode jsonNode) { + var resultsLocation = TEST_VALID_ORG_NAME + "/" + TEST_UNKNOWN_TEAM_NAME + "/test-model-2"; + + assertThatExceptionOfType(NotFoundException.class) + .isThrownBy(() -> ngcRegistryClient.validate(jsonNode, resultsLocation)); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testInvalidApiKey(JsonNode jsonNode) { + var resultsLocation = TEST_VALID_ORG_NAME_INVALID_KEY + "/test-model-2"; + + assertThatExceptionOfType(UnauthorizedException.class) + .isThrownBy(() -> ngcRegistryClient.validate(jsonNode, resultsLocation)); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testModelNameShort(JsonNode jsonNode) { + // Model name with 15 characters (well within the limit) + // Should not require truncation + var shortModelName = "short"; + var resultsLocation = TEST_VALID_ORG_NAME + "/" + shortModelName; + + // Should not throw exception - no truncation needed + ngcRegistryClient.validate(jsonNode, resultsLocation); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testModelNameAtMaxLength(JsonNode jsonNode) { + // Model name with exactly 64 characters (at the limit) + // Should be automatically truncated to fit UUID + var maxLengthModelName = "a".repeat(64); + var resultsLocation = TEST_VALID_ORG_NAME + "/" + maxLengthModelName; + + // Should not throw exception - will be truncated to fit UUID + ngcRegistryClient.validate(jsonNode, resultsLocation); + } + + @ParameterizedTest + @MethodSource("jsonNodeArgs") + void testModelNameExceedsMaxLength(JsonNode jsonNode) { + // Model name with 65 characters (exceeds the 64 character limit) + var tooLongModelName = "a".repeat(65); + var resultsLocation = TEST_VALID_ORG_NAME + "/" + tooLongModelName; + + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(() -> ngcRegistryClient.validate(jsonNode, resultsLocation)) + .withMessageContaining("exceeds the maximum allowed length of 64 characters"); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ratelimit/RateLimiterServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ratelimit/RateLimiterServiceTest.java new file mode 100644 index 000000000..71dc40426 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/ratelimit/RateLimiterServiceTest.java @@ -0,0 +1,371 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.ratelimit; + +import static com.nvidia.nvct.util.NvctConstants.UUID_WILDCARD; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.lenient; + +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.configuration.ratelimit.AccountRateLimiterProperties; +import com.nvidia.nvct.configuration.ratelimit.AccountRateLimiterProperties.AccountRateCappingProperties; +import com.nvidia.nvct.configuration.ratelimit.TaskRateLimiterProperties; +import com.nvidia.nvct.configuration.ratelimit.TaskRateLimiterProperties.TaskRateCappingProperties; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@ExtendWith(MockitoExtension.class) +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class RateLimiterServiceTest { + + @Mock + private AccountRateLimiterProperties accountProperties; + @Mock + private TaskRateLimiterProperties taskProperties; + + private RateLimiterService service; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + } + + @AfterAll + void cleanup() { + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void beforeEach() { + service = new RateLimiterService(accountProperties, taskProperties); + } + + private static Stream accountRateLimiterUseCases() { + return Stream.of( + // Set up default rate limiting policy for accounts with cap of 10000 calls per sec. + // + // Allowed Calls: 10 TEST_TASK_ID_1/TEST_NCA_ID + 10 TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 0 + Arguments.of(createAccountOverrides("*", 10000L), + Collections.emptyMap(), + 20, + 0), + + // Set up default rate limiting policy for accounts with cap of 6 calls per second. + // + // Allowed Calls: 6 TEST_TASK_ID_1/TEST_NCA_ID + 6 TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 4 TEST_TASK_ID_1/TEST_NCA_ID + 4 TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createAccountOverrides("*", 6L), + Collections.emptyMap(), + 12, + 8), + + // Set up default rate limiting policy for accounts with cap of 6 calls per second. + // Override the default cap for TEST_NCA_ID with 4 calls per second. + // + // Allowed Calls: 4 TEST_TASK_ID_1/TEST_NCA_ID + 6 TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 6 TEST_TASK_ID_1/TEST_NCA_ID + 4 TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createAccountOverrides("*", 6L), + Map.of(TEST_NCA_ID, createAccountOverrides(TEST_NCA_ID, 4L)), + 10, + 10) + ); + } + + @ParameterizedTest + @MethodSource("accountRateLimiterUseCases") + void verifyAccountRateLimit( + AccountRateCappingProperties defaultAccountProperties, + Map accountOverrideIds, + int expectedSuccess, + int expectedDecline) { + applyMocks(defaultAccountProperties, null, accountOverrideIds, null); + + AtomicInteger successful = new AtomicInteger(); + AtomicInteger declined = new AtomicInteger(); + IntStream.range(0, 10).parallel() + .forEach(i -> { + try { + service.verifyLimits(TEST_NCA_ID); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + try { + service.verifyLimits(TEST_NCA_ID_2); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + }); + assertThat(successful.get()).isEqualTo(expectedSuccess); + assertThat(declined.get()).isEqualTo(expectedDecline); + } + + private static Stream taskRateLimiterUseCases() { + return Stream.of( + // Set up default rate limiting policy for tasks with cap of 10000 calls per second. + // + // Allowed Calls: 10 TEST_TASK_ID_1/TEST_NCA_ID + 10 TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 0 + Arguments.of(createTaskOverrides(UUID_WILDCARD, 10000L), + Collections.emptyMap(), + 20, + 0), + + // Set up default rate limiting policy for tasks with cap of 8 calls per second. + // + // Allowed Calls: 8 TEST_TASK_ID_1/TEST_NCA_ID + 8 for TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 2 TEST_TASK_ID_1/TEST_NCA_ID + 2 for TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createTaskOverrides(UUID_WILDCARD, 8L), + Collections.emptyMap(), + 16, + 4), + + // Set up default rate limiting policy for tasks with cap of 8 calls per second. + // Override the default cap for task TEST_TASK_ID_1 with 4 calls per second. + // + // Allowed Calls: 4 TEST_TASK_ID_1/TEST_NCA_ID + 8 for TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 6 TEST_TASK_ID_1/TEST_NCA_ID + 2 for TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createTaskOverrides(UUID_WILDCARD, 8L), + Map.of(TEST_TASK_ID_1, createTaskOverrides(TEST_TASK_ID_1, 4L)), + 12, + 8) + ); + } + + @ParameterizedTest + @MethodSource("taskRateLimiterUseCases") + void verifyTaskRateLimit( + TaskRateCappingProperties defaultTaskProperties, + Map taskOverrideIds, + int expectedSuccess, + int expectedDecline) { + applyMocks(null, defaultTaskProperties, null, taskOverrideIds); + + AtomicInteger successful = new AtomicInteger(); + AtomicInteger declined = new AtomicInteger(); + IntStream.range(0, 10).parallel() + .forEach(i -> { + try { + service.verifyLimits(TEST_TASK_ID_1); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + try { + service.verifyLimits(TEST_TASK_ID_2); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + }); + assertThat(successful.get()).isEqualTo(expectedSuccess); + assertThat(declined.get()).isEqualTo(expectedDecline); + } + + private static Stream accountAndTaskRateLimiterUseCases() { + return Stream.of( + // Set up default rate limiting policy for accounts with cap of 10000 calls per sec. + // Set up default rate limiting policy for tasks with cap of 10000 calls per second. + // + // Allowed Calls: 10 TEST_TASK_ID_1/TEST_NCA_ID + 10 TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 0 + Arguments.of(createAccountOverrides("*", 10000L), + createTaskOverrides(UUID_WILDCARD, 10000L), + Collections.emptyMap(), + Collections.emptyMap(), + 20, + 0), + + // Set up default rate limiting policy for accounts with cap of 10000 calls per sec. + // Set up default rate limiting policy for tasks with cap of 6 calls per second. + // + // Allowed Calls: 6 TEST_TASK_ID_1/TEST_NCA_ID + 6 for TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 4 TEST_TASK_ID_1/TEST_NCA_ID + 4 for TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createAccountOverrides("*", 10000L), + createTaskOverrides(UUID_WILDCARD, 6L), + Collections.emptyMap(), + Collections.emptyMap(), + 12, + 8), + + // Set up default rate limiting policy for accounts with cap of 5 calls per second. + // Set up default rate limiting policy for tasks with cap of 8 calls per second. + // + // Allowed Calls: 5 TEST_TASK_ID_1/TEST_NCA_ID + 5 for TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 5 TEST_TASK_ID_1/TEST_NCA_ID + 5 for TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createAccountOverrides("*", 5L), + createTaskOverrides(UUID_WILDCARD, 8L), + Collections.emptyMap(), + Collections.emptyMap(), + 10, + 10), + + // Set up default rate limiting policy for accounts with cap of 10 calls per second. + // Set up default rate limiting policy for tasks with cap of 10 calls per second. + // Override the default cap for account TEST_NCA_ID with 4 calls per second. + // Override the default cap for task TEST_TASK_ID_1 with 4 calls per second. + // + // Allowed Calls: 4 TEST_TASK_ID_1/TEST_NCA_ID + 10 for TEST_TASK_ID_2/TEST_NCA_ID_2 + // Declined Calls: 6 TEST_TASK_ID_1/TEST_NCA_ID + 0 for TEST_TASK_ID_2/TEST_NCA_ID_2 + Arguments.of(createAccountOverrides("*", 10L), + createTaskOverrides(UUID_WILDCARD, 10L), + Map.of(TEST_NCA_ID, createAccountOverrides(TEST_NCA_ID, 4L)), + Map.of(TEST_TASK_ID_1, createTaskOverrides(TEST_TASK_ID_1, 4L)), + 14, + 6) + ); + } + + @ParameterizedTest + @MethodSource("accountAndTaskRateLimiterUseCases") + void verifyAccountAndTaskRateLimit( + AccountRateCappingProperties defaultAccountProperties, + TaskRateCappingProperties defaultTaskProperties, + Map accountOverrideIds, + Map taskOverrideIds, + int expectedSuccess, + int expectedDecline) { + applyMocks(defaultAccountProperties, defaultTaskProperties, accountOverrideIds, + taskOverrideIds); + + AtomicInteger successful = new AtomicInteger(); + AtomicInteger declined = new AtomicInteger(); + IntStream.range(0, 10).parallel() + .forEach(i -> { + try { + service.verifyLimits(TEST_NCA_ID, TEST_TASK_ID_1); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + try { + service.verifyLimits(TEST_NCA_ID_2, TEST_TASK_ID_2); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + }); + assertThat(successful.get()).isEqualTo(expectedSuccess); + assertThat(declined.get()).isEqualTo(expectedDecline); + } + + @Test + void verifyAccountRateLimitWithFulfillment() { + applyMocks( + createAccountOverrides("*", 6), + createTaskOverrides(UUID_WILDCARD, 6), + Collections.emptyMap(), + Collections.emptyMap()); + + AtomicInteger successful = new AtomicInteger(); + AtomicInteger declined = new AtomicInteger(); + IntStream.range(0, 10).parallel() + .forEach(i -> { + try { + service.verifyLimits(TEST_NCA_ID); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + }); + + // Fulfil the buckets + // Thread.sleep(2000L) analog + await() + .pollDelay(Duration.ofSeconds(2)) + .untilAsserted(() -> assertThat(true).isTrue()); + + IntStream.range(0, 10).parallel() + .forEach(i -> { + try { + service.verifyLimits(TEST_NCA_ID); + successful.incrementAndGet(); + } catch (TooManyRequestsException e) { + declined.incrementAndGet(); + } + }); + assertThat(successful.get()).isEqualTo(12); + assertThat(declined.get()).isEqualTo(8); + } + + private static TaskRateCappingProperties createTaskOverrides( + UUID taskId, + long allowedInvocationsPerSecond) { + return TaskRateCappingProperties + .builder() + .taskId(taskId) + .allowedInvocationsPerSecond(allowedInvocationsPerSecond) + .build(); + } + + private static AccountRateCappingProperties createAccountOverrides( + String ncaId, + long allowedInvocationsPerSecond) { + return AccountRateCappingProperties + .builder() + .ncaId(ncaId) + .allowedInvocationsPerSecond(allowedInvocationsPerSecond) + .build(); + } + + private void applyMocks( + AccountRateCappingProperties defaultAccountProperties, + TaskRateCappingProperties defaultTaskProperties, + Map accountOverrideIds, + Map taskOverrideIds) { + lenient().when(accountProperties.getDefaultRateCappingProperties()) + .thenReturn(defaultAccountProperties); + lenient().when(taskProperties.getDefaultRateCappingProperties()) + .thenReturn(defaultTaskProperties); + lenient().when(accountProperties.getOverridesMap()).thenReturn(accountOverrideIds); + lenient().when(taskProperties.getTaskOverridesMap()).thenReturn(taskOverrideIds); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactServiceTest.java new file mode 100644 index 000000000..3db2c359d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactServiceTest.java @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_5; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_5; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import java.util.UUID; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class RegistryArtifactServiceTest { + + @Autowired + private RegistryArtifactService registryArtifactService; + + @Autowired + private TestTaskService testTaskService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.registries.recognized.container.ngc.hostname}") + private String ngcContainerRegistryHostname; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockCasServer.start(authnBaseUrl, casBaseUrl); + MockNgcContainerRegistryServer.start(ngcContainerRegistryHostname); + MockNvcfServer.start(nvcfBaseUrl); + } + + @AfterAll + void cleanup() { + MockCasServer.stop(); + MockNvcfServer.stop(); + MockNgcContainerRegistryServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void setup() { + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + testTaskService.createTaskWithModel(TEST_NCA_ID, TEST_TASK_ID_2, TEST_ICMS_REQ_ID_2, + TEST_MODELS); + testTaskService.createTaskWithModel(TEST_NCA_ID, TEST_TASK_ID_3, TEST_ICMS_REQ_ID_3, + TEST_MODELS_2); + testTaskService.createTaskWithModelAndResources(TEST_NCA_ID_2, TEST_TASK_ID_4, + TEST_ICMS_REQ_ID_4); + testTaskService.createTaskWithResource(TEST_NCA_ID_2, TEST_TASK_ID_5, + TEST_ICMS_REQ_ID_5); + } + + @AfterEach + void afterEach() { + registryArtifactService.invalidateCache(); + testTaskService.clearAll(); + } + + Stream cacheHandleArgs() { + return Stream.of( + Arguments.of( + TEST_NCA_ID, + TEST_TASK_ID_1, + "e3b0c44298fc1c149afbf4c8996fb924"), + Arguments.of( + TEST_NCA_ID, + TEST_TASK_ID_2, + "a941b29b4d8cdea172401c82cd9cdcee"), + Arguments.of( + TEST_NCA_ID, + TEST_TASK_ID_3, + "a941b29b4d8cdea172401c82cd9cdcee"), + Arguments.of( + TEST_NCA_ID_2, + TEST_TASK_ID_4, + "da56d7b9f5b145646f32b571d19ae4bf"), + Arguments.of( + TEST_NCA_ID_2, + TEST_TASK_ID_5, + "a492d24950a022cebff9f6e6c9dad0f2")); + } + + @ParameterizedTest + @MethodSource("cacheHandleArgs") + void testCacheHandle(String ncaId, UUID taskId, String cacheHandle) { + assertThat(registryArtifactService.getCacheHandle(ncaId, taskId)).isEqualTo(cacheHandle); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationServiceTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationServiceTest.java new file mode 100644 index 000000000..5d5fafb00 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/registry/RegistryArtifactValidationServiceTest.java @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.registry; + +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.CONTAINER; +import static com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum.HELM; +import static com.nvidia.nvct.util.TestConstants.TEST_GFN_GPU_SPEC; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistryService; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistryService; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.service.account.dto.RegistryCredentialDto; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RegistryArtifactValidationServiceTest { + + @Mock + private RegistryCredentialService registryCredentialService; + + @Mock + private RegistryValidationService registryValidationService; + + @Mock + private ModelRegistryService modelRegistryService; + + @Mock + private ResourceRegistryService resourceRegistryService; + + @Mock + private HelmRegistryService helmRegistryService; + + @Mock + private ContainerRegistryService containerRegistryService; + + private RegistryArtifactValidationService createService(String exceptionHandling) { + return new RegistryArtifactValidationService( + registryCredentialService, + registryValidationService, + modelRegistryService, + resourceRegistryService, + helmRegistryService, + containerRegistryService, + exceptionHandling); + } + + private static TaskEntity containerTask() { + return TaskEntity.builder() + .taskId(UUID.randomUUID()) + .ncaId("test-nca-id") + .name("test-task") + .status(TaskStatus.QUEUED) + .gpuSpec(TEST_GFN_GPU_SPEC) + .maxQueuedDuration(Duration.ofHours(1)) + .terminalGracePeriodDuration(Duration.ofHours(1)) + .containerImage("docker.io/library/nginx:latest") + .build(); + } + + private static TaskEntity helmTask() { + return TaskEntity.builder() + .taskId(UUID.randomUUID()) + .ncaId("test-nca-id") + .name("test-helm-task") + .status(TaskStatus.QUEUED) + .gpuSpec(TEST_GFN_GPU_SPEC) + .maxQueuedDuration(Duration.ofHours(1)) + .terminalGracePeriodDuration(Duration.ofHours(1)) + .helmChart("oci://registry.example.com/charts/mychart:1.0") + .build(); + } + + private static TaskEntity blankContainerTask() { + return TaskEntity.builder() + .taskId(UUID.randomUUID()) + .ncaId("test-nca-id") + .name("test-blank-task") + .status(TaskStatus.QUEUED) + .gpuSpec(TEST_GFN_GPU_SPEC) + .maxQueuedDuration(Duration.ofHours(1)) + .terminalGracePeriodDuration(Duration.ofHours(1)) + .build(); + } + + // --- validateArtifacts() top-level flow --- + + @Test + void shouldThrowWhenArtifactValidationFailsAndExceptionHandlingIsThrow() { + var service = createService("throw"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(false); + when(registryCredentialService.getModelRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getResourceRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getContainerRegistryCredentialsValue(any())) + .thenReturn(Collections.emptyList()); + doThrow(new NotFoundException("Artifact not found")) + .when(containerRegistryService).validateArtifact(any(), anyList()); + + assertThatThrownBy(() -> service.validateArtifacts(task)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Artifact not found"); + } + + @Test + void shouldLogAndContinueWhenArtifactValidationFailsAndExceptionHandlingIsLog() { + var service = createService("log"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(false); + when(registryCredentialService.getModelRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getResourceRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getContainerRegistryCredentialsValue(any())) + .thenReturn(Collections.emptyList()); + doThrow(new NotFoundException("Artifact not found")) + .when(containerRegistryService).validateArtifact(any(), anyList()); + + assertThatCode(() -> service.validateArtifacts(task)) + .doesNotThrowAnyException(); + } + + @Test + void shouldThrowBadRequestWhenCredentialExistenceFailsAndExceptionHandlingIsThrow() { + var service = createService("throw"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(true); + + assertThatThrownBy(() -> service.validateArtifacts(task)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Missing CONTAINER registry credential for hostname 'docker.io'"); + } + + @Test + void shouldLogAndContinueWhenCredentialExistenceFailsAndExceptionHandlingIsLog() { + var service = createService("log"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(true); + + assertThatCode(() -> service.validateArtifacts(task)) + .doesNotThrowAnyException(); + } + + @Test + void shouldSucceedWhenCredentialResolutionReturnsEmptyAndArtifactValidationDisabled() { + var service = createService("throw"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(false); + when(registryCredentialService.getModelRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getResourceRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getContainerRegistryCredentialsValue(any())) + .thenReturn(Collections.emptyList()); + + assertThatCode(() -> service.validateArtifacts(task)) + .doesNotThrowAnyException(); + } + + // --- validateArtifacts() top-level flow with helm task --- + + @Test + void shouldThrowWhenHelmArtifactValidationFailsAndExceptionHandlingIsThrow() { + var service = createService("throw"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(false); + when(registryCredentialService.getModelRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getResourceRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getHelmRegistryCredentialsValue(any())) + .thenReturn(Collections.emptyList()); + doThrow(new NotFoundException("Helm chart not found")) + .when(helmRegistryService).validateArtifact(any(), anyList()); + + assertThatThrownBy(() -> service.validateArtifacts(task)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Helm chart not found"); + } + + @Test + void shouldLogAndContinueWhenHelmArtifactValidationFailsAndExceptionHandlingIsLog() { + var service = createService("log"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(false); + when(registryCredentialService.getModelRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getResourceRegistryCredentials(any())) + .thenReturn(Collections.emptyMap()); + when(registryCredentialService.getHelmRegistryCredentialsValue(any())) + .thenReturn(Collections.emptyList()); + doThrow(new NotFoundException("Helm chart not found")) + .when(helmRegistryService).validateArtifact(any(), anyList()); + + assertThatCode(() -> service.validateArtifacts(task)) + .doesNotThrowAnyException(); + } + + @Test + void shouldThrowBadRequestWhenHelmCredentialExistenceFailsAndExceptionHandlingIsThrow() { + var service = createService("throw"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(true); + + assertThatThrownBy(() -> service.validateArtifacts(task)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining( + "Missing HELM registry credential for hostname 'registry.example.com'"); + } + + @Test + void shouldLogAndContinueWhenHelmCredentialExistenceFailsAndExceptionHandlingIsLog() { + var service = createService("log"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(true); + + assertThatCode(() -> service.validateArtifacts(task)) + .doesNotThrowAnyException(); + } + + // --- validateContainerRegistryCredentialsExist() --- + + @Test + void validateContainerCredentialsShouldThrowWhenValidationEnabledAndCredentialsMissing() { + var service = createService("throw"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(true); + + assertThatThrownBy(() -> service.validateContainerRegistryCredentialsExist(task)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Missing CONTAINER registry credential for hostname 'docker.io'"); + } + + @Test + void validateContainerCredentialsShouldPassWhenValidationDisabledAndCredentialsMissing() { + var service = createService("throw"); + var task = containerTask(); + + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(false); + + assertThatCode(() -> service.validateContainerRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + } + + @Test + void validateContainerCredentialsShouldPassWhenCredentialsPresent() { + var service = createService("throw"); + var task = containerTask(); + + var cred = Mockito.mock(RegistryCredentialDto.class); + when(registryCredentialService.getContainerRegistryCredentials(any())) + .thenReturn(List.of(cred)); + when(registryCredentialService.getRegistryHostname("docker.io/library/nginx:latest")) + .thenReturn("docker.io"); + when(registryValidationService.isArtifactValidationEnabled(CONTAINER, "docker.io")) + .thenReturn(true); + + assertThatCode(() -> service.validateContainerRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + } + + @Test + void validateContainerCredentialsShouldSkipWhenContainerImageIsBlank() { + var service = createService("throw"); + var task = blankContainerTask(); + + assertThatCode(() -> service.validateContainerRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + verifyNoInteractions(registryCredentialService); + } + + // --- validateHelmRegistryCredentialsExist() --- + + @Test + void validateHelmCredentialsShouldThrowWhenValidationEnabledAndCredentialsMissing() { + var service = createService("throw"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(true); + + assertThatThrownBy(() -> service.validateHelmRegistryCredentialsExist(task)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "Missing HELM registry credential for hostname 'registry.example.com'"); + } + + @Test + void validateHelmCredentialsShouldPassWhenValidationDisabledAndCredentialsMissing() { + var service = createService("throw"); + var task = helmTask(); + + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(Collections.emptyList()); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(false); + + assertThatCode(() -> service.validateHelmRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + } + + @Test + void validateHelmCredentialsShouldPassWhenCredentialsPresent() { + var service = createService("throw"); + var task = helmTask(); + + var cred = Mockito.mock(RegistryCredentialDto.class); + when(registryCredentialService.getHelmRegistryCredentials(any())) + .thenReturn(List.of(cred)); + when(registryCredentialService.getHelmRegistryHostname( + "oci://registry.example.com/charts/mychart:1.0")) + .thenReturn("registry.example.com"); + when(registryValidationService.isArtifactValidationEnabled(HELM, "registry.example.com")) + .thenReturn(true); + + assertThatCode(() -> service.validateHelmRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + } + + @Test + void validateHelmCredentialsShouldSkipWhenHelmChartIsBlank() { + var service = createService("throw"); + var task = containerTask(); + + assertThatCode(() -> service.validateHelmRegistryCredentialsExist(task)) + .doesNotThrowAnyException(); + verify(registryCredentialService, Mockito.never()) + .getHelmRegistryCredentials(any()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalClientIntegrationTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalClientIntegrationTest.java new file mode 100644 index 000000000..a52a2268b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalClientIntegrationTest.java @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.reval; + +import static com.nvidia.nvct.service.reval.RevalClient.CLIENT_REGISTRATION_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ACCOUNT_NAME; +import static com.nvidia.nvct.util.TestConstants.TEST_ARTIFACT_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_DOCKER_CONTAINER_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_NGC_HELM_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_NAME_1; +import static com.nvidia.nvct.util.TestUtil.createHelmBasedTaskEntity; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nvidia.boot.mock.oauth2.MockOAuth2TokenServerInstanced; +import com.nvidia.boot.mock.oauth2.OAuth2TokenServerConfigurationProperties; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.nvct.configuration.staticclientauth.StaticClientAuthConfiguration; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.service.account.AccountService; +import com.nvidia.nvct.service.account.dto.AccountDto; +import com.nvidia.nvct.service.registry.RegistryArtifactValidationService; +import com.nvidia.nvct.service.registry.RegistryCredentialService; +import com.nvidia.nvct.service.registry.RegistryTaskMapperService; +import com.nvidia.nvct.util.MockRevalServer; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils; +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.reactive.function.client.WebClient; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +class RevalClientIntegrationTest { + + private static final String REVAL_BASE_URL = "http://localhost:8085"; + private static final String OAUTH_BASE_URL = "http://localhost:8086"; + private static final String CLIENT_ID = "test-client-id"; + private static final String SECRET_ID = "test-secret-id"; + private static final String STATIC_TOKEN = "static-test-token"; + + private static final JsonMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private MockOAuth2TokenServerInstanced ssaMockServer; + private AccountService accountService; + private SimpleMeterRegistry meterRegistry; + private RegistryArtifactValidationService registryArtifactValidationService; + private RegistryCredentialService registryCredentialService; + private JsonMapper jsonMapper; + private ManagedHttpResources httpResources; + + @Value("${nvct.sidecars.image-pull-secret}") + private String sidecarImagePullSecret; + @Value("${nvct.sidecars.hostname}") + private String sidecarRegistryHostname; + + @SneakyThrows + @BeforeEach + void setUp() { + this.jsonMapper = JsonMapper.builder().build(); + this.httpResources = + NvctOAuth2ClientUtils.getClientHttpConnectorManaged(CLIENT_REGISTRATION_ID); + + // Start mock Reval server + MockRevalServer.start(new URI(REVAL_BASE_URL)); + + // Create real OAuth2TokenServerConfigurationProperties + OAuth2TokenServerConfigurationProperties ssaProperties = + new OAuth2TokenServerConfigurationProperties( + OAUTH_BASE_URL, // issuer + OAUTH_BASE_URL + "/.well-known/jwks.json", // keysetUrl + "ES256", // jwsAlgorithm + null, // serviceBindings + List.of(CLIENT_ID), // clientBindings + null // customBindings + ); + + // Start OAuth mock server + ssaMockServer = new MockOAuth2TokenServerInstanced(ssaProperties); + ssaMockServer.start(); + + // Mock account service + accountService = mock(AccountService.class); + AccountDto account = AccountDto.builder() + .ncaId(TEST_NCA_ID) + .name(TEST_ACCOUNT_NAME) + .registryCredentials(List.of(TEST_DOCKER_CONTAINER_REGISTRY, + TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_HELM_REGISTRY)) + .lastUpdatedAt(Instant.now()) + .maxTasksAllowed(1) + .build(); + when(accountService.getAccount(TEST_NCA_ID)).thenReturn(account); + RegistryMapperService registryMapperService = + new RegistryMapperService(TEST_CONTAINER_REGISTRY, TEST_ARTIFACT_REGISTRY, + TEST_HELM_REGISTRY); + RegistryTaskMapperService registryTaskMapperService = + new RegistryTaskMapperService(registryMapperService); + registryCredentialService = new RegistryCredentialService(accountService, + registryMapperService, + registryTaskMapperService, + jsonMapper, + sidecarImagePullSecret, + sidecarRegistryHostname); + registryArtifactValidationService = mock(RegistryArtifactValidationService.class); + this.meterRegistry = new SimpleMeterRegistry(); + } + + @AfterEach + void tearDown() { + MockRevalServer.stop(); + + if (ssaMockServer != null) { + ssaMockServer.stop(); + } + if (httpResources != null) { + httpResources.close(); + } + } + + @Test + void testAuthWithStaticToken() { + // Given + RevalClient revalClient = createRevalClientWithStaticToken(); + GpuSpecificationDto deploymentInfo = createDeploymentInfo(); + TaskEntity taskEntity = + createHelmBasedTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + // When + revalClient.validate(TEST_NCA_ID, taskEntity, deploymentInfo); + + // Then + verify(accountService, times(2)).getAccount(TEST_NCA_ID); + } + + @Test + void testAuthWithOAuth() { + // Given + RevalClient revalClient = createRevalClientWithOAuth(); + GpuSpecificationDto deploymentInfo = createDeploymentInfo(); + TaskEntity taskEntity = + createHelmBasedTaskEntity(TEST_TASK_ID_1, TEST_NCA_ID, TEST_TASK_NAME_1, + jsonMapper); + // When + revalClient.validate(TEST_NCA_ID, taskEntity, deploymentInfo); + + // Then + // Successful call with OAuth authentication + verify(accountService, times(2)).getAccount(TEST_NCA_ID); + } + + private GpuSpecificationDto createDeploymentInfo() { + ObjectNode configuration = OBJECT_MAPPER.createObjectNode() + .put("replicas", 3) + .put("serviceAccountName", "nvct"); + + return GpuSpecificationDto.builder() + .gpu("T10") + .instanceType("g6.full") + .configuration(configuration) + .build(); + } + + private RevalClient createRevalClientWithStaticToken() { + StaticClientAuthConfiguration.StaticClientRevalProperties staticProperties = + new StaticClientAuthConfiguration.StaticClientRevalProperties(); + staticProperties.setToken(STATIC_TOKEN); + + return new RevalClient( + REVAL_BASE_URL, + true, + CLIENT_ID, + SECRET_ID, + "helmreval:validate", + OAUTH_BASE_URL + "/token", + Optional.of(staticProperties), + registryArtifactValidationService, + registryCredentialService, + httpResources, + WebClient.builder(), + jsonMapper); + } + + private RevalClient createRevalClientWithOAuth() { + return new RevalClient( + REVAL_BASE_URL, + true, + CLIENT_ID, + SECRET_ID, + "helmreval:validate", + OAUTH_BASE_URL + "/token", + Optional.empty(), + registryArtifactValidationService, + registryCredentialService, + httpResources, + WebClient.builder(), + jsonMapper); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalSerializationTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalSerializationTest.java new file mode 100644 index 000000000..606c8bdaa --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/reval/RevalSerializationTest.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.reval; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.SerializationFeature; +import com.nvidia.nvct.service.reval.RevalStubService.RevalValidateResponse; +import java.util.Arrays; +import java.util.List; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +class RevalSerializationTest { + + @Test + @SneakyThrows + void testEmptyRequestSerialization() { + var jsonMapper = JsonMapper.builder().build(); + + var request = RevalStubService.RevalValidateRequest.builder().build(); + var json = jsonMapper.writeValueAsString(request); + var expectedJson = "{}"; + + assertThat(json).isEqualTo(expectedJson); + } + + @Test + @SneakyThrows + void testRequestSerialization() { + var jsonMapper = JsonMapper.builder() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + + var configuration = jsonMapper + .createObjectNode() + .put("key", "value") + .put("key2", 2); + + var request = RevalStubService.RevalValidateRequest.builder() + .configuration(configuration) + .helmChart("my_helm_chart") + .build(); + + var requestJson = jsonMapper.writeValueAsString(request); + var expectedJson = """ + {"helmChart":"my_helm_chart","configuration":{"key":"value","key2":2}}"""; + + assertThat(jsonMapper.readTree(requestJson)).isEqualTo(jsonMapper.readTree(expectedJson)); + } + + + @Test + @SneakyThrows + void testResponseDeserialization() { + var json = """ + { + "valid": true, + "validationErrors": ["Error 1","Error 2"], + "internalErrors": ["Internal Error 1"] + } + """; + + var jsonMapper = JsonMapper.builder().build(); + var validationResult = jsonMapper.readValue(json, RevalValidateResponse.class); + var expectedValidationResult = RevalValidateResponse.builder() + .valid(true) + .validationErrors(Arrays.asList("Error 1", "Error 2")) + .internalErrors(List.of("Internal Error 1")) + .build(); + + assertThat(validationResult).isEqualTo(expectedValidationResult); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutineTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutineTest.java new file mode 100644 index 000000000..32de3b125 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/CleanTerminalTasksRoutineTest.java @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.NO_CAPACITY; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.result.ResultService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockEssServer; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class CleanTerminalTasksRoutineTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private CleanTerminalTasksRoutine cleanTerminalTasksRoutine; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EventService eventService; + + @Autowired + private TaskService taskService; + + @Autowired + private ResultService resultService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.ess.base-url}") + private String essBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockEssServer.start(essBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + MockEssServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + + @Test + @SneakyThrows + void testUnder7DaysTerminalTasksCleanup() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + // set the last heartbeat to now and status to completed + taskService.updateTask(TEST_TASK_ID_1, Instant.now()); + taskService.updateTask(TEST_TASK_ID_1, COMPLETED); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + var results = resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(results).isNotNull(); + + cleanTerminalTasksRoutine.runUnchecked(); + + // Verify tasksRepository did not change + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + assertThat(updatedTask).isEqualTo(taskEntity.get()); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + + // Verify entry in the ResultsByTaskRepository did not change + var updatedResults = resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(updatedResults).isEqualTo(results); + } + + @Test + @SneakyThrows + void testQueuedTasksCleanup() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + // set the last heartbeat to more than 7 days ago and status to non-terminal such as queued + taskService.updateTask(TEST_TASK_ID_1, Instant.now().minus(Duration.ofDays(8))); + taskService.updateTask(TEST_TASK_ID_1, QUEUED); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + var results = resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(results).isNotNull(); + + cleanTerminalTasksRoutine.runUnchecked(); + + // Verify tasksRepository did not change + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + assertThat(updatedTask).isEqualTo(taskEntity.get()); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + + // Verify entry in the ResultsByTaskRepository did not change + var updatedResults = resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(updatedResults).isEqualTo(results); + } + + @Test + @SneakyThrows + void testTerminalTasksCleanup() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + // set the last heartbeat to more than 7 days ago and status to terminal such as completed + taskService.updateTask(TEST_TASK_ID_1, Instant.now().minus(Duration.ofDays(8))); + taskService.updateTask(TEST_TASK_ID_1, COMPLETED); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + var results = resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(results).isNotNull(); + + cleanTerminalTasksRoutine.runUnchecked(); + + // Verify tasksRepository does not have the task anymore + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> taskService.fetchTask(TEST_TASK_ID_1)); + + // Verify entry in the EventsByTaskRepository does not exist + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1)); + + // Verify entry in the results does not exist + assertThatExceptionOfType(NotFoundException.class).isThrownBy( + () -> resultService.fetchResults(TEST_NCA_ID, TEST_TASK_ID_1)); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutineTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutineTest.java new file mode 100644 index 000000000..a713e2d36 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorLaunchedTasksRoutineTest.java @@ -0,0 +1,222 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.LAUNCHED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.NO_CAPACITY; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockNvcfServer; +import com.nvidia.nvct.util.MockIcmsServer; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class MonitorLaunchedTasksRoutineTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private MonitorLaunchedTasksRoutine monitorLaunchedTasksRoutine; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EventService eventService; + + @Autowired + private TaskService taskService; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + monitorLaunchedTasksRoutine.setEnabled(true); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + monitorLaunchedTasksRoutine.setEnabled(false); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + @Test + @SneakyThrows + void testNonLaunchedTaskRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + // explicitly set task status to anything other than LAUNCHED + taskService.updateTask(TEST_TASK_ID_1, RUNNING); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorLaunchedTasksRoutine.runUnchecked(Instant.now()); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + + @Test + @SneakyThrows + void testNoCapacityRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder(). + instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + tasksRepository.findById(TEST_TASK_ID_1); + // make sure current task status is LAUNCHED + var taskEntity = taskService.updateTask(TEST_TASK_ID_1, LAUNCHED); + + // Fake time for testing purposes. + monitorLaunchedTasksRoutine.runUnchecked(Instant.now().plus(Duration.ofMinutes(100))); + + // Verify health and status of task is updated + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + var healthInfo = taskMapperService.deserializeHealth(updatedTask.getHealth()) + .orElseThrow(); + assertThat(healthInfo).isNotNull(); + var expectedErrorLog = "Instance terminated due to to capacity constraint"; + assertThat(healthInfo.error()).contains(expectedErrorLog); + assertThat(updatedTask.getStatus()).isEqualTo(ERRORED); + + // Verify entry in the EventsByTaskRepository is updated + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + assertThat(events.getLast().message()).contains(expectedErrorLog); + } + + @Test + @SneakyThrows + void testNoCapacityButNotTimedOutRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + // make sure current task status is LAUNCHED + taskService.updateTask(TEST_TASK_ID_1, LAUNCHED); + taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorLaunchedTasksRoutine.runUnchecked(Instant.now()); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + @Test + @SneakyThrows + void testHasCapacityRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + + // make sure current task status is LAUNCHED + taskService.updateTask(TEST_TASK_ID_1, LAUNCHED); + taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorLaunchedTasksRoutine.runUnchecked(Instant.now()); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutineTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutineTest.java new file mode 100644 index 000000000..50eef30c8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorQueuedTasksRoutineTest.java @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.EXCEEDED_MAX_QUEUED_DURATION; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.NO_CAPACITY; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import tools.jackson.databind.json.JsonMapper; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class MonitorQueuedTasksRoutineTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private MonitorQueuedTasksRoutine monitorQueuedTasksRoutine; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private EventService eventService; + + @Autowired + private TaskService taskService; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + @Test + @SneakyThrows + void testNonQueuedTaskRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + taskService.updateTask(TEST_TASK_ID_1, RUNNING); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + + @Test + @SneakyThrows + void testNoCapacityRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY) + .createTime(Instant.now().minus(Duration.ofMinutes(100)) + .toString()) + .build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify health and status of task is updated + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + var healthInfo = taskMapperService.deserializeHealth(updatedTask.getHealth()) + .orElseThrow(); + assertThat(healthInfo).isNotNull(); + + var expectedErrorLog = "Instance terminated due to to capacity constraint"; + assertThat(healthInfo.error()).contains(expectedErrorLog); + assertThat(updatedTask.getStatus()).isEqualTo(EXCEEDED_MAX_QUEUED_DURATION); + + // Verify entry in the EventsByTaskRepository is updated + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + assertThat(events.getLast().message()).contains(expectedErrorLog); + } + + @Test + @SneakyThrows + void testNoCapacityButNotTimedOutRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isPresent(); + assertThat(taskEntity.get().getStatus()).isEqualTo(QUEUED); + assertThat(taskEntity.get().getHealth()).isNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEmpty(); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isPresent(); + assertThat(updatedTaskEntity.get().getStatus()).isEqualTo(ERRORED); + assertThat(updatedTaskEntity.get().getHealth()).isNotNull(); + assertThat(taskMapperService.deserializeHealth(updatedTaskEntity.get().getHealth()) + .orElseThrow() + .error()).isNotBlank(); + + // Verify an event has been raised for the status change. + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(updatedEvents).hasSize(1); + } + + @Test + @SneakyThrows + void testHasCapacityRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + @Test + @SneakyThrows + void testExceededMaxQueuedDurationRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(MockIcmsServer.InstancesState.HEALTHY) + .createTime(Instant.now().minus(Duration.ofHours(10)) + .toString()) + .build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + + // Create a task with createdAt date 10 hours earlier + testTaskService.createTask(TEST_NCA_ID, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + Instant.now().minus(Duration.ofHours(10))); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify task status is updated + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + assertThat(updatedTask.getStatus()).isEqualTo(EXCEEDED_MAX_QUEUED_DURATION); + + // Verify entry in the EventsByTaskRepository is updated + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + var expectedErrorLog = + "Changing status from 'QUEUED' to 'EXCEEDED_MAX_QUEUED_DURATION' with error"; + assertThat(events).isNotNull(); + assertThat(events.getLast().message()).contains(expectedErrorLog); + } + + @Test + @SneakyThrows + void testNonExceededMaxQueuedDurationRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + // Create a task with createdAt date 1 min earlier + testTaskService.createTask(TEST_NCA_ID, + TEST_TASK_ID_1, + TEST_ICMS_REQ_ID_1, + Instant.now().minus(Duration.ofMinutes(1))); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorQueuedTasksRoutine.runUnchecked(); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutineTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutineTest.java new file mode 100644 index 000000000..ef55cd3b2 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/scheduler/MonitorWorkerHeartbeatRoutineTest.java @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.scheduler; + +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.CANCELED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.COMPLETED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.ERRORED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.LAUNCHED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.QUEUED; +import static com.nvidia.nvct.persistence.task.entity.TaskStatus.RUNNING; +import static com.nvidia.nvct.service.event.EventService.STATUS_CHANGE_EVENT_MESSAGE; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.NO_CAPACITY; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_1; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_2; +import static com.nvidia.nvct.util.TestConstants.EVENT_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_ICMS_REQ_ID_1; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.grpc.TestTaskService; +import com.nvidia.nvct.persistence.task.TasksRepository; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.event.TestEventService; +import com.nvidia.nvct.service.event.EventService; +import com.nvidia.nvct.service.icms.IcmsService; +import com.nvidia.nvct.service.task.TaskMapperService; +import com.nvidia.nvct.service.task.TaskService; +import com.nvidia.nvct.util.MockIcmsServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import tools.jackson.databind.json.JsonMapper; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class MonitorWorkerHeartbeatRoutineTest { + + @Autowired + private JsonMapper jsonMapper; + + @Autowired + private MonitorWorkerHeartbeatRoutine monitorWorkerHeartbeatRoutine; + + @Autowired + private TasksRepository tasksRepository; + + @Autowired + private TestTaskService testTaskService; + + @Autowired + private TestEventService testEventService; + + @Autowired + private EventService eventService; + + @Autowired + private TaskService taskService; + + @Autowired + private IcmsService icmsService; + + @Autowired + private TaskMapperService taskMapperService; + + @Value("${nvct.registries.recognized.model.ngc.hostname}") + private String casBaseUrl; + + @Value("${nvct.registries.recognized.model.ngc.oauth2.base-url}") + private String authnBaseUrl; + + @Value("${nvct.icms.base-url}") + private URL icmsBaseUrl; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockCasServer.start(authnBaseUrl, casBaseUrl); + } + + @AfterAll + void cleanup() { + MockNvcfServer.stop(); + MockIcmsServer.stop(); + MockCasServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @AfterEach + void reset() { + testTaskService.clearAll(); + } + + @Test + @SneakyThrows + void testNonRunningTaskRun() { + MockIcmsServer.start(icmsBaseUrl, jsonMapper); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Explicitly set the task status to non-running such as launched, so that background + // thread won't monitor it. + taskService.updateTask(TEST_TASK_ID_1, LAUNCHED); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorWorkerHeartbeatRoutine.runUnchecked(); + + // Verify task entity did not change + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change. + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + @Test + @SneakyThrows + void testNoHeartbeatRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Explicitly set the task status to running and set lastHeartbeatAt to more than 4m ago + // so that background thread will monitor it. + taskService.updateTask(TEST_TASK_ID_1, RUNNING, Instant.now().minus(Duration.ofMinutes(5))); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + + monitorWorkerHeartbeatRoutine.runUnchecked(); + + // Verify health and status of task is updated. + var updatedTask = taskService.fetchTask(TEST_TASK_ID_1); + var healthInfo = taskMapperService.deserializeHealth(updatedTask.getHealth()) + .orElseThrow(); + assertThat(healthInfo).isNotNull(); + var expectedErrorLog = "Instance terminated due to to capacity constraint"; + assertThat(healthInfo.error()).contains(expectedErrorLog); + assertThat(updatedTask.getStatus()).isEqualTo(ERRORED); + + // Verify entry in the EventsByTaskRepository is updated. + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isNotNull(); + assertThat(events.getLast().message()).contains(expectedErrorLog); + } + + @Test + @SneakyThrows + void testHeartbeatNotTimedOutRun() { + var contexts = List.of(MockIcmsServer.IcmsRequestContext.builder() + .instanceState(NO_CAPACITY).build()); + MockIcmsServer.start(icmsBaseUrl, jsonMapper, contexts); + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1); + + // Explicitly set the task status to running, but set lastHeartbeatAt to now + // so that background thread won't monitor it. + taskService.updateTask(TEST_TASK_ID_1, RUNNING, Instant.now()); + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isNotNull(); + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + + monitorWorkerHeartbeatRoutine.runUnchecked(); + + // Verify task entity did not change. + var updatedTaskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(updatedTaskEntity).isEqualTo(taskEntity); + + // Verify entry in the EventsByTaskRepository did not change. + var updatedEvents = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).isEqualTo(updatedEvents); + } + + Stream usingTerminalStatusFromLatestEventArgs() { + return Stream.of( + Arguments.of(COMPLETED, + STATUS_CHANGE_EVENT_MESSAGE.formatted(RUNNING, COMPLETED)), + Arguments.of(CANCELED, + STATUS_CHANGE_EVENT_MESSAGE.formatted(RUNNING, CANCELED)) + ); + } + + @ParameterizedTest + @MethodSource("usingTerminalStatusFromLatestEventArgs") + void testUsingTerminalStatusFromLatestEvent( + TaskStatus terminalStatus, + String eventMessage) { + MockIcmsServer.start(icmsBaseUrl, jsonMapper, List.of()); + + // Create a Task by faking the creation time to be 10 minutes in the past. + testTaskService.createTask(TEST_NCA_ID, TEST_TASK_ID_1, TEST_ICMS_REQ_ID_1, + Instant.now().minus(Duration.ofMinutes(10))); + + // Simulate events for TEST_TASK_ID_1. + testEventService.populateEventForTask(EVENT_ID_1, TEST_NCA_ID, TEST_TASK_ID_1, + STATUS_CHANGE_EVENT_MESSAGE.formatted(QUEUED, LAUNCHED)); + testEventService.populateEventForTask(EVENT_ID_2, TEST_NCA_ID, TEST_TASK_ID_1, + STATUS_CHANGE_EVENT_MESSAGE.formatted(LAUNCHED, RUNNING)); + + // Explicitly set the Task status to RUNNING and the heartbeat timestamp to be 6 min + // in the past. + taskService.updateTask(TEST_TASK_ID_1, RUNNING, Instant.now().minus(Duration.ofMinutes(6))); + + // Verify that the status of the Task is RUNNING. + var taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isPresent(); + assertThat(taskEntity.get().getStatus()).isEqualTo(RUNNING); + + // Simulate race condition where the Task is marked with terminal status and then + // immediately marked as RUNNING because of heartbeat. When the Task is marked with + // terminal status, an event is generated and the corresponding ICMS request is + // terminated/deleted. Let's set the heartbeat timestamp to be 5min in the past so + // that async monitoring routine picks it up. + taskService.updateTask(TEST_TASK_ID_1, terminalStatus); + testEventService.populateEventForTask(EVENT_ID_3, TEST_NCA_ID, TEST_TASK_ID_1, + eventMessage); + icmsService.terminateInstanceByTaskId(TEST_NCA_ID, TEST_TASK_ID_1); + taskService.updateTask(TEST_TASK_ID_1, RUNNING, // Back to RUNNING + Instant.now().minus(Duration.ofMinutes(5))); + + // Verify that the status of the Task is back to RUNNING. + taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isPresent(); + assertThat(taskEntity.get().getStatus()).isEqualTo(RUNNING); + + // Verify that there are 3 events now. + var events = eventService.fetchEvents(TEST_NCA_ID, TEST_TASK_ID_1); + assertThat(events).hasSize(3); + + // Run the monitoring routine that processes Tasks with status RUNNING and + // last heartbeat timestamp more than 4 minutes in the past. The routine should + // restore the status of the Task using the terminal status from the last event. + monitorWorkerHeartbeatRoutine.runUnchecked(); + + // Verify that the Task now has a terminal status. + taskEntity = tasksRepository.findById(TEST_TASK_ID_1); + assertThat(taskEntity).isPresent(); + assertThat(taskEntity.get().getStatus()).isEqualTo(terminalStatus); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/token/WorkerAssertionValidatorTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/token/WorkerAssertionValidatorTest.java new file mode 100644 index 000000000..83afe051d --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/service/token/WorkerAssertionValidatorTest.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.service.token; + +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_TASK_ID_1; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.nvct.IntegrationTestConfiguration; +import com.nvidia.nvct.NvctTestApp; +import com.nvidia.nvct.util.MockNotaryServer; +import com.nvidia.nvct.util.MockNvcfServer; +import java.net.URL; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +@SpringBootTest( + classes = {NvctTestApp.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class WorkerAssertionValidatorTest { + + @Autowired + private WorkerAssertionValidator workerAssertionValidator; + + @Autowired + private TokenService tokenService; + + @Autowired + private Clock clock; + + @Value("${nvct.notary.base-url}") + private String notaryBaseUrl; + + @Value("${spring.security.oauth2.client.registration.notary.client-id}") + private String notaryClientId; + + @Value("${nvct.nvcf.base-url}") + private URL nvcfBaseUrl; + + @BeforeAll + void beforeAll() { + log.info("{}: Started running tests", this.getClass().getSimpleName()); + MockNvcfServer.start(nvcfBaseUrl); + MockNotaryServer.start(notaryBaseUrl, notaryClientId); + } + + @AfterAll + void cleanup() { + MockNotaryServer.stop(); + MockNvcfServer.stop(); + log.info("{}: Completed running tests", this.getClass().getSimpleName()); + } + + @BeforeEach + void setClock() { + when(clock.instant()).thenReturn(Instant.now().truncatedTo(ChronoUnit.SECONDS)); + } + + @Test + void shouldAcceptValidSignedWorkerAssertion() { + var token = tokenService.issueWorkerAccessAssertion(TEST_NCA_ID, TEST_TASK_ID_1); + + assertThatCode(() -> workerAssertionValidator.validate(token, TEST_NCA_ID, TEST_TASK_ID_1)) + .doesNotThrowAnyException(); + } + + @Test + void shouldRejectUnsignedWorkerAssertion() { + var token = new PlainJWT(new JWTClaimsSet.Builder() + .issuer(notaryBaseUrl) + .subject(notaryClientId) + .issueTime(Date.from(Instant.now(clock))) + .claim("assertion", Map.of("ncaId", TEST_NCA_ID, "taskId", TEST_TASK_ID_1.toString())) + .build()).serialize(); + + assertThatThrownBy(() -> workerAssertionValidator.validate(token, TEST_NCA_ID, TEST_TASK_ID_1)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void shouldRejectSignedWorkerAssertionWithWrongIssuer() { + var token = MockNotaryServer.generateSignedWorkerAssertion( + "http://wrong-notary.example:8080", + notaryClientId, + TEST_NCA_ID, + TEST_TASK_ID_1, + Instant.now(clock)); + + assertThatThrownBy(() -> workerAssertionValidator.validate(token, TEST_NCA_ID, TEST_TASK_ID_1)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("issuer"); + } + + @Test + void shouldRejectExpiredWorkerAssertion() { + var token = MockNotaryServer.generateSignedWorkerAssertion( + notaryBaseUrl, + notaryClientId, + TEST_NCA_ID, + TEST_TASK_ID_1, + Instant.now(clock).minus(4, ChronoUnit.HOURS)); + + assertThatThrownBy(() -> workerAssertionValidator.validate(token, TEST_NCA_ID, TEST_TASK_ID_1)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Expired"); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/EssResponseTransformer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/EssResponseTransformer.java new file mode 100644 index 000000000..9f828a559 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/EssResponseTransformer.java @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.http.Response; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.nvct.service.ess.EssStubService.SaveSecretsRequest; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import lombok.Builder; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class EssResponseTransformer implements ResponseTransformerV2 { + public static final String NAME = "ess-response-transformer"; + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + private final Map> secretsByTaskKey = new HashMap<>(); + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return NAME; + } + + @SneakyThrows + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var url = request.getAbsoluteUrl(); + log.debug("Transformer '{}': Request - {} {}", this.hashCode(), request.getMethod(), url); + + return switch (request.getMethod().getName()) { + case "PUT" -> saveOrUpdateSecrets(request, response); + case "DELETE" -> deleteSecrets(request, response); + case "GET" -> fetchSecrets(request, response); + default -> throw new BadRequestException("Unexpected HTTP Method"); + }; + } + + public void clearSecrets() { + secretsByTaskKey.clear(); + } + + @SneakyThrows + private Response saveOrUpdateSecrets(Request request, Response response) { + var taskId = getTaskId(request); + log.debug("Task id '{}': Saving / Updating Secrets", taskId); + + var rawBody = request.getBodyAsString(); + var body = jsonMapper.readValue(rawBody, SaveSecretsRequest.class); + var newSecrets = body.getData(); + secretsByTaskKey.put(taskId, newSecrets); + return response; + } + + @SneakyThrows + private Response fetchSecrets(Request request, Response response) { + var taskId = getTaskId(request); + log.debug("Task id '{}': Fetching Secrets", taskId); + + if (!request.getAbsoluteUrl().contains("query_type=fetch_secret")) { + throw new BadRequestException("Only supports query_type=fetch_secret"); + } + + var existingSecrets = secretsByTaskKey.getOrDefault(taskId, Collections.emptyMap()); + if (!existingSecrets.isEmpty()) { + var payload = new FetchSecretsResponse( + new FetchSecretsResponse.FetchSecretData(existingSecrets)); + var serialized = jsonMapper.writeValueAsString(payload); + return Response.Builder.like(response).body(serialized).build(); + } + return Response.response().status(404).build(); + } + + private Response deleteSecrets(Request request, Response response) { + var taskId = getTaskId(request); + + // Delete secrets. + log.debug("Task id '{}': Deleting Secrets", taskId); + secretsByTaskKey.remove(taskId); + + return response; + } + + private UUID getTaskId(Request request) { + var url = request.getAbsoluteUrl(); + var indexOfSlashAfterTasks = url.indexOf('/', url.indexOf("tasks")); + var rawTaskId = !url.contains("secrets") ? + url.substring(indexOfSlashAfterTasks + 1) : + url.substring(indexOfSlashAfterTasks + 1, url.indexOf("secrets") - 1); + return UUID.fromString(rawTaskId); + } + + @Value + @Jacksonized + @Builder + @VisibleForTesting + static class FetchSecretsResponse { + @NonNull + FetchSecretData data; + + @Value + @Jacksonized + @Builder + public static class FetchSecretData { + @NonNull + Map data; // Object will be a Map when response is deserialized. + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/ManagedHttpResourcesTest.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/ManagedHttpResourcesTest.java new file mode 100644 index 000000000..9b3bb72cf --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/ManagedHttpResourcesTest.java @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nvidia.nvct.util.NvctOAuth2ClientUtils.ManagedHttpResources; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.client.reactive.ClientHttpConnector; +import reactor.core.publisher.Mono; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.resources.LoopResources; + +@ExtendWith(MockitoExtension.class) +class ManagedHttpResourcesTest { + + @Mock + private ClientHttpConnector connector; + + @Mock + private ConnectionProvider connectionProvider; + + @Mock + private LoopResources loopResources; + + @Test + void close_invokesDisposeLaterOnBothResources() { + when(connectionProvider.disposeLater()).thenReturn(Mono.empty()); + when(loopResources.disposeLater( + eq(ManagedHttpResources.QUIET_PERIOD), + eq(ManagedHttpResources.DISPOSE_TIMEOUT))) + .thenReturn(Mono.empty()); + + var resources = new ManagedHttpResources(connector, connectionProvider, + loopResources, "test-client"); + resources.close(); + + verify(connectionProvider).disposeLater(); + verify(loopResources).disposeLater( + ManagedHttpResources.QUIET_PERIOD, ManagedHttpResources.DISPOSE_TIMEOUT); + } + + @Test + void close_swallowsException_whenDisposeFails() { + when(connectionProvider.disposeLater()) + .thenReturn(Mono.error(new RuntimeException("simulated dispose failure"))); + when(loopResources.disposeLater( + eq(ManagedHttpResources.QUIET_PERIOD), + eq(ManagedHttpResources.DISPOSE_TIMEOUT))) + .thenReturn(Mono.empty()); + + var resources = new ManagedHttpResources(connector, connectionProvider, + loopResources, "test-client"); + + assertDoesNotThrow(resources::close); + + // LoopResources must still be disposed even though ConnectionProvider failed. + verify(loopResources).disposeLater( + ManagedHttpResources.QUIET_PERIOD, ManagedHttpResources.DISPOSE_TIMEOUT); + } + + @Test + void close_isNoOp_whenBothResourcesNull() { + var resources = new ManagedHttpResources(null, null, null, "test"); + assertDoesNotThrow(resources::close); + } + + @Test + void connector_returnsInjectedInstance() { + try (var resources = new ManagedHttpResources(connector, null, null, "test")) { + assertSame(connector, resources.connector()); + } + } + + @Test + void timeoutConstants_areInternallyConsistent() { + assertTrue(ManagedHttpResources.BLOCK_TIMEOUT.compareTo( + ManagedHttpResources.DISPOSE_TIMEOUT) > 0, + "BLOCK_TIMEOUT must be strictly greater than DISPOSE_TIMEOUT"); + + assertTrue(ManagedHttpResources.BLOCK_TIMEOUT.compareTo(Duration.ofSeconds(30)) < 0, + "BLOCK_TIMEOUT must stay under spring.lifecycle.timeout-per-shutdown-phase (default 30s)"); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockApiKeysServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockApiKeysServer.java new file mode 100644 index 000000000..486e0fa5b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockApiKeysServer.java @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_OWNER_ID; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Policy; +import com.nvidia.nvct.service.apikeys.ApiKeyValidationResult.Resource; +import com.nvidia.nvct.service.apikeys.dto.ApiKeyValidationResponse; +import java.net.URI; +import java.util.List; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class MockApiKeysServer { + + public static final String EVALUATION_URI_PATH = "/v1/namespaces/nvct/evaluations/apikey.allow"; + + private static final JsonMapper OBJECT_MAPPER = JsonMapper.builder().build(); + @Getter + private static WireMockServer mockApiKeysServer; + + @SneakyThrows + public static void start(String apiKeysBaseUrl) { + stop(); + mockApiKeysServer = new WireMockServer(new URI(apiKeysBaseUrl).getPort()); + mockApiKeysServer.start(); + resetToDefault(); + } + + public static void stop() { + if (mockApiKeysServer != null) { + mockApiKeysServer.stop(); + } + } + + @SneakyThrows + public static void setApiKeyValidationResponse( + String ncaId, + String ownerId, + List resources, + List scopes, + boolean allowed) { + var result = new ApiKeyValidationResult(allowed, + ncaId, + ownerId, + new Policy(resources, + scopes, + "nv-cloud-functions")); + var response = new ApiKeyValidationResponse("nvct", "apikey.allow", result); + byte[] responseBytes = OBJECT_MAPPER.writeValueAsBytes(response); + mockApiKeysServer.stubFor( + post(urlPathEqualTo(EVALUATION_URI_PATH)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody(responseBytes))); + } + + + @SneakyThrows + public static void setResponse( + String ncaId, + String ownerId, + List resources, + List scopes) { + setApiKeyValidationResponse(ncaId, ownerId, resources, scopes, true); + } + + public static void resetToDefault() { + setResponse(TEST_NCA_ID, TEST_OWNER_ID, List.of(), List.of()); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockEssServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockEssServer.java new file mode 100644 index 000000000..5866af7ff --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockEssServer.java @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.datastax.oss.driver.shaded.guava.common.net.HttpHeaders.CONTENT_TYPE; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.nvidia.nvct.util.EssResponseTransformer.NAME; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.matching.EqualToPattern; +import java.net.URI; +import java.time.Instant; +import java.util.UUID; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class MockEssServer { + + @Getter + private static WireMockServer mockEssServer; + + private static EssResponseTransformer essResponseTransformer; + + @SneakyThrows + public static void start(String essBaseUrl) { + stop(); + + essResponseTransformer = new EssResponseTransformer(); + var configuration = new WireMockConfiguration() + .port(URI.create(essBaseUrl).getPort()) + .extensions(essResponseTransformer); + mockEssServer = new WireMockServer(configuration); + + var saveOrUpdateSecretsResponse = """ + { + "data": { + "created_time": "%s", + "version": "%s" + } + } + """.formatted(Instant.now(), UUID.randomUUID()); + mockEssServer.stubFor(put(urlPathMatching("/v1/tasks/(.+)?/secrets")) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody(saveOrUpdateSecretsResponse) + .withTransformers(NAME))); + mockEssServer.stubFor(get(urlPathMatching("/v1/tasks/(.+)?/secrets")) + .withQueryParam("query_type", + new EqualToPattern("fetch_secret")) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withTransformers(NAME))); + mockEssServer.stubFor(delete(urlPathMatching("/v1/tasks/(.+)?/secrets")) + .willReturn(aResponse().withStatus(204) + .withTransformers(NAME))); + mockEssServer.stubFor(delete(urlPathMatching("/v1/tasks/(.+)")) + .willReturn(aResponse().withStatus(204) + .withTransformers(NAME))); + mockEssServer.start(); + } + + public static void stop() { + if (mockEssServer != null) { + mockEssServer.stop(); + } + } + + public static void clearSecrets() { + if (essResponseTransformer != null) { + essResponseTransformer.clearSecrets(); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockIcmsServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockIcmsServer.java new file mode 100644 index 000000000..f55eb961f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockIcmsServer.java @@ -0,0 +1,478 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.nvidia.nvct.util.MockIcmsServer.ClusterGroupsResponseState.COMPLETE; +import static com.nvidia.nvct.util.MockIcmsServer.InstancesState.HEALTHY; +import static com.nvidia.nvct.util.TestConstants.L40G; +import static com.nvidia.nvct.util.TestConstants.L40G_INSTANCE_TYPE; +import static com.nvidia.nvct.util.TestUtil.readFileAsString; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.common.ListOrSingle; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; +import com.github.tomakehurst.wiremock.extension.responsetemplating.helpers.FormParser; +import com.github.tomakehurst.wiremock.http.Response; +import com.github.tomakehurst.wiremock.matching.EqualToPattern; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import com.nvidia.nvct.rest.task.dto.InstanceUsageTypeEnum; +import com.nvidia.nvct.service.icms.IcmsStubService; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse; +import com.nvidia.nvct.service.icms.IcmsStubService.GetInstancesResponse.InstanceRequest; +import java.net.URL; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import tools.jackson.databind.json.JsonMapper; + +@Slf4j +@UtilityClass +public class MockIcmsServer { + + public enum InstancesState { + HEALTHY, + UNHEALTHY, + NO_CAPACITY + } + + public enum ClusterGroupsResponseState { + COMPLETE, + EMPTY_BODY, + NO_BODY, + MISSING_CLUSTER_GROUP, + MISSING_GPUS, + MISSING_GPU, + MISSING_INSTANCE_TYPES, + MISSING_INSTANCE_TYPE_DEFAULT, + WITH_ERROR_BODY_400, + WITHOUT_ERROR_BODY_500 + } + + public static final String GPU_INSTANCETYPE = "g6.full"; + public static final String GPU_NAME = "GFN_T10"; + + @Data + @Builder(toBuilder = true) + public static class IcmsRequestContext { + String icmsRequestId; + InstancesState instanceState; + String createTime; + } + + private static final String KEY_CONTEXT = "Context"; + + @Getter + private static WireMockServer mockIcmsServer; + + private static JsonMapper jsonMapper; + + @Getter + private static long cacheSize; + + @Getter + private static String capturedHelmValidationPolicy; + + @SneakyThrows + public static void start(URL url, JsonMapper jsonMapper) { + var contexts = List.of(IcmsRequestContext.builder().instanceState(HEALTHY).build()); + start(url, jsonMapper, contexts); + } + + @SneakyThrows + public static void start( + URL url, + JsonMapper jsonMapper, + List contexts) { + start(url, jsonMapper, contexts, COMPLETE); + } + + @SneakyThrows + public static void start( + URL url, + JsonMapper jsonMapper, + List contexts, + ClusterGroupsResponseState clusterGroupsResponseState) { + stop(); + MockIcmsServer.jsonMapper = jsonMapper; + var instanceRequestExtension = new RequestInstancesResponseTransformer(); + var getInstancesByTaskIdExtension = new GetInstancesByTaskIdResponseTransformer(); + var config = WireMockConfiguration.options() + .port(url.getPort()) + .extensions(instanceRequestExtension, + getInstancesByTaskIdExtension); + mockIcmsServer = new WireMockServer(config); + mockIcmsServer.stubFor(post(urlPathEqualTo("/v1/si")) + .withQueryParam("Action", + new EqualToPattern("RequestInstances")) + .willReturn(aResponse().withStatus(200) + .withTransformers(instanceRequestExtension.getName()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{}"))); + mockIcmsServer.stubFor(get(urlPathMatching("/v1/si/accounts/.*/deployments/.*/instances")) + .withQueryParam("IncludeTerminated", + new EqualToPattern("false")) + .withQueryParam("UseConciseName", + new EqualToPattern("true")) + .withQueryParam("ExpiredAckedInstances", + new EqualToPattern("false")) + .willReturn(aResponse().withStatus(200) + .withTransformer( + getInstancesByTaskIdExtension.getName(), + KEY_CONTEXT, + contexts) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{}"))); + mockIcmsServer.stubFor(get(urlPathMatching("/v1/si/accounts/.*/deployments/.*/instances")) + .withQueryParam("IncludeTerminated", + new EqualToPattern("true")) + .withQueryParam("UseConciseName", + new EqualToPattern("true")) + .withQueryParam("ExpiredAckedInstances", + new EqualToPattern("true")) + .willReturn(aResponse().withStatus(200) + .withTransformer( + getInstancesByTaskIdExtension.getName(), + KEY_CONTEXT, + contexts) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{}"))); + mockIcmsServer.stubFor(delete(urlPathEqualTo("/v1/si")) + .withQueryParam("Action", + new EqualToPattern("TerminateInstances")) + .willReturn(aResponse().withStatus(200))); + mockIcmsServer.stubFor(delete(urlPathMatching("/v1/si/accounts/.*/workloads/.*")) + .willReturn(aResponse().withStatus(200))); + addStubForGetClusterGroupsEndpoint(mockIcmsServer, clusterGroupsResponseState); + addStubForGetClustersEndpoint(mockIcmsServer); + mockIcmsServer.start(); + } + + public static void stop() { + if (mockIcmsServer != null) { + mockIcmsServer.stop(); + } + } + + public static void stubTerminateInstanceNotFound() { + mockIcmsServer.stubFor( + delete(urlPathMatching("/v1/si/accounts/.*/workloads/.*")) + .atPriority(1) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{\"detail\": \"Workload not found\"}"))); + } + + private static void addStubForGetClustersEndpoint( + WireMockServer mockIcmsServer) { + var body = readFileAsString("fixtures/icms/clusters/complete-response.json"); + mockIcmsServer.stubFor(get(urlPathMatching("/v1/si/accounts/.*/clusters")) + .withQueryParam("includeAuthorizedClusters", + new EqualToPattern("true")) + .withQueryParam("includeGfnInAuthorizedClusters", + new EqualToPattern("true")) + .withQueryParam( + "instanceTypeUsage", + equalTo(InstanceUsageTypeEnum.DEFAULT.toString()) + .or(equalTo(InstanceUsageTypeEnum.CONTAINER.toString()))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(body))); + } + + private static void addStubForGetClusterGroupsEndpoint( + WireMockServer mockIcmsServer, + ClusterGroupsResponseState responseState) { + var body = switch (responseState) { + case COMPLETE -> readFileAsString("fixtures/icms/cluster-groups/complete-response.json"); + case EMPTY_BODY -> "{}"; + case NO_BODY, WITHOUT_ERROR_BODY_500 -> null; + case MISSING_CLUSTER_GROUP -> readFileAsString( + "fixtures/icms/cluster-groups/missing-gfn-cluster-group-response.json"); + case MISSING_GPUS -> readFileAsString( + "fixtures/icms/cluster-groups/missing-gfn-gpus-response.json"); + case MISSING_GPU -> readFileAsString("fixtures/icms/cluster-groups/missing-t10-gpu-response.json"); + case MISSING_INSTANCE_TYPES -> readFileAsString( + "fixtures/icms/cluster-groups/missing-t10-instance-types-response.json"); + case MISSING_INSTANCE_TYPE_DEFAULT -> readFileAsString( + "fixtures/icms/cluster-groups/missing-t10-instance-type-default-response.json"); + case WITH_ERROR_BODY_400 -> "{\"error\": \"pretend bad deployment spec\"}"; + }; + var status = switch (responseState) { + case WITH_ERROR_BODY_400 -> 400; + case WITHOUT_ERROR_BODY_500 -> 500; + default -> 200; + }; + mockIcmsServer.stubFor(get(urlPathMatching("/v1/si/accounts/.*/clusterGroups")) + .withQueryParam( + "instanceTypeUsage", + equalTo(InstanceUsageTypeEnum.DEFAULT.toString()) + .or(equalTo(InstanceUsageTypeEnum.CONTAINER.toString()))) + .willReturn(aResponse().withStatus(status) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody(body))); + mockIcmsServer.stubFor(get(urlPathEqualTo("/v1/si")) + .withQueryParam("Action", + new EqualToPattern("DescribeInstances")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(""" + { + "Instances": [ + { + "ImageId": "", + "ContainerImage": "", + "InstanceId": "local-instance", + "InstanceIps": [ + "127.0.0.1", + "127.0.0.2" + ], + "CloudProvider": "GFN | OCI | AZURE | AWS | GCP", + "InstanceType": "", + "Placement": { + "AvailabilityZone": "np-lax02" + }, + "State": { + "Code": 0, + "Name": "running" + }, + "HealthInfo": { + "ErrorLog": "" + }, + "LaunchRequestId": "" + } + ] + }"""))); + + } + + public static class RequestInstancesResponseTransformer implements ResponseTransformerV2 { + + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var data = FormParser.parse(request.getBodyAsString(), true); + cacheSize = Long.parseLong( + data.getOrDefault("LaunchSpecification.CacheSize", ListOrSingle.of("0")) + .getFirst()); + capturedHelmValidationPolicy = + data.containsKey("LaunchSpecification.HelmValidationPolicy") + ? data.get("LaunchSpecification.HelmValidationPolicy").getFirst() + : null; + var rawBody = """ + { + "requestId": "%s" + }"""; + var body = rawBody.formatted(UUID.randomUUID()); + return Response.Builder.like(response).body(body).build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return "request-instances"; + } + } + + public static class DescribeInstanceRequestsResponseTransformer + implements ResponseTransformerV2 { + + private static final String RAW_HEALTHY_INSTANCE_JSON = + readFileAsString("fixtures/icms/raw-healthy-instance.json"); + private static final String RAW_UNHEALTHY_INSTANCE_JSON = + readFileAsString("fixtures/icms/raw-unhealthy-instance.json"); + private static final String RAW_NO_CAPACITY_INSTANCE_JSON = + readFileAsString("fixtures/icms/raw-no-capacity-instance.json"); + + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var parameters = serveEvent.getTransformerParameters(); + var icmsRequestIds = request.queryParameter("InstanceRequestId").values(); + var rawContexts = (List) parameters.get(KEY_CONTEXT); + List contexts = new ArrayList<>(); + for (int i = 0; i < icmsRequestIds.size(); i++) { + var index = i % rawContexts.size(); + var icmsRequestId = icmsRequestIds.get(i); + var ctx = rawContexts.get(index).toBuilder().icmsRequestId(icmsRequestId).build(); + contexts.add(ctx); + } + var body = getTransformedResponseBody(contexts); + return Response.Builder.like(response).body(body).build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return "describe-instance-requests"; + } + + @SneakyThrows + private static String getTransformedResponseBody( + List contexts) { + var instanceRequests = contexts.stream() + .map(DescribeInstanceRequestsResponseTransformer::getInstanceRequests) + .flatMap(Collection::stream) + .toList(); + var responseBody = GetInstancesResponse.builder() + .instanceRequests(instanceRequests).build(); + return jsonMapper.writeValueAsString(responseBody); + } + + @SneakyThrows + private static List getInstanceRequests( + IcmsRequestContext context) { + List instances; + try { + instances = switch (context.getInstanceState()) { + case HEALTHY -> List.of( + jsonMapper.readValue(RAW_HEALTHY_INSTANCE_JSON.formatted( + UUID.randomUUID(), // instance-id + GPU_INSTANCETYPE, + GPU_NAME, + context.getIcmsRequestId()), + InstanceRequest.class)); + case UNHEALTHY -> List.of( + jsonMapper.readValue(RAW_UNHEALTHY_INSTANCE_JSON.formatted( + UUID.randomUUID(), // instance-id + GPU_INSTANCETYPE, + GPU_NAME, + context.getIcmsRequestId(), + GPU_NAME), + InstanceRequest.class)); + case NO_CAPACITY -> List.of( + jsonMapper.readValue(RAW_NO_CAPACITY_INSTANCE_JSON.formatted( + UUID.randomUUID(), // instance-id + L40G_INSTANCE_TYPE, + L40G, + context.getIcmsRequestId(), + L40G), + InstanceRequest.class)); + }; + } catch (Exception ex) { + log.error("Context Status: '{}'; ICMS Request Id: '{}', Exception: '{}'", + context.getInstanceState(), context.getIcmsRequestId(), + ex.getMessage()); + throw ex; + } + + log.info("Context Status: '{}'", context.getInstanceState()); + log.info("Instance1: '{}'", instances.getFirst()); + return instances; + } + } + + public static class GetInstancesByTaskIdResponseTransformer + implements ResponseTransformerV2 { + + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var parameters = serveEvent.getTransformerParameters(); + var rawContexts = (List) parameters.get(KEY_CONTEXT); + var contexts = rawContexts.stream() + .map(context -> { + var icmsRequestId = StringUtils.isBlank(context.getIcmsRequestId()) + ? UUID.randomUUID().toString() + : context.getIcmsRequestId(); + return context.toBuilder().icmsRequestId(icmsRequestId).build(); + }) + .toList(); + var body = getTransformedResponseBody(contexts); + return Response.Builder.like(response).body(body).build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return "get-instances-by-task-id"; + } + + @SneakyThrows + private static String getTransformedResponseBody( + List contexts) { + var instances = contexts.stream() + .map(GetInstancesByTaskIdResponseTransformer::getInstances) + .flatMap(Collection::stream) + .toList(); + var responseBody = IcmsStubService.Instances.builder() + .Instances(instances).build(); + return jsonMapper.writeValueAsString(responseBody); + } + + @SneakyThrows + private static List getInstances( + IcmsRequestContext context) { + return DescribeInstanceRequestsResponseTransformer.getInstanceRequests(context) + .stream() + .map(instance -> GetInstancesByTaskIdResponseTransformer.toInstance( + instance, context)) + .toList(); + } + + private static IcmsStubService.Instance toInstance( + InstanceRequest instanceRequest, + IcmsRequestContext context) { + return IcmsStubService.Instance.builder() + .createTime(context.getCreateTime() != null ? + Instant.parse(context.getCreateTime()) : Instant.now()) + .containerImage(instanceRequest.getLaunchSpecification().getContainerImage()) + .instanceId(instanceRequest.getInstanceId()) + .cloudProvider(instanceRequest.getCloudProvider()) + .instanceType(instanceRequest.getLaunchSpecification().getInstanceType()) + .placement(InstanceRequest.Placement.builder() + .availabilityZone(instanceRequest.getLaunchedAvailabilityZone()) + .build()) + .state(instanceRequest.getInstanceState()) + .healthInfo(instanceRequest.getHealthInfo()) + .launchRequestId(instanceRequest.getInstanceRequestId().toString()) + .build(); + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNotaryServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNotaryServer.java new file mode 100644 index 000000000..7154fdffa --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNotaryServer.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.nvct.util.NotaryServiceResponseTransformer.NAME; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import java.net.URI; +import java.time.Instant; +import java.util.UUID; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; + +public class MockNotaryServer { + + private static final JsonMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private static NotaryServiceResponseTransformer notaryServiceResponseTransformer; + + private static WireMockServer mockNotaryServer; + + @SneakyThrows + public static void start(String notaryBaseUrl, String notaryClientId) { + stop(); + notaryServiceResponseTransformer = new NotaryServiceResponseTransformer(notaryBaseUrl, + notaryClientId); + var configuration = new WireMockConfiguration() + .port(URI.create(notaryBaseUrl).getPort()) + .extensions(notaryServiceResponseTransformer); + + mockNotaryServer = new WireMockServer(configuration); + mockNotaryServer + .stubFor(post(urlPathEqualTo("/sign")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withTransformers(NAME))); + mockNotaryServer + .stubFor(get(urlPathEqualTo("/.well-known/jwks.json")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(notaryServiceResponseTransformer.getJwksJson()))); + mockNotaryServer.start(); + } + + public static void stop() { + if (mockNotaryServer != null) { + mockNotaryServer.stop(); + } + } + + @SneakyThrows + public static String generateSignedWorkerAssertion(String issuer, + String subject, + String ncaId, + UUID taskId, + Instant issuedAt) { + return NotaryServiceResponseTransformer.generateSignedWorkerAssertion( + issuer, subject, ncaId, taskId, issuedAt, OBJECT_MAPPER); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNvcfServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNvcfServer.java new file mode 100644 index 000000000..1fc95ea08 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockNvcfServer.java @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_4; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_5; +import static com.nvidia.nvct.util.TestConstants.TEST_CLIENT_ID_6; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_2; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_3; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5; +import static com.nvidia.nvct.util.TestConstants.TEST_NCA_ID_WITH_TELEMETRIES_4; +import static com.nvidia.nvct.util.TestUtil.readFileAsString; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; +import com.github.tomakehurst.wiremock.http.Response; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import java.net.URL; +import java.time.Instant; +import java.util.Map; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class MockNvcfServer { + + @Getter + private static WireMockServer mockNvcfServer; + + private static final String ACCOUNT_RESPONSE = + readFileAsString("fixtures/nvcf/account-response.json"); + private static final String ACCOUNT_WITH_TELEMETRIES_RESPONSE = + readFileAsString("fixtures/nvcf/account-with-telemetries-response.json"); + private static final String ACCOUNT_WITHOUT_REGISTRY_CREDENTIALS_RESPONSE = + readFileAsString("fixtures/nvcf/account-without-registry-credentials-response.json"); + private static final String CLIENT_RESPONSE = + readFileAsString("fixtures/nvcf/client-response.json"); + + @SneakyThrows + public static void start(URL url) { + stop(); + var accountExtension = new AccountResponseTransformer(); + var clientResponseExtension = new ClientResponseTransformer(); + var config = WireMockConfiguration.options() + .port(url.getPort()) + .extensions(accountExtension, clientResponseExtension); + mockNvcfServer = new WireMockServer(config); + mockNvcfServer.stubFor(get(urlMatching("/v2/nvcf/accounts/(.*)")) + .willReturn(aResponse().withStatus(200) + .withTransformers(accountExtension.getName()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{}"))); + mockNvcfServer.stubFor(get(urlMatching("/v2/nvcf/clients/(.*)")) + .willReturn(aResponse().withStatus(200) + .withTransformers(clientResponseExtension.getName()) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody("{}"))); + mockNvcfServer.start(); + } + + public static void stop() { + if (mockNvcfServer != null) { + mockNvcfServer.stop(); + } + } + + public static class AccountResponseTransformer implements ResponseTransformerV2 { + private static final Map ncaIdToClientIdMap = + Map.of(TEST_NCA_ID, TEST_CLIENT_ID, + TEST_NCA_ID_2, TEST_CLIENT_ID_2, + TEST_NCA_ID_3, TEST_CLIENT_ID_3, + TEST_NCA_ID_WITH_TELEMETRIES_4, TEST_CLIENT_ID_4, + TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5, TEST_CLIENT_ID_5, + TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6, TEST_CLIENT_ID_6); + + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var index = request.getUrl().lastIndexOf("/"); + var ncaId = request.getUrl().substring(index + 1); + var clientId = ncaIdToClientIdMap.get(ncaId); + if (clientId == null) { + return Response.Builder.like(response).status(404).body("").build(); + } + String rawBody; + if (ncaId.contains("without-registry-credentials")) { + rawBody = ACCOUNT_WITHOUT_REGISTRY_CREDENTIALS_RESPONSE; + } else if (ncaId.contains("telemetries")) { + rawBody = ACCOUNT_WITH_TELEMETRIES_RESPONSE; + } else { + rawBody = ACCOUNT_RESPONSE; + } + var maxTasksAllowed = ncaId.contains("1-max-allowed-tasks") ? 1 : 50; + var body = rawBody.formatted(ncaId, clientId, ncaId + "-name", maxTasksAllowed) + .replace("_instant_", Instant.now().toString()); + return Response.Builder.like(response).body(body).build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return "account-response-transformer"; + } + } + + public static class ClientResponseTransformer implements ResponseTransformerV2 { + private static final Map clientIdToNcaIdMap = + Map.of(TEST_CLIENT_ID, TEST_NCA_ID, + TEST_CLIENT_ID_2, TEST_NCA_ID_2, + TEST_CLIENT_ID_3, TEST_NCA_ID_3, + TEST_CLIENT_ID_4, TEST_NCA_ID_WITH_TELEMETRIES_4, + TEST_CLIENT_ID_5, TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5, + TEST_CLIENT_ID_6, TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6); + + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var index = request.getUrl().lastIndexOf("/"); + var clientId = request.getUrl().substring(index + 1); + var ncaId = clientIdToNcaIdMap.get(clientId); + if (ncaId == null) { + return Response.Builder.like(response).status(404).body("").build(); + } + var body = CLIENT_RESPONSE.formatted(clientId, ncaId, ncaId + "-name"); + return Response.Builder.like(response).body(body).build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return "client-response-transformer"; + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockRevalServer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockRevalServer.java new file mode 100644 index 000000000..4d799569b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/MockRevalServer.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class MockRevalServer { + private static WireMockServer wireMockServer; + + public static void start(URI listenUrl) { + stop(); + + wireMockServer = new WireMockServer(options().port(listenUrl.getPort())); + + var validationOk = aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"valid\": true}"); + + var validationError = aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + {"valid": false, "validationErrors": ["spam", "eggs"]} + """); + + wireMockServer.stubFor( + post("/v1/validate") + .withRequestBody( + matchingJsonPath( + "$.helmChart", + containing("invalid-helm-chart"))) + .willReturn(validationError)); + wireMockServer.stubFor( + post("/v1/validate") + .atPriority(4) + .withRequestBody( + matchingJsonPath("$.configuration.fail", equalTo("fail"))) + .willReturn(validationError)); + wireMockServer.stubFor( + post("/v1/validate") + .withRequestBody( + matchingJsonPath( + "$.helmChart", + containing("test-helm-chart"))) + .willReturn(validationOk)); + + wireMockServer.stubFor( + post("/v1/validate") + .withHeader("Authorization", absent()) + .willReturn( + aResponse() + .withStatus(401) + .withBody("Authorization header required"))); + + wireMockServer.start(); + + } + + public static void stop() { + if (wireMockServer != null && wireMockServer.isRunning()) { + wireMockServer.stop(); + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/NotaryServiceResponseTransformer.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/NotaryServiceResponseTransformer.java new file mode 100644 index 000000000..bb0f5d886 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/NotaryServiceResponseTransformer.java @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.boot.mock.oauth2.OAuth2TestUtils.getServiceId; +import static net.minidev.json.parser.JSONParser.DEFAULT_PERMISSIVE_MODE; + +import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.http.Response; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.nvct.service.token.client.NotaryStubService.SecretPathsAssertion; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignResponse; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignSecretPathsRequest; +import com.nvidia.nvct.service.token.client.NotaryStubService.SignWorkerAccessRequest; +import com.nvidia.nvct.service.token.client.NotaryStubService.WorkerAccessAssertion; +import java.net.URI; +import java.net.URL; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import net.minidev.json.parser.JSONParser; +import tools.jackson.databind.json.JsonMapper; + +@Slf4j +public class NotaryServiceResponseTransformer implements ResponseTransformerV2 { + + public static final String NAME = "notary-service-response-transformer"; + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + + private String notaryBaseUrl; + private String notaryClientId; + + public NotaryServiceResponseTransformer(String notaryBaseUrl, String notaryClientId) { + this.notaryBaseUrl = notaryBaseUrl; + this.notaryClientId = notaryClientId; + } + + public String getJwksJson() { + return NotaryUtils.getJwks().toString(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + @Override + public String getName() { + return NAME; + } + + @SneakyThrows + @Override + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var url = request.getAbsoluteUrl(); + log.info("Transformer '{}': Request - {} {}", this.hashCode(), request.getMethod(), url); + + return switch (request.getMethod().getName()) { + case "POST" -> sign(request, response); + default -> throw new BadRequestException("Unexpected HTTP Method"); + }; + } + + @SneakyThrows + private Response sign(Request request, Response response) { + if (request.getBodyAsString().contains("secretPaths")) { + // The request is secretPath request. Issue a secrets token + var assertion = jsonMapper.readValue(request.getBody(), SignSecretPathsRequest.class).data(); + var namespace = assertion.namespace(); + var secretPaths = assertion.secretPaths(); + log.info("Namespace '{}'. Secret paths '{}'", namespace, secretPaths); + var payload = new SignResponse(generateJwtForSecretsAssertion(notaryBaseUrl, + notaryClientId, + namespace, + secretPaths, + jsonMapper)); + var serialized = jsonMapper.writeValueAsString(payload); + return Response.Builder.like(response).body(serialized).build(); + } + + // Otherwise the request is worker access request. Issue a worker token + var assertion = jsonMapper.readValue(request.getBody(), SignWorkerAccessRequest.class).data(); + var taskId = assertion.taskId(); + var ncaId = assertion.ncaId(); + log.info("NCA id '{}', Task id '{}': assertion token", ncaId, taskId); + var payload = new SignResponse(generateJwtForWorkerAssertion(notaryBaseUrl, + notaryClientId, + ncaId, + taskId, + jsonMapper)); + var serialized = jsonMapper.writeValueAsString(payload); + return Response.Builder.like(response).body(serialized).build(); + } + + @SneakyThrows + private static String generateJwtForWorkerAssertion(String notaryBaseUrl, + String notaryClientId, + String ncaId, + UUID taskId, + JsonMapper jsonMapper) { + var assertion = new WorkerAccessAssertion(ncaId, taskId); + return NotaryUtils.getJwt(notaryClientId, + jsonMapper.writeValueAsString(assertion), + URI.create(notaryBaseUrl).toURL()); + } + + @SneakyThrows + public static String generateSignedWorkerAssertion(String issuer, + String subject, + String ncaId, + UUID taskId, + Instant issuedAt, + JsonMapper jsonMapper) { + var assertion = new WorkerAccessAssertion(ncaId, taskId); + return NotaryUtils.getJwt(subject, + jsonMapper.writeValueAsString(assertion), + new URL(issuer), + Date.from(issuedAt)); + } + + @SneakyThrows + private static String generateJwtForSecretsAssertion(String notaryBaseUrl, + String notaryClientId, + String namespace, + List secretPaths, + JsonMapper jsonMapper) { + var assertion = new SecretPathsAssertion(namespace, secretPaths); + return NotaryUtils.getJwt(notaryClientId, + jsonMapper.writeValueAsString(assertion), + new URL(notaryBaseUrl)); + } + + private static class NotaryUtils { + + private static final JWSSigner signer; + + @Getter + private static final JWKSet jwks; + + static { + try { + var gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(Curve.P_256.toECParameterSpec()); + var keyPair = gen.generateKeyPair(); + + // Convert to JWK format + var privateJwk = new ECKey.Builder(Curve.P_256, + (ECPublicKey) keyPair.getPublic()) + .privateKey((ECPrivateKey) keyPair.getPrivate()) + .keyID(UUID.randomUUID().toString()) + .algorithm(JWSAlgorithm.ES256) + .build(); + var publicJWK = privateJwk.toPublicJWK(); + jwks = new JWKSet(publicJWK); + signer = new ECDSASigner(privateJwk); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SneakyThrows(JOSEException.class) + private static String getJwt(JWTClaimsSet.Builder claims) { + var jwk = jwks.getKeys().get(0); + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder((JWSAlgorithm) jwk.getAlgorithm()) + .keyID(jwk.getKeyID()) + .build(), + claims.build()); + signedJWT.sign(signer); + return signedJWT.serialize(); + } + + @SneakyThrows + private static String getJwt( + String subject, String assertion, URL issuer) { + return getJwt(subject, assertion, issuer, new Date()); + } + + @SneakyThrows + private static String getJwt( + String subject, String assertion, URL issuer, Date issuedAt) { + var parser = new JSONParser(DEFAULT_PERMISSIVE_MODE); + var jsonAssertion = parser.parse(assertion); + var claimsSetBuilder = new JWTClaimsSet.Builder() + .subject(subject) + .issueTime(issuedAt) + .claim("assertion", jsonAssertion) + .audience(List.of(getServiceId(issuer), subject)) + .issuer(issuer.toString()) + .jwtID(UUID.randomUUID().toString()); + return getJwt(claimsSetBuilder); + } + } + +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestConstants.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestConstants.java new file mode 100644 index 000000000..1ac112d54 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestConstants.java @@ -0,0 +1,478 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_OCI_HELM_CHART_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_OCI_HELM_CHART_TAG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_OCI_IMAGE_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_OCI_IMAGE_TAG_NAME; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_NOT_EXIST_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL_WITH_TEAM; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL_WITH_TEAM_2; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL_WITH_VERSION; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILE_PERMISSION_DENIED_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL_WITH_TEAM; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL_WITH_TEAM_2; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL_WITH_VERSION; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILE_NOT_EXISTS_URL_WITH_TEAM; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; +import static com.nvidia.nvct.util.NvctConstants.NGC_API_KEY; +import static java.nio.charset.StandardCharsets.UTF_8; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; +import com.nvidia.boot.mock.BootTestConstants; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.ModelUdt; +import com.nvidia.nvct.persistence.task.entity.ResourceUdt; +import com.nvidia.nvct.rest.task.dto.ArtifactDto; +import com.nvidia.nvct.rest.task.dto.ContainerEnvironmentEntryDto; +import com.nvidia.nvct.rest.task.dto.GpuSpecificationDto; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import com.nvidia.nvct.rest.task.dto.SecretDto; +import com.nvidia.nvct.rest.task.dto.ValidationPolicyNameEnum; +import com.nvidia.nvct.service.account.dto.RegistryCredentialDto; +import io.grpc.Metadata; +import io.grpc.Metadata.Key; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public class TestConstants { + + private static final JsonMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + public static final String TEST_ADMIN_SUBJECT = "test-admin-id"; + + public static final String TEST_CLIENT_SUBJECT = "test-client-id"; + public static final String TEST_ACCOUNT_NAME = "test-account"; + public static final String TEST_NCA_ID = "test-nca-id"; + public static final String TEST_OWNER_ID = "test-owner-id"; + public static final String TEST_OWNER_ID_2 = "test-owner-id-2"; + public static final String TEST_CLIENT_ID = TEST_CLIENT_SUBJECT; + public static final String TEST_CLIENT_SUBJECT_2 = "test-client-id-2"; + public static final String TEST_ACCOUNT_NAME_2 = "test-account-2"; + public static final String TEST_NCA_ID_2 = "test-nca-id-2"; + public static final String TEST_CLIENT_ID_2 = TEST_CLIENT_SUBJECT_2; + public static final String TEST_ACCOUNT_NAME_3 = "test-account-3"; + public static final String TEST_NCA_ID_3 = "test-nca-id-3"; + public static final String TEST_CLIENT_ID_3 = "test-client-id-3"; + public static final String TEST_NCA_ID_WITH_TELEMETRIES_4 = "test-nca-id-with-telemetries-4"; + public static final String TEST_CLIENT_ID_4 = "test-client-id-4"; + public static final String TEST_NCA_ID_WITH_1_MAX_ALLOWED_TASKS_5 = + "test-nca-id-with-1-max-allowed-tasks-5"; + public static final String TEST_CLIENT_ID_5 = "test-client-id-5"; + public static final String TEST_NCA_ID_WITHOUT_REGISTRY_CREDENTIALS_6 = + "test-nca-id-without-registry-credentials-6"; + public static final String TEST_CLIENT_ID_6 = "test-client-id-6"; + + public static final UUID TEST_TASK_ID_1 = + UUID.fromString("571114ac-13a2-4243-8f9f-1ccbee3b374f"); + public static final String TEST_TASK_NAME_1 = "test-task-name-1"; + public static final UUID TEST_TASK_ID_2 = + UUID.fromString("bcaf4c3f-e3dd-464c-a3c7-5c7143555b22"); + public static final String TEST_TASK_NAME_2 = "test-task-name-2"; + public static final UUID TEST_TASK_ID_3 = + UUID.fromString("4ab4a3bf-f151-43b9-bf98-80c1b2f68647"); + public static final UUID TEST_TASK_ID_4 = + UUID.fromString("5bc4a3bf-f151-43b9-bf98-80c1b2f68647"); + public static final UUID TEST_TASK_ID_5 = + UUID.fromString("6ef4a3bf-f151-43b9-bf98-80c1b2f68647"); + public static final String TEST_TASK_NAME_3 = "test-task-name-3"; + public static final UUID FAKE_TASK_ID = + UUID.fromString("c4ec1ef3-2514-4189-9bb0-3f39c9eeff6e"); + + + public static final UUID EVENT_ID_1 = UUID.randomUUID(); + public static final UUID EVENT_ID_2 = UUID.randomUUID(); + public static final UUID EVENT_ID_3 = UUID.randomUUID(); + public static final UUID EVENT_ID_4 = UUID.randomUUID(); + + public static final UUID RESULT_ID_1 = UUID.randomUUID(); + public static final UUID RESULT_ID_2 = UUID.randomUUID(); + public static final UUID RESULT_ID_3 = UUID.randomUUID(); + public static final UUID RESULT_ID_4 = UUID.randomUUID(); + + public static final String TEST_VALID_ORG_NAME = "whw3rcpsilnj"; // "test-valid-org-name" + public static final String TEST_VALID_ORG_NAME_INVALID_KEY = "test-valid-org-invalid-key"; + public static final String TEST_VALID_TEAM_NAME = "jeff"; + public static final String TEST_UNKNOWN_ORG_NAME = "test-unknown-org-name"; + public static final String TEST_UNKNOWN_TEAM_NAME = "test-unknown-team-name"; + public static final String TEST_MODEL_NAME = "test-model-name"; + public static final String TEST_NGC_API_KEY = "nvapi-stg-test-key"; + + public static final String TEST_RESULTS_LOCATION_1 = + TEST_VALID_ORG_NAME + "/" + TEST_VALID_TEAM_NAME + "/" + TEST_MODEL_NAME; + public static final String TEST_RESULTS_LOCATION_2 = + TEST_VALID_ORG_NAME + "/" + TEST_MODEL_NAME; + + public static final UUID TEST_ICMS_REQ_ID_1 = + UUID.fromString("572114ac-13a2-4243-8f9f-1ccbee3b374f"); + public static final UUID TEST_ICMS_REQ_ID_2 = + UUID.fromString("672114ad-13a2-4243-8f9f-1ccbee3b374f"); + public static final UUID TEST_ICMS_REQ_ID_3 = + UUID.fromString("772114ae-13a2-4243-8f9f-1ccbee3b374f"); + public static final UUID TEST_ICMS_REQ_ID_4 = + UUID.fromString("872114ae-13a2-4243-8f9f-1ccbee3b374f"); + public static final UUID TEST_ICMS_REQ_ID_5 = + UUID.fromString("972114ae-13a2-4243-8f9f-1ccbee3b374f"); + + // Backends or Cluster Groups + public static final String GFN = "GFN"; + public static final String OCI = "OCI"; + + // GPUs + public static final String T10 = "T10"; + public static final String L40G = "L40G"; + public static final String A10G = "A10G"; + + // Instance Types + public static final String T10_INSTANCE_TYPE = "g6.full"; + public static final String L40G_INSTANCE_TYPE = "gl40g_1.br25_2xlarge"; + public static final String OCI_L40G_INSTANCE_TYPE = "BM_GPU_L40G-2X"; + + public static final GpuSpecificationDto TEST_GFN_GPU_SPEC_DTO = + GpuSpecificationDto.builder().gpu(L40G).instanceType(L40G_INSTANCE_TYPE) + .backend(GFN).build(); + public static final GpuSpecificationDto TEST_OCI_GPU_SPEC_DTO = + GpuSpecificationDto.builder().gpu(L40G).instanceType(OCI_L40G_INSTANCE_TYPE) + .backend(OCI).build(); + public static final GpuSpecUdt TEST_GFN_GPU_SPEC = + GpuSpecUdt.builder() + .gpu(L40G).instanceType(L40G_INSTANCE_TYPE).backend(GFN).build(); + private static final ObjectNode CONFIGURATION = OBJECT_MAPPER.createObjectNode() + .put("replicas", 5); + public static final GpuSpecificationDto TEST_OCI_GPU_SPEC_HELM_CONFIG_DTO = + GpuSpecificationDto.builder().gpu(L40G).instanceType(OCI_L40G_INSTANCE_TYPE) + .backend(OCI).configuration(CONFIGURATION).build(); + public static final HelmValidationPolicyDto TEST_HELM_VALIDATION_POLICY_DTO = + HelmValidationPolicyDto.builder() + .name(ValidationPolicyNameEnum.DEFAULT) + .extraKubernetesTypes(List.of(HelmValidationPolicyDto.KubernetesType.builder() + .group("apps") + .version("v1") + .kind("Deployment") + .build())) + .build(); + public static final GpuSpecificationDto TEST_OCI_GPU_SPEC_WITH_HELM_POLICY_DTO = + GpuSpecificationDto.builder().gpu(L40G).instanceType(OCI_L40G_INSTANCE_TYPE) + .backend(OCI) + .helmValidationPolicy(TEST_HELM_VALIDATION_POLICY_DTO) + .build(); + + public static final String TEST_CONTAINER_REGISTRY = "stg.nvcr.io"; + public static final String TEST_CONTAINER_REGISTRY_PROD = "nvcr.io"; + public static final String TEST_CONTAINER_REGISTRY_CANARY = "canary.nvcr.io"; + public static final String TEST_HELM_REGISTRY = "helm.stg.ngc.nvidia.com"; + public static final String TEST_HELM_REGISTRY_PROD = "helm.ngc.nvidia.com"; + public static final String TEST_HELM_REGISTRY_CANARY = "helm.canary.ngc.nvidia.com"; + public static final String TEST_ARTIFACT_REGISTRY = "api.stg.ngc.nvidia.com"; + public static final String TEST_ARTIFACT_REGISTRY_PROD = "api.ngc.nvidia.com"; + public static final String TEST_ARTIFACT_REGISTRY_CANARY = "api.canary.ngc.nvidia.com"; + public static final String TEST_DOCKER_REGISTRY = "docker.io"; + public static final String TEST_CONTAINER_ARGS = "test-container-args"; + public static final URI TEST_HELM_CHART = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_HELM_REGISTRY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_WITH_CANARY_HOST = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz" + .formatted(TEST_HELM_REGISTRY_CANARY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_UNKNOWN_REGISTRY = + URI.create( + "https://unknown-registry/%s/%s/charts/%s-%s.tgz" + .formatted(TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_NOT_EXISTS = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_HELM_REGISTRY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_UNKNOWN_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_PERMISSION_DENIED = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_HELM_REGISTRY, + BootTestConstants.TEST_UNKNOWN_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_UNKNOWN_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_NOT_SUPPORTED_REGISTRY = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted("not.support.com", + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_CONTAINER_IMAGE = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_CONTAINER_IMAGE_WITH_CANARY_HOST = + URI.create(TEST_CONTAINER_REGISTRY_CANARY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY = + URI.create("not-exits/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_CONTAINER_IMAGE_WITHOUT_TAG = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME)); + public static final URI TEST_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s@%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_HASH)); + public static final URI TEST_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + "not-exists")); + public static final URI TEST_CONTAINER_IMAGE_WITH_INVALID_TAG = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + "latest:latest")); + public static final URI TEST_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + "permission-denied")); + public static final URI TEST_CONTAINER_IMAGE_NOT_SUPPORTED = + URI.create("not.supported" + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final String BASE_ARTIFACT_URL = "https://" + TEST_ARTIFACT_REGISTRY; + + public static final String MODEL_ARTIFACTS_URL = BASE_ARTIFACT_URL + MODEL_FILES_URL_WITH_TEAM; + public static final String MODEL_ARTIFACTS_URL_2 = + BASE_ARTIFACT_URL + MODEL_FILES_URL_WITH_TEAM_2; + public static final String MODEL_ARTIFACTS_URL_3 = BASE_ARTIFACT_URL + MODEL_FILES_URL; + public static final String MODEL_ARTIFACTS_URL_4 = + BASE_ARTIFACT_URL + MODEL_FILES_URL_WITH_VERSION; + public static final String MODEL_ARTIFACTS_URL_WITH_CANARY_HOST = + "https://" + TEST_ARTIFACT_REGISTRY_CANARY + MODEL_FILES_URL_WITH_TEAM; + public static final String MODEL_ARTIFACTS_URL_UNKNOWN_REGISTRY_1 = + "https://not-exists" + MODEL_FILES_URL_WITH_TEAM; + public static final String MODEL_ARTIFACTS_URL_MISSING_PROTOCOL_1 = + TEST_ARTIFACT_REGISTRY + MODEL_FILES_URL_WITH_TEAM; + public static final String MODEL_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1 = + "https://not.support.com" + MODEL_FILES_URL_WITH_TEAM; + public static final String MODEL_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1 = + BASE_ARTIFACT_URL + MODEL_FILE_PERMISSION_DENIED_URL; + public static final String MODEL_ARTIFACTS_URL_NOT_EXISTS_1 = + BASE_ARTIFACT_URL + MODEL_FILES_NOT_EXIST_URL; + + + public static final String RESOURCE_ARTIFACTS_URL = + BASE_ARTIFACT_URL + RESOURCE_FILES_URL_WITH_TEAM_2; + public static final String RESOURCE_ARTIFACTS_URL_2 = + BASE_ARTIFACT_URL + RESOURCE_FILES_URL_WITH_TEAM; + public static final String RESOURCE_ARTIFACTS_URL_3 = + BASE_ARTIFACT_URL + RESOURCE_FILES_URL; + public static final String RESOURCE_ARTIFACTS_URL_4 = + BASE_ARTIFACT_URL + RESOURCE_FILES_URL_WITH_VERSION; + public static final String RESOURCE_ARTIFACTS_URL_WITH_CANARY_HOST_1 = + BASE_ARTIFACT_URL + RESOURCE_FILES_URL_WITH_TEAM_2; + public static final String RESOURCE_ARTIFACTS_URL_UNKNOWN_REGISTRY_1 = + "https://not-exists" + RESOURCE_FILES_URL_WITH_TEAM_2; + public static final String RESOURCE_ARTIFACTS_URL_MISSING_PROTOCOL_1 = + TEST_ARTIFACT_REGISTRY + RESOURCE_FILES_URL_WITH_TEAM_2; + public static final String RESOURCE_ARTIFACTS_URL_NOT_SUPPORTED_REGISTRY_1 = + "https://not.support.com" + RESOURCE_FILES_URL_WITH_TEAM_2; + public static final String RESOURCE_ARTIFACTS_URL_PERMISSION_DENIED_REGISTRY_1 = + BASE_ARTIFACT_URL + RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; + public static final String RESOURCE_ARTIFACTS_URL_NOT_EXISTS_1 = + BASE_ARTIFACT_URL + RESOURCE_FILE_NOT_EXISTS_URL_WITH_TEAM; + + public static final Set TEST_MODELS = + Set.of(ModelUdt.builder() + .name("model-1").version("1.0") + .url(MODEL_ARTIFACTS_URL) + .build(), + ModelUdt.builder() + .name("model-2").version("2.0") + .url(MODEL_ARTIFACTS_URL_2) + .build()); + + public static final Set TEST_MODELS_2 = + Set.of(ModelUdt.builder() + .name("model-1").version("1.0") + .url(MODEL_ARTIFACTS_URL_2) + .build(), + ModelUdt.builder() + .name("model-2").version("2.0") + .url(MODEL_ARTIFACTS_URL) + .build()); + + public static final Set TEST_RESOURCES = + Set.of(ResourceUdt.builder() + .name("resource-1").version("1.0") + .url(RESOURCE_ARTIFACTS_URL) + .build(), + ResourceUdt.builder() + .name("resource-2").version("2.0") + .url(RESOURCE_ARTIFACTS_URL_2) + .build()); + + public static final Set TEST_MODEL_DTOS = + Set.of(ArtifactDto.builder() + .name("model-1").version("1.0") + .uri(URI.create(MODEL_ARTIFACTS_URL)) + .build(), + ArtifactDto.builder() + .name("model-2").version("2.0") + .uri(URI.create(MODEL_ARTIFACTS_URL_2)) + .build()); + + public static final Set TEST_RESOURCE_DTOS = + Set.of(ArtifactDto.builder() + .name("resource-1").version("1.0") + .uri(URI.create(RESOURCE_ARTIFACTS_URL)) + .build(), + ArtifactDto.builder() + .name("resource-2").version("2.0") + .uri(URI.create(RESOURCE_ARTIFACTS_URL_2)) + .build()); + + public static final List TEST_CONTAINER_ENVIRONMENT = + List.of(ContainerEnvironmentEntryDto.builder().key("KEY_1").value("VALUE_1").build(), + ContainerEnvironmentEntryDto.builder().key("KEY_2").value("VALUE_2").build(), + ContainerEnvironmentEntryDto.builder().key("KEY_3").value("VALUE_3").build()); + public static final Key MD_KEY_AUTHORIZATION = Key.of("authorization", + Metadata.ASCII_STRING_MARSHALLER); + + public static final Set TEST_TAGS = Set.of("tag1", "tag2", "tag3"); + public static final String TEST_DESCRIPTION = "test-description"; + + private static final JsonNode JSON_SECRET_VALUE = OBJECT_MAPPER.createObjectNode() + .put("AWS_REGION", "us-west-2") + .put("AWS_BUCKET", "ov-content") + .put("AWS_ACCESS_KEY_ID", "ov-content-key-id") + .put("AWS_SECRET_ACCESS_KEY", "ov-content-access-key") + .put("AWS_SESSION_TOKEN", "ov-content-session-token"); + public static final Set TEST_SECRETS = Set.of(SecretDto.builder() + .name(NGC_API_KEY) + .value(new StringNode( + "shhh!shhh!")) + .build(), + SecretDto.builder() + .name("secret2") + .value(JSON_SECRET_VALUE) + .build()); + + // Telemetry UUIDs are referred in + // src/test/java/resources/fixtures/nvcf/account-with-telemetries-response.json file. + public static final UUID TEST_TELEMETRY_LOGS_ID = + UUID.fromString("4df8cb5b-94e0-4fcc-ac12-ddb8da2f25aa"); + public static final UUID TEST_TELEMETRY_METRICS_ID = + UUID.fromString("d49695b0-86b1-4a84-9b82-2ea823f51d78"); + public static final UUID TEST_TELEMETRY_TRACES_ID = + UUID.fromString("edc4cd0a-80cd-4a7b-90d9-706b9eae9b4c"); + public static final String TEST_TELEMETRY_ENDPOINT = + "http://example-telemetry.test.com/endpoint"; + public static final String BASE64_CONTAINER_REGISTRY_CRED = + Base64.getEncoder() + .encodeToString("$oauthtoken:nvapi-stg-test-container-registry-cred" + .getBytes(UTF_8)); + public static final String BASE64_HELM_REGISTRY_CRED = + Base64.getEncoder() + .encodeToString("$oauthtoken:nvapi-stg-test-helm-registry-cred" + .getBytes(UTF_8)); + public static final String BASE64_MODEL_REGISTRY_CRED = + Base64.getEncoder() + .encodeToString("$oauthtoken:nvapi-stg-test-model-registry-cred" + .getBytes(UTF_8)); + public static final String BASE64_SIDECAR_REGISTRY_CRED = Base64.getEncoder().encodeToString( + "$oauthtoken:nvapi-stg-test-sidecar-cred".getBytes( + StandardCharsets.UTF_8)); + public static final RegistryCredentialDto TEST_NGC_CONTAINER_REGISTRY = + RegistryCredentialDto.builder() + .artifactTypes(Set.of(ArtifactTypeEnum.CONTAINER)) + .registryHostname(TEST_CONTAINER_REGISTRY) + .secret(SecretDto.builder() + .name("container-registry-cred-for-org1") + .value(new StringNode(BASE64_CONTAINER_REGISTRY_CRED)) + .build()) + .build(); + public static final RegistryCredentialDto TEST_NGC_MODEL_REGISTRY = + RegistryCredentialDto.builder() + .artifactTypes(Set.of(ArtifactTypeEnum.MODEL)) + .registryHostname(TEST_ARTIFACT_REGISTRY) + .secret(SecretDto.builder() + .name("model-registry-cred-for-org1") + .value(new StringNode(BASE64_MODEL_REGISTRY_CRED)) + .build()) + .build(); + public static final RegistryCredentialDto + TEST_NGC_HELM_REGISTRY = RegistryCredentialDto.builder() + .artifactTypes(Set.of(ArtifactTypeEnum.HELM)) + .registryHostname(TEST_HELM_REGISTRY) + .secret(SecretDto.builder() + .name("helm-registry-cred-for-org1") + .value(new StringNode(BASE64_HELM_REGISTRY_CRED)) + .build()) + .build(); + public static final String BASE64_ENCODED_DOCKER_CRED = Base64.getEncoder() + .encodeToString("username:docker-pat-password".getBytes(UTF_8)); + public static final RegistryCredentialDto + TEST_DOCKER_CONTAINER_REGISTRY = RegistryCredentialDto.builder() + .artifactTypes(Set.of(ArtifactTypeEnum.CONTAINER)) + .registryHostname("docker.io") + .secret(SecretDto.builder() + .name("cred-for-docker-acct-foo") + .value(new StringNode(BASE64_ENCODED_DOCKER_CRED)) + .build()) + .build(); + public static final String BASE64_ENCODED_ECR_PRIVATE_CRED = Base64.getEncoder() + .encodeToString("access-key-id-1:secret-access-key-1".getBytes(UTF_8)); + public static final String BASE64_ENCODED_ECR_PUBLIC_CRED = Base64.getEncoder() + .encodeToString("access-key-id-1:secret-access-key-1".getBytes(UTF_8)); + public static final String BASE64_ENCODED_VOLCENGINE_CRED = Base64.getEncoder() + .encodeToString("volcengine-access-key-1:volcengine-secret-key-1".getBytes(UTF_8)); + public static final String BASE64_ENCODED_ACR_CRED = Base64.getEncoder() + .encodeToString("acr-client-id-1:acr-client-secret-1".getBytes(UTF_8)); + public static final String BASE64_ENCODED_HARBOR_CRED = Base64.getEncoder() + .encodeToString("harbor-robot-account:harbor-robot-password".getBytes(UTF_8)); + public static final String BASE64_ENCODED_ARTIFACTORY_CRED = Base64.getEncoder() + .encodeToString("artifactory-username:artifactory-password".getBytes(UTF_8)); + + public static final String TEST_CUSTOM_REGISTRY_NAME_1 = "custom-1"; + public static final String TEST_CUSTOM_REGISTRY_HOST_NAME_1 = "custom-registry-test-1.com"; + public static final URI TEST_CUSTOM_CONTAINER_IMAGE_WITH_TAG_1 = + URI.create(TEST_CUSTOM_REGISTRY_HOST_NAME_1 + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_CUSTOM_HELM_CHART_WITH_TAG_1 = + URI.create("oci://" + TEST_CUSTOM_REGISTRY_HOST_NAME_1 + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, TEST_VALID_OCI_HELM_CHART_TAG_NAME)); +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestRegistryService.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestRegistryService.java new file mode 100644 index 000000000..c9c88e181 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestRegistryService.java @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_REGISTRY; +import static com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient.AZURE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PUBLIC_REGISTRY_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_NAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.VOLCENGINE_REGISTRY_NAME; +import static com.nvidia.nvct.util.TestConstants.BASE_ARTIFACT_URL; +import static com.nvidia.nvct.util.TestConstants.TEST_ARTIFACT_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_DOCKER_REGISTRY; +import static com.nvidia.nvct.util.TestConstants.TEST_HELM_REGISTRY; + +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistryService; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistryService; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class TestRegistryService { + + private final ModelRegistryService modelRegistryService; + private final ResourceRegistryService resourceRegistryService; + private final HelmRegistryService helmRegistryService; + private final ContainerRegistryService containerRegistryService; + private final RegistryLookupService registryLookupService; + private final RegistryMapperService registryMapperService; + + public TestRegistryService( + ModelRegistryService modelRegistryService, + ResourceRegistryService resourceRegistryService, + HelmRegistryService helmRegistryService, + ContainerRegistryService containerRegistryService, + RegistryLookupService registryLookupService, + RegistryMapperService registryMapperService, + @Value("${nvct.registries.recognized.container.ngc.hostname}") + String ngcContainerRegistryHostname, + @Value("${nvct.registries.recognized.model.ngc.hostname}") + String casBaseUrl, + @Value("${nvct.registries.recognized.container.docker.hostname}") + String dockerBaseUrl, + @Value("${nvct.registries.recognized.container.ecr.hostname}") + String ecrPrivateHostname, + @Value("${nvct.registries.recognized.container.ecr-public.hostname}") + String ecrPublicHostname, + @Value("${nvct.registries.recognized.container.volcengine.hostname}") + String volcengineHostname, + @Value("${nvct.registries.recognized.container.acr.hostname}") + String acrHostname, + @Value("${nvct.registries.recognized.container.harbor.hostname}") + String harborHostname, + @Value("${nvct.registries.recognized.container.artifactory.hostname}") + String artifactoryHostname) { + this.modelRegistryService = modelRegistryService; + this.resourceRegistryService = resourceRegistryService; + this.helmRegistryService = helmRegistryService; + this.containerRegistryService = containerRegistryService; + this.registryLookupService = registryLookupService; + this.registryMapperService = registryMapperService; + + updateNgcRegistryHostname(ngcContainerRegistryHostname, casBaseUrl); + updateDockerRegistryHostname(dockerBaseUrl); + updateEcrPrivateRegistryHostname(ecrPrivateHostname); + updateEcrPublicRegistryHostname(ecrPublicHostname); + updateVolcengineRegistryHostname(volcengineHostname); + updateAcrRegistryHostname(acrHostname); + updateHarborRegistryHostname(harborHostname); + updateArtifactoryRegistryHostname(artifactoryHostname); + } + + private void updateNgcRegistryHostname(String ngcContainerRegistryHostname, String casBaseUrl) { + modelRegistryService.overwriteRegistryHostnameMap( + URI.create(casBaseUrl).getHost(), + URI.create(BASE_ARTIFACT_URL).getHost()); + resourceRegistryService.overwriteRegistryHostnameMap( + URI.create(casBaseUrl).getHost(), + URI.create(BASE_ARTIFACT_URL).getHost()); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(casBaseUrl).getHost(), TEST_HELM_REGISTRY); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(ngcContainerRegistryHostname).getHost(), TEST_CONTAINER_REGISTRY); + registryLookupService.updateContainerRegistryConfigMap( + ngcContainerRegistryHostname, TEST_CONTAINER_REGISTRY); + registryLookupService.updateContainerRegistryMap( + NGC_PRIVATE_REGISTRY_NAME, TEST_CONTAINER_REGISTRY); + registryLookupService.updateHelmRegistryConfigMap( + casBaseUrl, TEST_HELM_REGISTRY); + registryLookupService.updateHelmRegistryMap( + NGC_PRIVATE_REGISTRY_NAME, TEST_HELM_REGISTRY); + registryLookupService.updateModelRegistryConfigMap( + casBaseUrl, TEST_ARTIFACT_REGISTRY); + registryLookupService.updateModelRegistryMap( + NGC_PRIVATE_REGISTRY_NAME, TEST_ARTIFACT_REGISTRY); + registryLookupService.updateResourceRegistryConfigMap( + casBaseUrl, TEST_ARTIFACT_REGISTRY); + registryLookupService.updateResourceRegistryMap( + NGC_PRIVATE_REGISTRY_NAME, TEST_ARTIFACT_REGISTRY); + registryMapperService.updateNgcContainerRegistryHostname(TEST_CONTAINER_REGISTRY); + registryMapperService.updateNgcArtifactRegistryHostname(TEST_ARTIFACT_REGISTRY); + registryMapperService.updateNgcHelmRegistryHostname(TEST_HELM_REGISTRY); + } + + private void updateDockerRegistryHostname(String dockerBaseUrl) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(dockerBaseUrl).getHost(), TEST_DOCKER_REGISTRY); + registryLookupService.updateContainerRegistryConfigMap( + dockerBaseUrl, TEST_DOCKER_REGISTRY); + registryLookupService.updateContainerRegistryMap( + DOCKER_REGISTRY_NAME, TEST_DOCKER_REGISTRY); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(dockerBaseUrl).getHost(), TEST_DOCKER_REGISTRY); + registryLookupService.updateHelmRegistryConfigMap( + dockerBaseUrl, TEST_DOCKER_REGISTRY); + registryLookupService.updateHelmRegistryMap( + DOCKER_REGISTRY_NAME, TEST_DOCKER_REGISTRY); + } + + private void updateEcrPrivateRegistryHostname(String ecrPrivateHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(ecrPrivateHostname).getHost(), ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryConfigMap( + ecrPrivateHostname, ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryMap( + ECR_PRIVATE_REGISTRY_NAME, ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(ecrPrivateHostname).getHost(), ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryConfigMap( + ecrPrivateHostname, ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryMap( + ECR_PRIVATE_REGISTRY_NAME, ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + } + + private void updateEcrPublicRegistryHostname(String ecrPublicHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(ecrPublicHostname).getHost(), ECR_PUBLIC_REGISTRY_HOSTNAME); + registryLookupService.updateContainerRegistryConfigMap( + ecrPublicHostname, ECR_PUBLIC_REGISTRY_HOSTNAME); + registryLookupService.updateContainerRegistryMap( + ECR_PUBLIC_REGISTRY_NAME, ECR_PUBLIC_REGISTRY_HOSTNAME); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(ecrPublicHostname).getHost(), ECR_PUBLIC_REGISTRY_HOSTNAME); + registryLookupService.updateHelmRegistryConfigMap( + ecrPublicHostname, ECR_PUBLIC_REGISTRY_HOSTNAME); + registryLookupService.updateHelmRegistryMap( + ECR_PUBLIC_REGISTRY_NAME, ECR_PUBLIC_REGISTRY_HOSTNAME); + } + + private void updateVolcengineRegistryHostname(String volcengineHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(volcengineHostname).getHost(), VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryConfigMap( + volcengineHostname, VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryMap( + VOLCENGINE_REGISTRY_NAME, VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(volcengineHostname).getHost(), VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryConfigMap( + volcengineHostname, VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryMap( + VOLCENGINE_REGISTRY_NAME, VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + } + + private void updateAcrRegistryHostname(String acrHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(acrHostname).getHost(), AZURE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryConfigMap( + acrHostname, AZURE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateContainerRegistryMap( + ACR_REGISTRY_NAME, AZURE_REGISTRY_GLOBAL_HOSTNAME); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(acrHostname).getHost(), AZURE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryConfigMap( + acrHostname, AZURE_REGISTRY_GLOBAL_HOSTNAME); + registryLookupService.updateHelmRegistryMap( + ACR_REGISTRY_NAME, AZURE_REGISTRY_GLOBAL_HOSTNAME); + } + + private void updateHarborRegistryHostname(String harborHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(harborHostname).getHost(), TEST_HARBOR_REGISTRY); + registryLookupService.updateContainerRegistryConfigMap( + harborHostname, TEST_HARBOR_REGISTRY); + registryLookupService.updateContainerRegistryMap( + HARBOR_REGISTRY_NAME, TEST_HARBOR_REGISTRY); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(harborHostname).getHost(), TEST_HARBOR_REGISTRY); + registryLookupService.updateHelmRegistryConfigMap( + harborHostname, TEST_HARBOR_REGISTRY); + registryLookupService.updateHelmRegistryMap( + HARBOR_REGISTRY_NAME, TEST_HARBOR_REGISTRY); + } + + private void updateArtifactoryRegistryHostname(String artifactoryHostname) { + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(artifactoryHostname).getHost(), TEST_ARTIFACTORY_REGISTRY); + registryLookupService.updateContainerRegistryConfigMap( + artifactoryHostname, TEST_ARTIFACTORY_REGISTRY); + registryLookupService.updateContainerRegistryMap( + ARTIFACTORY_REGISTRY_NAME, TEST_ARTIFACTORY_REGISTRY); + + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(artifactoryHostname).getHost(), TEST_ARTIFACTORY_REGISTRY); + registryLookupService.updateHelmRegistryConfigMap( + artifactoryHostname, TEST_ARTIFACTORY_REGISTRY); + registryLookupService.updateHelmRegistryMap( + ARTIFACTORY_REGISTRY_NAME, TEST_ARTIFACTORY_REGISTRY); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestUtil.java b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestUtil.java new file mode 100644 index 000000000..117bafd04 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/java/com/nvidia/nvct/util/TestUtil.java @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct.util; + +import static com.nvidia.nvct.rest.task.dto.ValidationPolicyNameEnum.UNRESTRICTED; +import static com.nvidia.nvct.util.NvctConstants.SCOPE_LIST_TASKS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ARGS; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_ENVIRONMENT; +import static com.nvidia.nvct.util.TestConstants.TEST_CONTAINER_IMAGE; +import static com.nvidia.nvct.util.TestConstants.TEST_MODELS; +import static com.nvidia.nvct.util.TestConstants.TEST_RESOURCES; +import static com.nvidia.nvct.util.TestConstants.TEST_RESULTS_LOCATION_1; + +import com.nimbusds.jwt.JWTParser; +import com.nvidia.nvct.persistence.task.entity.GpuSpecUdt; +import com.nvidia.nvct.persistence.task.entity.ResultHandlingStrategy; +import com.nvidia.nvct.persistence.task.entity.TaskEntity; +import com.nvidia.nvct.persistence.task.entity.TaskStatus; +import com.nvidia.nvct.rest.task.dto.HealthDto; +import com.nvidia.nvct.rest.task.dto.HelmValidationPolicyDto; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.ClassPathResource; +import org.testcontainers.containers.ContainerState; +import tools.jackson.databind.json.JsonMapper; + +public class TestUtil { + + @SneakyThrows + public static byte[] readFileAsBytes(String pathToFile) { + return new ClassPathResource(pathToFile).getContentAsByteArray(); + } + + @SneakyThrows + public static String readFileAsString(String pathToFile) { + return new String(readFileAsBytes(pathToFile)); + } + + public static String getContainerUrl(ContainerState containerState, int port) { + return "http://" + containerState.getHost() + ":" + containerState.getMappedPort(port); + } + + + @SneakyThrows + public static boolean isListTasksScopeInToken(String token) { + if (StringUtils.isBlank(token) || token.startsWith("nvapi")) { + return false; + } + + var jwt = JWTParser.parse(token); + var scopes = (List) jwt.getJWTClaimsSet().getClaim("scopes"); + return scopes.stream().anyMatch(scope -> scope.endsWith(SCOPE_LIST_TASKS)); + } + + public static String getToken(Object tokenSupplier) { + if (tokenSupplier instanceof Supplier) { + return (String) ((Supplier) tokenSupplier).get(); + } + return (String) tokenSupplier; + } + + @SneakyThrows + public static TaskEntity createContainerBasedTaskEntity( + UUID id, + String ncaId, + String name, + TaskStatus status, + JsonMapper jsonMapper) { + if (status == null) { + status = TaskStatus.QUEUED; + } + return TaskEntity.builder() + .taskId(id) + .ncaId(ncaId) + .name(name) + .status(status) + .percentComplete(80) + .health(testHealth()) + .createdAt(Instant.now()) + .lastUpdatedAt(Instant.now().plusSeconds(5)) + .lastHeartbeatAt(Instant.now().plusSeconds(10)) + .description("task-description") + .tags(Set.of("tag1", "tag2")) + .containerArgs(TEST_CONTAINER_ARGS) + .containerImage(TEST_CONTAINER_IMAGE.toString()) + .containerEnvironment( + Base64.getEncoder().encodeToString( + jsonMapper.writeValueAsBytes(TEST_CONTAINER_ENVIRONMENT))) + .models(TEST_MODELS) + .resources(TEST_RESOURCES) + .maxRuntimeDuration(Duration.parse("PT7H")) + .maxQueuedDuration(Duration.parse("PT24H")) + .terminalGracePeriodDuration(Duration.parse("PT1H")) + .gpuSpec(GpuSpecUdt.builder() + .backend("GFN").gpu("T10").instanceType("g6.full") + .clusters(Set.of("cluster01", "cluster02")) + .helmValidationPolicy( + jsonMapper.writeValueAsString(buildHelmValidationPolicyDto())) + .build()) + .resultHandlingStrategy(ResultHandlingStrategy.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .build(); + } + + public static TaskEntity createTaskEntity( + UUID id, + String ncaId, + String name, + JsonMapper jsonMapper) { + return createContainerBasedTaskEntity(id, ncaId, name, null, jsonMapper); + } + + @SneakyThrows + public static TaskEntity createHelmBasedTaskEntity( + UUID id, + String ncaId, + String name, + JsonMapper jsonMapper) { + return TaskEntity.builder() + .taskId(id) + .ncaId(ncaId) + .name(name) + .status(TaskStatus.QUEUED) + .percentComplete(80) + .health(testHealth()) + .createdAt(Instant.now()) + .lastUpdatedAt(Instant.now().plusSeconds(5)) + .lastHeartbeatAt(Instant.now().plusSeconds(10)) + .description("task-description") + .tags(Set.of("tag1", "tag2")) + .helmChart(TestConstants.TEST_HELM_CHART.toString()) + .containerEnvironment( + Base64.getEncoder().encodeToString( + jsonMapper.writeValueAsBytes(TEST_CONTAINER_ENVIRONMENT))) + .models(TEST_MODELS) + .resources(TEST_RESOURCES) + .maxRuntimeDuration(Duration.parse("PT7H")) + .maxQueuedDuration(Duration.parse("PT24H")) + .terminalGracePeriodDuration(Duration.parse("PT1H")) + .gpuSpec(GpuSpecUdt.builder() + .backend("GFN").gpu("T10").instanceType("g6.full") + .clusters(Set.of("cluster01", "cluster02")) + .helmValidationPolicy( + jsonMapper.writeValueAsString(buildHelmValidationPolicyDto())) + .build()) + .resultHandlingStrategy(ResultHandlingStrategy.UPLOAD) + .resultsLocation(TEST_RESULTS_LOCATION_1) + .build(); + } + + public static HelmValidationPolicyDto buildHelmValidationPolicyDto() { + return HelmValidationPolicyDto.builder() + .name(UNRESTRICTED) + .extraKubernetesTypes(List.of( + HelmValidationPolicyDto.KubernetesType.builder() + .group("apps").version("v1").kind("Deployment").build(), + HelmValidationPolicyDto.KubernetesType.builder() + .group("").version("v1").kind("Service").build())) + .build(); + } + + @SneakyThrows + private static String testHealth() { + return Optional.of(new JsonMapper().writeValueAsString(HealthDto.builder() + .backend("GFN") + .gpu("T10") + .instanceType("g6.full") + .error("error-message") + .build())) + .orElseThrow(); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/application-test.yaml b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/application-test.yaml new file mode 100644 index 000000000..54575a536 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/application-test.yaml @@ -0,0 +1,350 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring.main.allow-bean-definition-overriding: true + +# Faster JVM exit after integration tests (avoids Surefire fork exit timeout when many +# @SpringBootTest contexts shut down gRPC + Tomcat + file watchers sequentially). +server: + shutdown: immediate + +grpc: + server: + port: 0 # Random port to avoid BindException when running multiple integration tests + +management: + tracing: + export: + enabled: false + sampling: + probability: 1.0 + opentelemetry: + tracing: + export: + otlp: + endpoint: https://dummy:8282 + endpoints: + web: + base-path: /actuator + exposure: + include: health,metrics,prometheus + endpoint: + health: + cache: + time-to-live: 2s + probes: + enabled: true + show-details: always + group: + readiness: + include: + - readinessState + - cassandra + liveness: + include: + - livenessState + - cassandra + health: + livenessState: + enabled: true + readinessState: + enabled: true + vault: + enabled: false + kubernetes: + enabled: false + metrics: + enable: + all: true + tags: + env: ${spring.application.env} + micro_service: ${spring.application.name} + service_version: ${spring.application.version} + host_id: ${spring.application.host.id} + host_dc: ${spring.application.host.dc} + prometheus: + metrics: + export: + enabled: true + observations: + annotations: + enabled: true + server: + port: 8181 + +spring: + lifecycle: + timeout-per-shutdown-phase: 5s + cassandra: + contact-points: 127.0.0.1 + keyspace-name: nvct + username: ${kv.cassandra.username} + password: ${kv.cassandra.password} + request: + timeout: 30s + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:9092 + jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/.well-known/jwks.json + jws-algorithms: ES256 + client: + registration: + # API Keys - used for authorizing requests + api-keys: + provider: api-keys + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: pdp-evaluate + # ICMS(Instance and Cluster Management Service) - used for instance and cluster management + icms: + provider: icms + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: instance-request,cluster_listing,instance_types,cluster-management + # NVCF API - used for account setup + nvcf: + provider: nvcf + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: account_setup + # Notary Service - used for token assertions + notary: + provider: notary + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: make-assertion + # ESS (Enterprise Secret Service) - used for secrets management + ess: + provider: ess + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: ess:secrets-admin,ess:entities-admin + # Reval Service - used for Helm chart validation + reval: + provider: reval + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: helmreval:validate + provider: + api-keys: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + icms: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + nvcf: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + notary: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + ess: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + reval: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + +logging: + level: + root: WARN # To see [AUDIT] logs in stdout, this should be set to INFO + com.nvidia: DEBUG + com.nvidia.nvct.configuration: INFO + com.nvidia.nvct.util: INFO + # Default org.springframework.web: DEBUG would log full MethodArgumentNotValidException text + # (including multi‑KB rejected values) from ExceptionHandlerExceptionResolver. + org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: INFO + # RestTemplate at DEBUG logs full serialized objects (e.g. CreateTaskRequest with multi‑KB secrets). + org.springframework.web.client.RestTemplate: INFO + # HttpLogging at DEBUG logs request/response bodies (same risk as RestTemplate for tests using testRestTemplate). + org.springframework.web.HttpLogging: INFO + org.springframework.web: DEBUG + org.springframework.data.cassandra.core.cql.CqlTemplate: INFO + +nvct: + fqdn: http://localhost:${server.port} + global-fqdn-grpc: http://localhost:9090 + sidecars: + tracing-key: ${kv.worker-tracing} + hostname: fake.registry.test + image-pull-secret: ${kv.sidecars.image-pull-secret} + init-container: stg.nvcr.io/nv-cf/nvcf-core/nvcf_worker_init:0.7.0 + utils-container: stg.nvcr.io/nv-cf/nvcf-core/nvcf_worker_utils:2.2.1 + otel-container: docker.io/otel/opentelemetry-collector:0.74.0 + ess-agent-container: stg.nvcr.io/nv-cf/nvcf-core/ess-agent:0.0.4 + otel-collector-container: stg.nvcr.io/nv-cf/nvcf-core/byoo-otel-collector:1.2.3 + aws: + region: us-east-1 + ess: + base-url: http://localhost:9098 + notary: + audiences: + ess: dummy-ess-audience + nvct: dummy-nvct-audience + base-url: http://localhost:9097 + jwt: + issuer-uri: ${nvct.notary.base-url} + jwk-set-uri: ${nvct.notary.jwt.issuer-uri}/.well-known/jwks.json + nvcf: + base-url: http://localhost:9119 + cache-ttl: PT1S + reval: + base-url: http://localhost:9099 + icms: + base-url: http://localhost:9096 + allocator: + enabled: true + api-keys: + base-url: http://localhost:9093 + rate-limiters: + account-rate-limiter: + allowed-invocations-per-second: 200 + task-rate-limiter: + allowed-invocations-per-second: 200 + scheduled-routines: + enabled: true + lock-consistency: QUORUM + clean-terminal-tasks-routine: + enabled: false + monitor-launched-tasks-routine: + enabled: false + monitor-queued-tasks-routine: + enabled: false + monitor-worker-heartbeat-routine: + enabled: false + registries: + recognized: + container: + docker: + name: "Docker Hub Registry" + hostname: http://localhost-docker:9101 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9102 + group-scope: pull + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9100" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + ecr: + name: "AWS ECR Private Registry" + hostname: http://localhost-ecr:9103 + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: http://localhost-ecr-public:9104 + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: http://localhost-volcengine:9105 + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: http://localhost-acr:9106 + call-timeout: PT10S + oauth2: + base-url: http://localhost-acr:9107 + harbor: + name: "Harbor Registry" + hostname: http://localhost-harbor:9108 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9109 + artifactory: + name: "Artifactory Registry" + hostname: http://localhost-artifactory:9110 + call-timeout: PT10S + oauth2: + base-url: http://localhost-artifactory:9111 + custom-1: + name: "Custom Registry Test 1" + hostname: "custom-registry-test-1.com" + helm: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg + docker: + name: "Docker Hub Registry" + hostname: http://localhost-docker:9101 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9102 + group-scope: pull + ecr: + name: "AWS ECR Private Registry" + hostname: http://localhost-ecr:9103 + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: http://localhost-ecr-public:9104 + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: http://localhost-volcengine:9105 + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: http://localhost-acr:9106 + call-timeout: PT10S + oauth2: + base-url: http://localhost-acr:9107 + harbor: + name: "Harbor Registry" + hostname: http://localhost-harbor:9108 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9109 + artifactory: + name: "JFrog Artifactory" + hostname: http://localhost-artifactory:9110 + call-timeout: PT10S + oauth2: + base-url: http://localhost-artifactory:9111 + custom-1: + name: "Custom Registry Test 1" + hostname: "custom-registry-test-1.com" + model: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg + resource: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/bootstrap-test.yaml b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/bootstrap-test.yaml new file mode 100644 index 000000000..2b328cea2 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/bootstrap-test.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + application: + name: nvct-core-test + env: test + version: 0.0.1-test + host: + id: local-test-host + dc: local-test-id + cloud: + kubernetes: + config: + enabled: false + +nv-boot: + reloadable-properties: + file: local_env/vault/secrets.json diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/docker-java.properties b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/docker-java.properties new file mode 100644 index 000000000..e8b62f3b4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/docker-java.properties @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Pin Docker API version to avoid compatibility issues with testcontainers +# api.version=1.44 diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/complete-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/complete-response.json new file mode 100644 index 000000000..2516481cc --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/complete-response.json @@ -0,0 +1,87 @@ +{ + "clusterGroups": [ + { + "id": "a8bc8edc-e7ef-455b-90d7-10645841e234", + "name": "GFN", + "ncaId": "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "T10", + "instanceTypes": [ + { + "name": "g6.full", + "value": "g6.full", + "description": "One Nvidia Tensor Core GPU", + "default": true + } + ] + }, + { + "name": "L40G", + "instanceTypes": [ + { + "name": "gl40g_1.br25_2xlarge", + "value": "gl40g_1.br25_2xlarge", + "description": "One Nvidia Ada GPU", + "default": true + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-XVZkj_L81QojSxA_6M_ajWajcx56xZEtM2MUR_BAH8M", + "name": "STATIC-ZONE" + } + ] + }, + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + }, + { + "name": "L40G", + "instanceTypes": [ + { + "name": "BM_GPU_L40G-2X", + "value": "BM_GPU_L40G-2XL", + "description": "One Nvidia Ada GPU", + "default": true + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-cluster-group-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-cluster-group-response.json new file mode 100644 index 000000000..7418c85c8 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-cluster-group-response.json @@ -0,0 +1,37 @@ +{ + "clusterGroups": [ + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-gpus-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-gpus-response.json new file mode 100644 index 000000000..31d188952 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-gfn-gpus-response.json @@ -0,0 +1,52 @@ +{ + "clusterGroups": [ + { + "id": "a8bc8edc-e7ef-455b-90d7-10645841e234", + "name": "GFN", + "ncaId": "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo", + "authorizedNcaIds": [ + "*" + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-XVZkj_L81QojSxA_6M_ajWajcx56xZEtM2MUR_BAH8M", + "name": "STATIC-ZONE" + } + ] + }, + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-gpu-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-gpu-response.json new file mode 100644 index 000000000..6809bcab0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-gpu-response.json @@ -0,0 +1,65 @@ +{ + "clusterGroups": [ + { + "id": "a8bc8edc-e7ef-455b-90d7-10645841e234", + "name": "GFN", + "ncaId": "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A10G", + "instanceTypes": [ + { + "name": "ga10g_1.br20_2xlarge", + "value": "ga10g_1.br20_2xlarge", + "description": "One Nvidia Ampere GPU", + "default": true + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-XVZkj_L81QojSxA_6M_ajWajcx56xZEtM2MUR_BAH8M", + "name": "STATIC-ZONE" + } + ] + }, + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-type-default-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-type-default-response.json new file mode 100644 index 000000000..6ae8f5eac --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-type-default-response.json @@ -0,0 +1,64 @@ +{ + "clusterGroups": [ + { + "id": "a8bc8edc-e7ef-455b-90d7-10645841e234", + "name": "GFN", + "ncaId": "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "T10", + "instanceTypes": [ + { + "name": "g6.full", + "value": "g6.full", + "description": "One Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-XVZkj_L81QojSxA_6M_ajWajcx56xZEtM2MUR_BAH8M", + "name": "STATIC-ZONE" + } + ] + }, + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-types-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-types-response.json new file mode 100644 index 000000000..2da39f778 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/cluster-groups/missing-t10-instance-types-response.json @@ -0,0 +1,57 @@ +{ + "clusterGroups": [ + { + "id": "a8bc8edc-e7ef-455b-90d7-10645841e234", + "name": "GFN", + "ncaId": "jY_kY8LqzQRKL0J8pqC6avyTLyWGUrlQr4BC-SVYhbo", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "T10" + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-XVZkj_L81QojSxA_6M_ajWajcx56xZEtM2MUR_BAH8M", + "name": "STATIC-ZONE" + } + ] + }, + { + "id": "d6c45d29-2094-4e45-a0f9-f39587da2ecc", + "name": "OCI", + "ncaId": "zK0fuqHAgHZtM8zbcSAlWeOgN3KE2PO3wI6jtjidFhw", + "authorizedNcaIds": [ + "*" + ], + "gpus": [ + { + "name": "A100_80GB", + "instanceTypes": [ + { + "name": "BM.GPU.A100-v2.8", + "value": "BM.GPU.A100-v2.8", + "description": "One Nvidia Tensor Core GPU", + "default": true + }, + { + "name": "BM.GPU.A100-v2.8_8x", + "value": "BM.GPU.A100-v2.8", + "description": "Eight Nvidia Tensor Core GPU" + } + ] + } + ], + "clusters": [ + { + "k8sVersion": "v1.25.9", + "id": "nvssa-stg-76wrulCe8IWNEOrc2w8MSsJSJme4xoVRUxO-Xck3pqw", + "name": "OCI-STATIC-REGION" + } + ] + } + ] +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/clusters/complete-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/clusters/complete-response.json new file mode 100644 index 000000000..8dab85e75 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/clusters/complete-response.json @@ -0,0 +1,1146 @@ +[ + { + "clusterName": "dgxc-k8saas-forge-az24-ct1", + "clusterGroupName": "dgxc-k8saas-forge-az24-ct1", + "clusterDescription": "", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "DGX-CLOUD", + "region": "eu-west-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [ + "KataRuntimeIsolation" + ], + "customAttributes": [], + "gpus": [ + { + "name": "AD102GL", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "1073741824M", + "gpuCount": 2, + "name": "DGX-CLOUD.GPU.AD102GL_2x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "24.5Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "4294967296M", + "gpuCount": 8, + "name": "DGX-CLOUD.GPU.AD102GL_8x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "97.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "2147483648M", + "gpuCount": 4, + "name": "DGX-CLOUD.GPU.AD102GL_4x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "48.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "536870912M", + "gpuCount": 1, + "name": "DGX-CLOUD.GPU.AD102GL_1x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "12.2Gi", + "default": true + } + ] + } + ], + "nvcaVersion": "2.37.0", + "ssaClientId": "nvssa-stg-dS7iY1dsI_5qXequTVZL0Zpij-xyFILuxPHs5d7ImdA", + "clusterId": "5942e544-11f9-3d89-9882-a31b959ae54e", + "clusterGroupId": "d6b30d7f-110a-455f-9d37-e7a3d9bfb44a", + "status": "READY", + "nvcaLastConnected": "2024-10-29T18:34:42.413Z", + "k8sVersion": "v1.29.5", + "gpuUsage": null, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "dgxc-k8saas-forge-az24-ct1_unhealthy", + "clusterGroupName": "dgxc-k8saas-forge-az24-ct1_unhealthy", + "clusterDescription": "", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "DGX-CLOUD", + "region": "eu-west-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [ + "KataRuntimeIsolation" + ], + "customAttributes": [], + "gpus": [ + { + "name": "AD102GL", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "1073741824M", + "gpuCount": 2, + "name": "DGX-CLOUD.GPU.AD102GL_2x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "24.5Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "4294967296M", + "gpuCount": 8, + "name": "DGX-CLOUD.GPU.AD102GL_8x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "97.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "2147483648M", + "gpuCount": 4, + "name": "DGX-CLOUD.GPU.AD102GL_4x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "48.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451716Ki", + "gpuMemory": "536870912M", + "gpuCount": 1, + "name": "DGX-CLOUD.GPU.AD102GL_1x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "12.2Gi", + "default": true + } + ] + } + ], + "nvcaVersion": "2.37.0", + "ssaClientId": "nvssa-stg-dS7iY1dsI_5qXequTVZL0Zpij-xyFILuxPHs5d7ImdA", + "clusterId": "5942e544-11f9-3d89-9882-a31b959ae64e", + "clusterGroupId": "d6b30d7f-110a-455f-9d37-e7a3d9bfb74a", + "status": "UNHEALTHY", + "nvcaLastConnected": "2024-10-29T18:34:42.413Z", + "k8sVersion": "v1.29.5", + "gpuUsage": null, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "dgxc-k8saas-forge-dev2-az24", + "clusterGroupName": "dgxc-k8saas-forge-dev2-az24", + "clusterDescription": "dgxc-k8saas-forge-dev2-az24", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [ + "*" + ], + "cloudProvider": "DGX-CLOUD", + "region": "us-west-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [ + "KataRuntimeIsolation" + ], + "customAttributes": [], + "gpus": [ + { + "name": "AD102GL", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 128, + "systemMemory": "1056429496Ki", + "gpuMemory": "536870912M", + "gpuCount": 1, + "name": "DGX-CLOUD.GPU.AD102GL_1x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "879.8Gi", + "default": true + }, + { + "cpuCores": 128, + "systemMemory": "1056429496Ki", + "gpuMemory": "4294967296M", + "gpuCount": 8, + "name": "DGX-CLOUD.GPU.AD102GL_8x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "6.9Ti", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056429496Ki", + "gpuMemory": "1073741824M", + "gpuCount": 2, + "name": "DGX-CLOUD.GPU.AD102GL_2x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "1.7Ti", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056429496Ki", + "gpuMemory": "2147483648M", + "gpuCount": 4, + "name": "DGX-CLOUD.GPU.AD102GL_4x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "3.4Ti", + "default": null + } + ] + } + ], + "nvcaVersion": "2.38.1", + "ssaClientId": "nvssa-stg-UQAYrnW3GDxCOdpk8TCuJO_pbdsufZn9ZRrWqW1gN00", + "clusterId": "fed25eef-ef38-30f5-8059-9872f31b71ab", + "clusterGroupId": "607d4661-2935-4c8f-958e-b25285e787df", + "status": "READY", + "nvcaLastConnected": "2024-11-25T21:49:56.379Z", + "k8sVersion": "v1.29.5", + "gpuUsage": { + "AD102GL": { + "capacity": 16, + "allocated": 0, + "available": 16 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "mcamp-test-1", + "clusterGroupName": "mcamp-test-1", + "clusterDescription": "mcamp-test-1", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [ + "foo-bar" + ], + "cloudProvider": "ON-PREM", + "region": "us-west-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [], + "nvcaVersion": "2.31.9", + "clusterId": "a2325818-2596-3ee6-9dc1-220740871fd1", + "clusterGroupId": "8f931aa1-5531-4f50-af9e-9aae20ee72c3", + "status": "NOT_READY", + "nvcaLastConnected": null, + "k8sVersion": null, + "gpuUsage": null, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-aws-use1-dev1", + "clusterGroupName": "nvcf-dgxc-k8s-aws-use1-dev1", + "clusterDescription": "nvcf-dgxc-k8s-aws-use1-dev1", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "AWS", + "region": "us-east-1", + "capabilities": [ + "CachingSupport", + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "H100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 192, + "systemMemory": "2097112576Ki", + "gpuMemory": "652472M", + "gpuCount": 8, + "name": "AWS.GPU.H100_8x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a p5.48xlarge machine", + "value": "AWS.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "484.4Gi", + "default": null + }, + { + "cpuCores": 192, + "systemMemory": "2097112576Ki", + "gpuMemory": "81559M", + "gpuCount": 1, + "name": "AWS.GPU.H100_1x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a p5.48xlarge machine", + "value": "AWS.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "60.6Gi", + "default": true + }, + { + "cpuCores": 192, + "systemMemory": "2097112576Ki", + "gpuMemory": "163118M", + "gpuCount": 2, + "name": "AWS.GPU.H100_2x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a p5.48xlarge machine", + "value": "AWS.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "121.1Gi", + "default": null + }, + { + "cpuCores": 192, + "systemMemory": "2097112576Ki", + "gpuMemory": "326236M", + "gpuCount": 4, + "name": "AWS.GPU.H100_4x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a p5.48xlarge machine", + "value": "AWS.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "242.2Gi", + "default": null + } + ] + } + ], + "nvcaVersion": "2.34.4", + "clusterId": "c8f1d4a1-c899-3531-ae93-ee9fdac60d03", + "clusterGroupId": "0051c6b3-cac6-486c-b4bd-50f9cfdd4fd1", + "status": "READY", + "nvcaLastConnected": "2024-11-20T01:50:31.330Z", + "k8sVersion": "v1.30.5-eks-ce1d5eb", + "gpuUsage": { + "H100": { + "capacity": 32, + "allocated": 1, + "available": 31 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-azr-scus-dev1", + "clusterGroupName": "nvcf-dgxc-k8s-azr-scus-dev1", + "clusterDescription": "nvcf-dgxc-k8s-azr-scus-dev1", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "AZURE", + "region": "us-east-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "A100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 96, + "systemMemory": "1857800200Ki", + "gpuMemory": "327680M", + "gpuCount": 4, + "name": "AZURE.GPU.A100_4x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "992.3Gi", + "default": null + }, + { + "cpuCores": 96, + "systemMemory": "1857800200Ki", + "gpuMemory": "81920M", + "gpuCount": 1, + "name": "AZURE.GPU.A100_1x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "248.1Gi", + "default": true + }, + { + "cpuCores": 96, + "systemMemory": "1857800200Ki", + "gpuMemory": "163840M", + "gpuCount": 2, + "name": "AZURE.GPU.A100_2x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "496.2Gi", + "default": null + }, + { + "cpuCores": 96, + "systemMemory": "1857800200Ki", + "gpuMemory": "655360M", + "gpuCount": 8, + "name": "AZURE.GPU.A100_8x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "1.9Ti", + "default": null + } + ] + } + ], + "nvcaVersion": "2.34.4", + "clusterId": "cfe4f376-db80-3f16-bbcc-61605903be63", + "clusterGroupId": "6c0fe500-20e0-4b9e-977e-2aa79af57b4a", + "status": "READY", + "nvcaLastConnected": "2024-11-20T01:50:16.144Z", + "k8sVersion": "v1.29.9", + "gpuUsage": { + "A100": { + "capacity": 32, + "allocated": 0, + "available": 32 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-forge-az24-dev5", + "clusterGroupName": "nvcf-dgxc-k8s-forge-az24-dev5", + "clusterDescription": "sensor rtx", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [ + "*" + ], + "cloudProvider": "DGX-CLOUD", + "region": "eu-west-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "L40", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 128, + "systemMemory": "1056451744Ki", + "gpuMemory": "98280M", + "gpuCount": 2, + "name": "DGX-CLOUD.GPU.L40_2x", + "description": "NVIDIA-L40 (ampere family) on a ThinkSystem-SR670-V2 machine", + "value": "DGX-CLOUD.GPU.L40", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.54.15", + "storage": "24.5Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451744Ki", + "gpuMemory": "393120M", + "gpuCount": 8, + "name": "DGX-CLOUD.GPU.L40_8x", + "description": "NVIDIA-L40 (ampere family) on a ThinkSystem-SR670-V2 machine", + "value": "DGX-CLOUD.GPU.L40", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.54.15", + "storage": "97.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451744Ki", + "gpuMemory": "196560M", + "gpuCount": 4, + "name": "DGX-CLOUD.GPU.L40_4x", + "description": "NVIDIA-L40 (ampere family) on a ThinkSystem-SR670-V2 machine", + "value": "DGX-CLOUD.GPU.L40", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.54.15", + "storage": "48.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451744Ki", + "gpuMemory": "49140M", + "gpuCount": 1, + "name": "DGX-CLOUD.GPU.L40_1x", + "description": "NVIDIA-L40 (ampere family) on a ThinkSystem-SR670-V2 machine", + "value": "DGX-CLOUD.GPU.L40", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.54.15", + "storage": "12.2Gi", + "default": true + } + ] + } + ], + "nvcaVersion": "2.37.0", + "clusterId": "a2934dc7-acea-3416-8e20-25e090dcecd4", + "clusterGroupId": "56f0a210-4394-44e2-95ea-10cd1c755db4", + "status": "UNHEALTHY", + "nvcaLastConnected": "2024-11-19T11:23:43.241Z", + "k8sVersion": "v1.29.7", + "gpuUsage": null, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-forge-az24-dev6", + "clusterGroupName": "nvcf-dgxc-k8s-forge-az24-dev6", + "clusterDescription": "nvcf-dgxc-k8s-forge-az24-dev6", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "DGX-CLOUD", + "region": "us-east-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [ + "KataRuntimeIsolation" + ], + "customAttributes": [], + "gpus": [ + { + "name": "AD102GL", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 128, + "systemMemory": "1056451708Ki", + "gpuMemory": "536870912M", + "gpuCount": 1, + "name": "DGX-CLOUD.GPU.AD102GL_1x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "12.2Gi", + "default": true + }, + { + "cpuCores": 128, + "systemMemory": "1056451708Ki", + "gpuMemory": "2147483648M", + "gpuCount": 4, + "name": "DGX-CLOUD.GPU.AD102GL_4x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "48.9Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451708Ki", + "gpuMemory": "1073741824M", + "gpuCount": 2, + "name": "DGX-CLOUD.GPU.AD102GL_2x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "24.5Gi", + "default": null + }, + { + "cpuCores": 128, + "systemMemory": "1056451708Ki", + "gpuMemory": "4294967296M", + "gpuCount": 8, + "name": "DGX-CLOUD.GPU.AD102GL_8x", + "description": "AD102GL-L40 (undefined family) on a ThinkSystem-SR670-V2 machine (Kata-enabled)", + "value": "DGX-CLOUD.GPU.AD102GL", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "unknown.unknown.unknown", + "storage": "97.9Gi", + "default": null + } + ] + } + ], + "nvcaVersion": "2.38.1", + "clusterId": "66c46ee9-e427-331b-ba38-168a7e0e1eb7", + "clusterGroupId": "4333d348-9d8e-4da1-ae71-c46b5780b312", + "status": "READY", + "nvcaLastConnected": "2024-11-25T22:20:00.291Z", + "k8sVersion": "v1.29.0", + "gpuUsage": { + "AD102GL": { + "capacity": 16, + "allocated": 1, + "available": 15 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-oci-iad-dev4", + "clusterGroupName": "nvcf-dgxc-k8s-oci-iad-dev4", + "clusterDescription": "", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "OCI", + "region": "us-east-1", + "capabilities": [ + "CachingSupport", + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "A100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 255, + "systemMemory": "2113521412Ki", + "gpuMemory": "81920M", + "gpuCount": 1, + "name": "OCI.GPU.A100_1x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "251.8Gi", + "default": true + }, + { + "cpuCores": 255, + "systemMemory": "2113521412Ki", + "gpuMemory": "655360M", + "gpuCount": 8, + "name": "OCI.GPU.A100_8x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "2.0Ti", + "default": null + }, + { + "cpuCores": 255, + "systemMemory": "2113521412Ki", + "gpuMemory": "327680M", + "gpuCount": 4, + "name": "OCI.GPU.A100_4x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "1007.4Gi", + "default": null + }, + { + "cpuCores": 255, + "systemMemory": "2113521412Ki", + "gpuMemory": "163840M", + "gpuCount": 2, + "name": "OCI.GPU.A100_2x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "503.7Gi", + "default": null + } + ] + } + ], + "nvcaVersion": "2.34.4", + "clusterId": "25138fbc-cb6e-36df-afca-cc40f5a474d6", + "clusterGroupId": "500f216a-9a2a-4211-bac6-7527f94deebf", + "status": "READY", + "nvcaLastConnected": "2024-11-20T01:55:10.676Z", + "k8sVersion": "v1.29.1", + "gpuUsage": { + "A100": { + "capacity": 32, + "allocated": 1, + "available": 31 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-dgxc-k8s-oci-ord-dev6", + "clusterGroupName": "nvcf-dgxc-k8s-oci-ord-dev6", + "clusterDescription": "nvcf-dgxc-k8s-oci-ord-dev6", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [], + "cloudProvider": "OCI", + "region": "us-east-1", + "capabilities": [ + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "A100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 255, + "systemMemory": "2113521420Ki", + "gpuMemory": "81920M", + "gpuCount": 1, + "name": "OCI.GPU.A100_1x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "251.8Gi", + "default": true + }, + { + "cpuCores": 255, + "systemMemory": "2113521420Ki", + "gpuMemory": "163840M", + "gpuCount": 2, + "name": "OCI.GPU.A100_2x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "503.7Gi", + "default": null + }, + { + "cpuCores": 255, + "systemMemory": "2113521420Ki", + "gpuMemory": "327680M", + "gpuCount": 4, + "name": "OCI.GPU.A100_4x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "1007.4Gi", + "default": null + }, + { + "cpuCores": 255, + "systemMemory": "2113521420Ki", + "gpuMemory": "655360M", + "gpuCount": 8, + "name": "OCI.GPU.A100_8x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a ORACLE-SERVER-E4-2c machine", + "value": "OCI.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.07", + "storage": "2.0Ti", + "default": null + } + ] + } + ], + "nvcaVersion": "2.34.4", + "clusterId": "cce4164d-a40c-3ff0-998a-0812e3eccf05", + "clusterGroupId": "f5e6b6bc-4adf-4276-ba67-d16234d9adff", + "status": "READY", + "nvcaLastConnected": "2024-11-20T01:51:03.418Z", + "k8sVersion": "v1.29.1", + "gpuUsage": { + "A100": { + "capacity": 8, + "allocated": 0, + "available": 8 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "nvcf-stage", + "clusterGroupName": "nvcf-stage", + "clusterDescription": "GCP Stage US Central", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [ + "*" + ], + "cloudProvider": "GCP", + "region": "us-east-1", + "capabilities": [ + "CachingSupport", + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "H100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 208, + "systemMemory": "1932077732Ki", + "gpuMemory": "163118M", + "gpuCount": 2, + "name": "GCP.GPU.H100_2x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a Google-Compute-Engine machine", + "value": "GCP.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.12", + "storage": "1.5Ti", + "default": null + }, + { + "cpuCores": 208, + "systemMemory": "1932077732Ki", + "gpuMemory": "326236M", + "gpuCount": 4, + "name": "GCP.GPU.H100_4x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a Google-Compute-Engine machine", + "value": "GCP.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.12", + "storage": "2.9Ti", + "default": null + }, + { + "cpuCores": 208, + "systemMemory": "1932077732Ki", + "gpuMemory": "652472M", + "gpuCount": 8, + "name": "GCP.GPU.H100_8x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a Google-Compute-Engine machine", + "value": "GCP.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.12", + "storage": "5.8Ti", + "default": null + }, + { + "cpuCores": 208, + "systemMemory": "1932077732Ki", + "gpuMemory": "81559M", + "gpuCount": 1, + "name": "GCP.GPU.H100_1x", + "description": "NVIDIA-H100-80GB-HBM3 (hopper family) on a Google-Compute-Engine machine", + "value": "GCP.GPU.H100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "550.90.12", + "storage": "743.7Gi", + "default": true + } + ] + } + ], + "nvcaVersion": "2.37.0", + "clusterId": "53566955-d7f9-37fb-a782-93560f577f3c", + "clusterGroupId": "98aec553-8b1e-4812-b568-35f5c5bbf01e", + "status": "UNHEALTHY", + "nvcaLastConnected": "2024-09-26T06:42:46.502Z", + "k8sVersion": "v1.29.7-gke.1104000", + "gpuUsage": null, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + }, + { + "clusterName": "picasso-edify-azure-stg", + "clusterGroupName": "AZURE", + "clusterDescription": "Stage cluster for edify Azure", + "ncaId": "CMYBKSNNjtg1TQmSke-gHNGgMlFvA-dCRAI8gcHOBcw", + "authorizedNCAIds": [ + "*" + ], + "cloudProvider": "AZURE", + "region": "us-west-1", + "capabilities": [ + "CachingSupport", + "DynamicGPUDiscovery", + "LogPosting" + ], + "attributes": [], + "customAttributes": [], + "gpus": [ + { + "name": "A100", + "capacity": 0, + "instanceTypes": [ + { + "cpuCores": 96, + "systemMemory": "1857800888Ki", + "gpuMemory": "81920M", + "gpuCount": 1, + "name": "AZURE.GPU.A100_1x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "535.54.03", + "storage": "248.1Gi", + "default": true + }, + { + "cpuCores": 96, + "systemMemory": "1857800888Ki", + "gpuMemory": "655360M", + "gpuCount": 8, + "name": "AZURE.GPU.A100_8x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "535.54.03", + "storage": "1.9Ti", + "default": null + }, + { + "cpuCores": 96, + "systemMemory": "1857800888Ki", + "gpuMemory": "163840M", + "gpuCount": 2, + "name": "AZURE.GPU.A100_2x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "535.54.03", + "storage": "496.2Gi", + "default": null + }, + { + "cpuCores": 96, + "systemMemory": "1857800888Ki", + "gpuMemory": "327680M", + "gpuCount": 4, + "name": "AZURE.GPU.A100_4x", + "description": "NVIDIA-A100-SXM4-80GB (ampere family) on a Virtual-Machine machine", + "value": "AZURE.GPU.A100", + "cpuArch": "amd64", + "os": "linux", + "driverVersion": "535.54.03", + "storage": "992.3Gi", + "default": null + } + ] + } + ], + "nvcaVersion": "2.38.1", + "clusterId": "nvssa-stg-oP6_PUxnMVhYP2IRiPR_rbekw6hcmRpSCslV2BDiFvo", + "clusterGroupId": "1e7fb06a-6a24-4087-a071-35cc603892b6", + "status": "READY", + "nvcaLastConnected": "2024-11-25T21:33:50.149Z", + "k8sVersion": "v1.27.9", + "gpuUsage": { + "A100": { + "capacity": 24, + "allocated": 7, + "available": 17 + } + }, + "envoyConfig": { + "image": "envoy", + "repository": "docker.io/envoyproxy", + "tag": "v1.26.7" + }, + "vaultConfig": { + "address": "https://stg.vault.nvidia.com:443" + }, + "clusterUpgradeStatus": null + } +] \ No newline at end of file diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-healthy-instance.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-healthy-instance.json new file mode 100644 index 000000000..219e44c6a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-healthy-instance.json @@ -0,0 +1,25 @@ +{ + "CreateTime": "2023-07-06T07:17:53.282Z", + "InstanceId": "%s", + "LaunchSpecification": { + "InstanceType": "%s", + "ContainerImage": "stg.nvcr.io/nv-cf/guineapig-1/ediffi:0.0.3", + "Placement": null, + "Gpu": "%s", + "Backend": "GFN", + "NcaId": "test-nca-id" + }, + "LaunchedAvailabilityZone": "NP-LAX-03", + "InstanceRequestId": "%s", + "CloudProvider": "GFN", + "State": "active", + "Status": { + "Code": "fulfilled", + "Message": "Your instance request is fulfilled", + "UpdateTime": "2023-07-06T07:57:06.731Z" + }, + "InstanceState": { + "Name": "running" + }, + "InstanceInterruptionBehavior": "terminate" +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-no-capacity-instance.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-no-capacity-instance.json new file mode 100644 index 000000000..684f0836a --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-no-capacity-instance.json @@ -0,0 +1,31 @@ +{ + "CreateTime": "2024-05-02T19:37:41.687Z", + "InstanceId": "%s", + "LaunchSpecification": { + "InstanceType": "%s", + "ContainerImage": "nvcr.io/whw3rcpsilnj/playground/maxine_vf_server_optimized:0.3", + "Placement": { + "AvailabilityZone": "NP-LAX-03" + }, + "Gpu": "%s", + "Backend": "GFN", + "NcaId": "NVVppkDl81nJZ0GtZORXNeeOoY8h1p1Tfd8vuIPFV18" + }, + "LaunchedAvailabilityZone": "NP-LAX-03", + "InstanceRequestId": "%s", + "CloudProvider": "GFN", + "State": "closed", + "Status": { + "Code": "instance-terminated-no-capacity", + "Message": "Instance status updated to instance-terminated-no-capacity", + "UpdateTime": "2024-05-03T17:23:15.492Z" + }, + "InstanceState": { + "Code": 48, + "Name": "terminated" + }, + "HealthInfo": { + "ErrorLog": "Instance terminated due to to capacity constraint" + }, + "InstanceInterruptionBehavior": "terminate" +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-unhealthy-instance.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-unhealthy-instance.json new file mode 100644 index 000000000..e29dabc18 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/icms/raw-unhealthy-instance.json @@ -0,0 +1,29 @@ +{ + "CreateTime": "2023-07-06T07:17:53.282Z", + "InstanceId": "%s", + "LaunchSpecification": { + "InstanceType": "%s", + "ContainerImage": "stg.nvcr.io/nv-cf/guineapig-1/ediffi:0.0.3", + "Placement": null, + "Gpu": "%s", + "Backend": "GFN", + "NcaId": "test-nca-id" + }, + "LaunchedAvailabilityZone": null, + "InstanceRequestId": "%s", + "CloudProvider": "GFN", + "State": "closed", + "Status": { + "Code": "instance-terminated-by-service", + "Message": "Your instance request has been terminated", + "UpdateTime": "2023-07-06T07:57:06.731Z" + }, + "InstanceState": { + "Code": 48, + "Name": "terminated" + }, + "HealthInfo": { + "ErrorLog": "%s: Inference container\n is failing\n to come up" + }, + "InstanceInterruptionBehavior": "terminate" +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-response.json new file mode 100644 index 000000000..a0542e233 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-response.json @@ -0,0 +1,151 @@ +{ + "account": { + "ncaId": "%s", + "clientIds": [ + "%s" + ], + "name": "%s", + "registryCredentials": [ + { + "registryHostname": "docker.io", + "secret": { + "name": "docker-container-registry-credential", + "value": "dXNlcm5hbWU6ZG9ja2VyLXBhdC1wYXNzd29yZA==" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "779846807323.dkr.ecr.us-west-2.amazonaws.com", + "secret": { + "name": "ecr-container-registry-credential", + "value": "YWNjZXNzLWtleS1pZC0xOnNlY3JldC1hY2Nlc3Mta2V5LTE=" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "public.ecr.aws", + "secret": { + "name": "ecr-container-registry-credential", + "value": "YWNjZXNzLWtleS1pZC0xOnNlY3JldC1hY2Nlc3Mta2V5LTE=" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "test-volcengine-registry-cn-beijing.cr.volces.com", + "secret": { + "name": "ve-registry-credential", + "value": "dm9sY2VuZ2luZS1hY2Nlc3Mta2V5LTE6dm9sY2VuZ2luZS1zZWNyZXQta2V5LTE=" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "test1-bmfvajfxgfcrhba5.azurecr.io", + "secret": { + "name": "acr-registry-credential-1", + "value": "YWNyLWNsaWVudC1pZC0xOmFjci1jbGllbnQtc2VjcmV0LTE=" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "demo.goharbor.io", + "secret": { + "name": "harbor-registry-credential", + "value": "aGFyYm9yLXJvYm90LWFjY291bnQ6aGFyYm9yLXJvYm90LXBhc3N3b3Jk" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "artifactorytest12345.jfrog.io", + "secret": { + "name": "artifactory-registry-credential", + "value": "YXJ0aWZhY3RvcnktdXNlcm5hbWU6YXJ0aWZhY3RvcnktcGFzc3dvcmQ=" + }, + "artifactTypes": [ + "CONTAINER", + "HELM" + ] + }, + { + "registryHostname": "stg.nvcr.io", + "secret": { + "name": "ngc-container-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtY29udGFpbmVyLXJlZ2lzdHJ5LWNyZWQ=" + }, + "artifactTypes": [ + "CONTAINER" + ] + }, + { + "registryHostname": "localhost-ngc", + "secret": { + "name": "ngc-container-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtY29udGFpbmVyLXJlZ2lzdHJ5LWNyZWQ=" + }, + "artifactTypes": [ + "CONTAINER" + ] + }, + { + "registryHostname": "api.stg.ngc.nvidia.com", + "secret": { + "name": "ngc-model-resource-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtbW9kZWwtcmVnaXN0cnktY3JlZA==" + }, + "artifactTypes": [ + "MODEL", + "RESOURCE" + ] + }, + { + "registryHostname": "localhost-ngc", + "secret": { + "name": "ngc-model-resource-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtbW9kZWwtcmVnaXN0cnktY3JlZA==" + }, + "artifactTypes": [ + "MODEL", + "RESOURCE" + ] + }, + { + "registryHostname": "helm.stg.ngc.nvidia.com", + "secret": { + "name": "ngc-helm-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtaGVsbS1yZWdpc3RyeS1jcmVk" + }, + "artifactTypes": [ + "HELM" + ] + }, + { + "registryHostname": "localhost-ngc", + "secret": { + "name": "ngc-helm-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtaGVsbS1yZWdpc3RyeS1jcmVk" + }, + "artifactTypes": [ + "HELM" + ] + } + ], + "maxTasksAllowed": "%d" + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-with-telemetries-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-with-telemetries-response.json new file mode 100644 index 000000000..358c172e0 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-with-telemetries-response.json @@ -0,0 +1,79 @@ +{ + "account": { + "ncaId": "%s", + "clientIds": [ "%s" ], + "name": "%s", + "telemetries": [ + { + "telemetryId": "edc4cd0a-80cd-4a7b-90d9-706b9eae9b4c", + "name": "datadog-prod-traces", + "endpoint": "https://otlp-gateway-prod-us-east-0.grafana.net/otlp", + "protocol": "GRPC", + "provider": "GRAFANA_CLOUD", + "types": [ + "TRACES" + ], + "createdAt": "_instant_" + }, + { + "telemetryId": "4df8cb5b-94e0-4fcc-ac12-ddb8da2f25aa", + "name": "grafana-prod-logs", + "endpoint": "https://otlp-gateway-prod-us-east-0.grafana.net/otlp", + "protocol": "GRPC", + "provider": "GRAFANA_CLOUD", + "types": [ + "LOGS" + ], + "createdAt": "_instant_" + }, + { + "telemetryId": "d49695b0-86b1-4a84-9b82-2ea823f51d78", + "name": "datadog-prod", + "endpoint": "datadoghq.com", + "protocol": "GRPC", + "provider": "DATADOG", + "types": [ + "METRICS" + ], + "createdAt": "_instant_" + } + ], + "registryCredentials": [ + { + "registryHostname": "stg.nvcr.io", + "secret": { + "name": "ngc-container-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtY29udGFpbmVyLXJlZ2lzdHJ5LWNyZWQ=" + }, + "artifactTypes": [ + "CONTAINER" + ] + }, + { + "registryHostname": "api.stg.ngc.nvidia.com", + "secret": { + "name": "ngc-model-resource-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtbW9kZWwtcmVnaXN0cnktY3JlZA==" + }, + "artifactTypes": [ + "MODEL", + "RESOURCE" + ] + }, + { + "registryHostname": "helm.stg.ngc.nvidia.com", + "secret": { + "name": "ngc-helm-registry-credential", + "value": "JG9hdXRodG9rZW46bnZhcGktc3RnLXRlc3QtY29udGFpbmVyLXJlZ2lzdHJ5LWNyZWQ=" + }, + "artifactTypes": [ + "HELM" + ] + } + ], + "maxFunctionsAllowed": 146, + "maxTasksAllowed": "%d", + "currentNumberFunctions": 98, + "lastUpdatedAt": "_instant_" + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-without-registry-credentials-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-without-registry-credentials-response.json new file mode 100644 index 000000000..363f211bf --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/account-without-registry-credentials-response.json @@ -0,0 +1,10 @@ +{ + "account": { + "ncaId": "%s", + "clientIds": [ + "%s" + ], + "name": "%s", + "maxTasksAllowed": "%d" + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/client-response.json b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/client-response.json new file mode 100644 index 000000000..e90d5059e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/fixtures/nvcf/client-response.json @@ -0,0 +1,7 @@ +{ + "client": { + "clientId": "%s", + "ncaId": "%s", + "name": "%s" + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/load-test.js b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/load-test.js new file mode 100644 index 000000000..99bc3876b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-core/src/test/resources/load-test.js @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import http from 'k6/http'; + +export default function () { + const url = 'https://stg.api.nvcf.nvidia.com/v1/nvcf'; + const payload = JSON.stringify({ + "requestHeader": { + "functionName": "simple_int8_sre", + "functionId": "7d199eeb-8af6-4588-ba60-3009773cd29d", + "metadata": [{"key": "metadata-key1", "value": "value1"}, + {"key": "metadata-key2", "value": "value2"}] + }, + "requestBody": { + "id": "42", + "inputs": [{ + "name": "INPUT0", + "shape": [1, 16], + "datatype": "INT8", + "data": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + }, { + "name": "INPUT1", + "shape": [1, 16], + "datatype": "INT8", + "data": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + }], + "outputs": [{"name": "OUTPUT0"}, {"name": "OUTPUT1"}] + } + }); + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer <>', + }, + }; + const response = http.post(url, payload, params); + if (response.status == 202) { + const requestId = response.json().reqId; + let response_status = 202 + while (response_status == 202) { + const res = http.get(url + '/' + requestId, params); + response_status = res.status; // console.log(response_status) + } + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel new file mode 100644 index 000000000..5b07d030b --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel @@ -0,0 +1,76 @@ +load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") +load("//rules/java:spring.bzl", "spring_boot_app") + +package(default_visibility = ["//visibility:public"]) + +NVCT_SERVICE_SRCS = glob(["src/main/java/**/*.java"]) + +NVCT_SERVICE_RESOURCES = glob(["src/main/resources/**"]) + +NVCT_SERVICE_TEST_SRCS = glob(["src/test/java/**/*.java"]) + +NVCT_SERVICE_TEST_RESOURCES = glob(["src/test/resources/**"]) + [ + "//src/control-plane-services/cloud-tasks:integration_local_env_files", +] + +NVCT_SERVICE_DEPS = [ + "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_loader", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_tracing", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_security", +] + +nvct_library( + name = "app_classes", + srcs = NVCT_SERVICE_SRCS, + deps = NVCT_SERVICE_DEPS, + resource_strip_prefix = "src/control-plane-services/cloud-tasks/nvct-service/src/main/resources", + resources = NVCT_SERVICE_RESOURCES, + visibility = ["//visibility:private"], +) + +spring_boot_app( + name = "app", + application = ":app_classes", + artifact_id = "nvct-service-oss", + artifact_name = "NVCT Service OSS", + artifact_version = "0.0.1-SNAPSHOT", + description = "NVIDIA Cloud Tasks OSS executable", + group_id = "com.nvidia.nvct", + main_class = "com.nvidia.nvct.App", +) + +nvct_library_test( + name = "tests", + coverage_library = ":app_classes", + srcs = NVCT_SERVICE_TEST_SRCS, + deps = [ + ":app_classes", + "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core", + "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core_test_fixtures", + "//src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test:nv_boot_mock_servers_test", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_testing", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_restclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_resttestclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_web_server", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webmvc", + "@nv_third_party_deps//:org_testcontainers_testcontainers", + "@nv_third_party_deps//:org_testcontainers_testcontainers_cassandra", + ], + data = [ + "//src/control-plane-services/cloud-tasks:integration_local_env", + ], + resources = NVCT_SERVICE_TEST_RESOURCES, + tags = [ + "exclusive", + "requires-docker", + ], + timeout = "long", +) diff --git a/src/control-plane-services/cloud-tasks/nvct-service/Dockerfile b/src/control-plane-services/cloud-tasks/nvct-service/Dockerfile new file mode 100644 index 000000000..1c67e5514 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/Dockerfile @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Pull base image. +FROM nvcr.io/nvidia/distroless/java:25-jdk-v4.0.8 + +# Add the service jar +ARG APP_JAR=nvct-service/target/app.jar +COPY ${APP_JAR} /usr/share/app.jar + +# enable setting the ulimit to max on boot +ENV ULIMIT_FLAG=1 + +# use this instead of JAVA_OPTS +ENV JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=40.0 -XX:+EnableDynamicAgentLoading -Dreactor.netty.pool.maxIdleTime=30000 -Dreactor.netty.pool.maxConnections=500" + +WORKDIR /home/app +CMD ["java", "-jar", "/usr/share/app.jar"] diff --git a/src/control-plane-services/cloud-tasks/nvct-service/local_env b/src/control-plane-services/cloud-tasks/nvct-service/local_env new file mode 120000 index 000000000..a02c0a7d3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/local_env @@ -0,0 +1 @@ +../local_env \ No newline at end of file diff --git a/src/control-plane-services/cloud-tasks/nvct-service/pom.xml b/src/control-plane-services/cloud-tasks/nvct-service/pom.xml new file mode 100644 index 000000000..3f87f5bfc --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/pom.xml @@ -0,0 +1,113 @@ + + + + 4.0.0 + + + com.nvidia.nvct + cloud-tasks + 0.0.1-SNAPSHOT + + + nvct-service-oss + jar + NVCT Service OSS + + NVIDIA Cloud Tasks — OSS executable + + + + com.nvidia.nvct.App + + + + + com.nvidia.nvct + nvct-core + ${project.version} + + + + + com.nvidia.nvct + nvct-core + ${project.version} + test-jar + tests + test + + + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-restclient + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-cassandra + test + + + com.nvidia.boot + nv-boot-mock-servers-test + test + + + + + app + + + src/main/resources + true + + + + + org.springframework.boot + spring-boot-maven-plugin + + + io.github.git-commit-id + git-commit-id-maven-plugin + + ${project.parent.basedir}/.git + + + + + diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/java/com/nvidia/nvct/App.java b/src/control-plane-services/cloud-tasks/nvct-service/src/main/java/com/nvidia/nvct/App.java new file mode 100644 index 000000000..be0b45e4f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/java/com/nvidia/nvct/App.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.micrometer.tracing.autoconfigure.prometheus.PrometheusExemplarsAutoConfiguration; +import org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration; + +@Slf4j +@SpringBootApplication(exclude = { + ReactiveUserDetailsServiceAutoConfiguration.class, + PrometheusExemplarsAutoConfiguration.class}) +public class App { + + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-local.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-local.yaml new file mode 100644 index 000000000..431a0e932 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-local.yaml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + cassandra: + contact-points: 127.0.0.1 + # Expect local_env/docker-compose.yml (9042:9042); port defaults from application.yaml. + username: cassandra + password: cassandra + +management: + tracing: + export: + enabled: false + opentelemetry: + tracing: + export: + otlp: + endpoint: https://dummy:8282 + +logging: + level: + com.nvidia: DEBUG + com.nvidia.nvct.configuration: INFO + org.springframework.web: DEBUG + org.springframework.data.cassandra.core.cql.CqlTemplate: DEBUG + +nvct: + aws: + region: us-east-1 + ess: + enabled: false + base-url: http://localhost:9098 + notary: + audiences: + ess: ess-api + nvct: nvct-api + base-url: http://localhost:9097 + nvcf: + base-url: http://localhost:9119 + reval: + base-url: http://localhost:9099 + icms: + allocator: + enabled: false + base-url: http://localhost:9096 + api-keys: + base-url: http://localhost:9093 + scheduled-routines: + enabled: false diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-ncp.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-ncp.yaml new file mode 100644 index 000000000..0c31d5bda --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application-ncp.yaml @@ -0,0 +1,146 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + cassandra: + contact-points: ${CASSANDRA_CONTACT_POINTS:cassandra.cassandra-system.svc.cluster.local} + keyspace-name: nvct_api + local-datacenter: ${CASSANDRA_DATACENTER:ncp} + username: cassandra + password: cassandra + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://nvct-api.nvcf.svc.cluster.local + jwk-set-uri: http://openbao-server.vault-system.svc.cluster.local:8200/v1/services/nvct-api/jwt/jwks + client: + registration: + api-keys: + client-id: unused # Override the inherited expression from application.yaml + client-secret: unused + icms: + client-id: unused + client-secret: unused + nvcf: + client-id: unused + client-secret: unused + notary: + # For validating jwt.sub == client-id as Notary Svc signs with sub nvct-api + client-id: nvct-api + client-secret: unused + ess: + client-id: unused + client-secret: unused + reval: + client-id: unused + client-secret: unused + provider: + api-keys: + token-uri: unused + icms: + token-uri: unused + nvcf: + token-uri: unused + notary: + token-uri: unused + ess: + token-uri: unused + reval: + token-uri: unused + +management: + tracing: + export: + enabled: false + opentelemetry: + tracing: + export: + otlp: + endpoint: https://dummy:8282 + headers: + lightstep-access-token: unused + +nvct: + aws: + region: ncp + fqdn: http://nvct-api.nvcf.svc.cluster.local:8080 + global-fqdn-grpc: http://nvct-api.nvcf.svc.cluster.local:9090 + sidecars: + tracing-key: unused + hostname: nvcr.io + repository: nv-cf/nvcf-core + # Sidecar container images are configured in configData in the colocated + # deploy values (nvct-api/values.yaml). + init-container: dummy + utils-container: dummy + otel-container: dummy + ess-agent-container: dummy + otel-collector-container: dummy + scheduled-routines: + lock-consistency: LOCAL_QUORUM + ess: + base-url: http://ess-api.ess.svc.cluster.local:8080 + static: + token: ${kv.tokens.ess} + notary: + audiences: + ess: ess-api + nvct: nvct-api + base-url: http://notary.nvcf.svc.cluster.local:8080 + static: + token: ${kv.tokens.notary} + nvcf: + cache-ttl: PT5M + base-url: http://api.nvcf.svc.cluster.local:8080 + static: + token: ${kv.tokens.nvcf} + reval: + enabled: false + base-url: http://reval.nvcf.svc.cluster.local:8080 + static: + token: ${kv.tokens.reval} + icms: + base-url: http://api.sis.svc.cluster.local:8080 + # base-url: http://api.icms.svc.cluster.local:8080 -- Use when self-hosted stack is ready + static: + token: ${kv.tokens.icms} + api-keys: + base-url: http://api-keys.api-keys.svc.cluster.local:8080 + # base-url: http://api-keys.nvcf.svc.cluster.local:8080 -- Use when self-hosted stack is ready + static: + token: ${kv.tokens.api-keys} + registries: + recognized: + container: + ngc: + hostname: "nvcr.io" + helm: + ngc: + hostname: "helm.ngc.nvidia.com" + oauth2: + base-url: https://authn.nvidia.com + group-scope: ngc + model: + ngc: + hostname: "api.ngc.nvidia.com" + oauth2: + base-url: https://authn.nvidia.com + group-scope: ngc + resource: + ngc: + hostname: "api.ngc.nvidia.com" + oauth2: + base-url: https://authn.nvidia.com + group-scope: ngc diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application.yaml new file mode 100644 index 000000000..4138d9e9f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/application.yaml @@ -0,0 +1,342 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + cassandra: + contact-points: 127.0.0.1 + keyspace-name: nvct + local-datacenter: datacenter1 + advanced: + speculative-execution-policy: + class: ConstantSpeculativeExecutionPolicy + max-executions: 1 + delay: 1500 #milliseconds + connection: + max-requests-per-connection: 10000 + pool: + local: + size: 2 + retry-policy: + class: com.nvidia.boot.cassandra.retry.NextHostRetryPolicy + port: 9042 + password: ${kv.cassandra.password} + username: ${kv.cassandra.username} + compression: lz4 + connection: + connect-timeout: 10s + init-query-timeout: 10s + pool: + heartbeat-interval: 30s + idle-timeout: 60s + request: + consistency: local_quorum + page-size: 5000 + serial-consistency: local_serial + timeout: 10s + session-name: nvct-api-session + session-metrics: + - bytes-sent + - connected-nodes + - cql-requests + node-metrics: + - pool.open-connections + - pool.in-flight + codec: + max-in-memory-size: 10MB + threads: + virtual: + enabled: false + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:9092 + jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/.well-known/jwks.json + jws-algorithms: ES256 + client: + registration: + api-keys: + provider: api-keys + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: pdp-evaluate + icms: + provider: icms + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: instance-request,cluster_listing,instance_types,cluster-management + nvcf: + provider: nvcf + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: account_setup + notary: + provider: notary + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: make-assertion + ess: + provider: ess + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: ess:secrets-admin,ess:entities-admin + reval: + provider: reval + client-id: ${kv.oauth2.client-id} + client-secret: ${kv.oauth2.client-secret} + authorization-grant-type: client_credentials + scope: helmreval:validate + provider: + api-keys: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + icms: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + nvcf: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + notary: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + ess: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + reval: + token-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/token + +logging: + level: + com.nvidia: INFO + org.springframework.web: INFO + +management: + tracing: + export: + enabled: true + sampling: + probability: 1.0 # Parity with javaagent based tracing + propagation: + type: w3c + opentelemetry: + resource-attributes: # Resource attributes for traces (attached to every span) + cloud.provider: ${CLOUD_PROVIDER:aws} + cloud.region: ${nvct.aws.region} + deployment.profiles: ${spring.profiles.active} + host.dc: ${spring.application.host.dc} + host.id: ${spring.application.host.id} + host.name: ${spring.application.host.id} + service.name: ${spring.application.name} + service.version: ${spring.application.version} + service.namespace: nvct + tracing: + export: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:https://dummy:8282} + compression: gzip + transport: http + timeout: 30s + headers: + lightstep-access-token: ${kv.tracing.accessToken} + endpoints: + web: + base-path: /actuator + exposure: + include: health,metrics,prometheus + endpoint: + health: + cache: + time-to-live: 2s + probes: + enabled: true + show-details: always + group: + readiness: + include: + - readinessState + - cassandra + liveness: + include: + - livenessState + - cassandra + health: + livenessState: + enabled: true + readinessState: + enabled: true + vault: + enabled: false + kubernetes: + enabled: false + metrics: + enable: + all: true + tags: + env: ${spring.application.env} + micro_service: ${spring.application.name} + service_version: ${spring.application.version} + host_id: ${spring.application.host.id} + host_dc: ${spring.application.host.dc} + distribution: + percentiles-histogram: + "[http.server.requests]": true + slo: + "[http.server.requests]": "5ms,10ms,25ms,50ms,75ms,100ms,200ms,300ms,400ms,500ms,750ms,1s,2s,3s,4s,5s,10s,15s,20s,30s" + observations: + annotations: + enabled: true + prometheus: + metrics: + export: + enabled: true + server: + port: 8181 + +server: + port: 8080 + compression: + enabled: true + forward-headers-strategy: framework + +nvct: + fqdn: http://localhost:${server.port} + global-fqdn-grpc: http://localhost:${grpc.server.port} + sidecars: + tracing-key: ${kv.worker-tracing} + hostname: stg.nvcr.io + repository: nv-cf/nvcf-core + image-pull-secret: ${kv.sidecars.image-pull-secret} + init-container: ${nvct.sidecars.hostname}/${nvct.sidecars.repository}/nvcf_worker_init:2.102.0 + utils-container: ${nvct.sidecars.hostname}/${nvct.sidecars.repository}/nvcf_worker_utils:2.101.0 + otel-container: ${nvct.sidecars.hostname}/${nvct.sidecars.repository}/opentelemetry-collector:0.126.0 + ess-agent-container: ${nvct.sidecars.hostname}/${nvct.sidecars.repository}/ess-agent:1.0.5 + otel-collector-container: ${nvct.sidecars.hostname}/${nvct.sidecars.repository}/byoo-otel-collector:0.126.16 + scheduled-routines: + enabled: true + lock-consistency: LOCAL_QUORUM + notary: + jwt: + issuer-uri: ${nvct.notary.base-url} + jwk-set-uri: ${nvct.notary.jwt.issuer-uri}/.well-known/jwks.json + rate-limiters: + account-rate-limiter: + allowed-invocations-per-second: 200 + task-rate-limiter: + allowed-invocations-per-second: 200 + registries: + recognized: + container: + docker: + name: "Docker Hub Registry" + hostname: "docker.io" + call-timeout: PT10S + oauth2: + base-url: https://auth.docker.io/ + group-scope: pull + ngc: + name: "NGC Private Registry" + hostname: "stg.nvcr.io" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + ecr: + name: "AWS ECR Private Registry" + hostname: "dkr.ecr.amazonaws.com" + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: "public.ecr.aws" + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: "cr.volces.com" + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: "azurecr.io" + call-timeout: PT10S + helm: + ngc: + name: "NGC Private Registry" + hostname: "helm.stg.ngc.nvidia.com" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: unused + group-scope: ngc-stg + docker: + name: "Docker Hub Registry" + hostname: "docker.io" + call-timeout: PT10S + oauth2: + base-url: https://auth.docker.io/ + group-scope: pull + ecr: + name: "AWS ECR Private Registry" + hostname: "dkr.ecr.amazonaws.com" + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: "public.ecr.aws" + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: "cr.volces.com" + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: "azurecr.io" + call-timeout: PT10S + model: + ngc: + name: "NGC Private Registry" + hostname: "api.stg.ngc.nvidia.com" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: unused + group-scope: ngc-stg + resource: + ngc: + name: "NGC Private Registry" + hostname: "api.stg.ngc.nvidia.com" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: unused + group-scope: ngc-stg + +grpc: + server: + port: 9090 + # enable keep alive pings to all live connections with client to check if they are still connected. + enable-keep-alive: true + # frequency of sending keep alive pings. + keep-alive-time: 15m + # duration within which the keep alive ack ping is expected before disconnecting. + keep-alive-timeout: 10s + # worst keep alive pings frequency the client can set. No client can send keep alive pings with frequency lesser than this. + permit-keep-alive-time: 1s + # whether keepalive will be performed when there are no outstanding RPC on a connection + permit-keep-alive-without-calls: true + # support large worker responses + max-inbound-message-size: ${spring.codec.max-in-memory-size} + shutdown-grace-period: 5m diff --git a/rules/java/private/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-local.yaml similarity index 73% rename from rules/java/private/BUILD.bazel rename to src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-local.yaml index 908410169..dc55c6fcb 100644 --- a/rules/java/private/BUILD.bazel +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-local.yaml @@ -1,16 +1,19 @@ -# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +spring: + application: + env: local -# Private Java rule implementations. +# debug: true diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-ncp.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-ncp.yaml new file mode 100644 index 000000000..d946dc4b4 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap-ncp.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + application: + env: ncp + +nv-boot: + reloadable-properties: + file: "file:/home/app/vault/secrets.json" diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap.yaml new file mode 100644 index 000000000..df97f79a2 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/main/resources/bootstrap.yaml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + application: + name: nvct-api + env: default + version: ${SPRING_APPLICATION_VERSION:0.0.1-dummy-version} + host: + id: ${HOSTNAME:${spring.application.name}-${random.int}-host} + dc: ${AWS_REGION:${spring.application.name}-${random.uuid}-dc} + cloud: + kubernetes: + config: + enabled: ${REMOTE_CONFIG_ENABLED:false} + name: ${REMOTE_CONFIG_NAME:nvct-api-remote-config} + fail-fast: true + retry: + enabled: true + max-attempts: 6 + reload: + enabled: true + mode: event + strategy: refresh + monitoring-config-maps: true + monitoring-secrets: false + refresh: + # Avoid warning - java.lang.IllegalArgumentException: wrong number of arguments: 0 expected: 27 - during startup + never-refreshable: com.zaxxer.hikari.HikariDataSource,org.springframework.cloud.kubernetes.commons.KubernetesClientProperties + +nv-boot: + reloadable-properties: + file: local_env/vault/secrets.json diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/test/java/com/nvidia/nvct/NvctServiceIntegrationTest.java b/src/control-plane-services/cloud-tasks/nvct-service/src/test/java/com/nvidia/nvct/NvctServiceIntegrationTest.java new file mode 100644 index 000000000..8483fe77f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/test/java/com/nvidia/nvct/NvctServiceIntegrationTest.java @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.nvidia.nvct; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.client.RestTemplate; + +/** + * OSS executable ({@link App} + {@code nvct-core}) smoke tests. Parallels the managed + * {@code nvct-service} repo integration tests, except this module has no + * {@code NvBootConfiguration} / {@code AuditProperties} wiring. + */ +@SpringBootTest( + classes = {App.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=test") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +@AutoConfigureTestRestTemplate +class NvctServiceIntegrationTest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @LocalManagementPort + private int managementPort; + + @Test + void healthEndpointReturnsOk() { + var response = testRestTemplate.exchange( + RequestEntity.get(URI.create("/health")).build(), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void actuatorHealthReturnsOk() { + var endpoint = URI.create("http://localhost:" + managementPort + "/actuator/health"); + var response = new RestTemplate().exchange(RequestEntity.get(endpoint).build(), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).containsIgnoringCase("status"); + } +} diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/application-test.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/application-test.yaml new file mode 100644 index 000000000..398f3eb69 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/application-test.yaml @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring.main.allow-bean-definition-overriding: true + +# Faster JVM exit after integration tests (avoids Surefire fork exit timeout when many +# @SpringBootTest contexts shut down gRPC + Tomcat + file watchers sequentially). +server: + shutdown: immediate + +grpc: + server: + port: 0 # Random port to avoid BindException when running multiple integration tests + +spring: + lifecycle: + timeout-per-shutdown-phase: 5s + cassandra: + contact-points: 127.0.0.1 + keyspace-name: nvct + request: + timeout: 30s + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:9092 + +management: + tracing: + export: + enabled: false + opentelemetry: + tracing: + export: + otlp: + endpoint: https://dummy:8282 + +logging: + level: + com.nvidia: DEBUG + com.nvidia.nvct.configuration: INFO + com.nvidia.nvct.util: INFO + org.springframework.web.client.RestTemplate: INFO + org.springframework.web.HttpLogging: INFO + org.springframework.web: DEBUG + org.springframework.data.cassandra.core.cql.CqlTemplate: INFO + +nvct: + fqdn: http://localhost:${server.port} + global-fqdn-grpc: http://localhost:9090 + sidecars: + hostname: fake.registry.test + aws: + region: us-east-1 + ess: + base-url: http://localhost:9098 + notary: + audiences: + ess: dummy-ess-audience + nvct: dummy-nvct-audience + base-url: http://localhost:9097 + nvcf: + base-url: http://localhost:9119 + reval: + base-url: http://localhost:9099 + icms: + base-url: http://localhost:9096 + allocator: + enabled: true + api-keys: + base-url: http://localhost:9093 + rate-limiters: + account-rate-limiter: + allowed-invocations-per-second: 200 + task-rate-limiter: + allowed-invocations-per-second: 200 + scheduled-routines: + enabled: true + lock-consistency: QUORUM + clean-terminal-tasks-routine: + enabled: false + monitor-launched-tasks-routine: + enabled: false + monitor-queued-tasks-routine: + enabled: false + monitor-worker-heartbeat-routine: + enabled: false + registries: + recognized: + container: + docker: + name: "Docker Hub Registry" + hostname: http://localhost-docker:9101 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9102 + group-scope: pull + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9100" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + ecr: + name: "AWS ECR Private Registry" + hostname: http://localhost-ecr:9103 + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: http://localhost-ecr-public:9104 + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: http://localhost-volcengine:9105 + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: http://localhost-acr:9106 + call-timeout: PT10S + oauth2: + base-url: http://localhost-acr:9107 + harbor: + name: "Harbor Registry" + hostname: http://localhost-harbor:9108 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9109 + artifactory: + name: "Artifactory Registry" + hostname: http://localhost-artifactory:9110 + call-timeout: PT10S + oauth2: + base-url: http://localhost-artifactory:9111 + custom-1: + name: "Custom Registry Test 1" + hostname: "custom-registry-test-1.com" + helm: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg + docker: + name: "Docker Hub Registry" + hostname: http://localhost-docker:9101 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9102 + group-scope: pull + ecr: + name: "AWS ECR Private Registry" + hostname: http://localhost-ecr:9103 + call-timeout: PT10S + ecr-public: + name: "AWS ECR Public Registry" + hostname: http://localhost-ecr-public:9104 + call-timeout: PT10S + volcengine: + name: "Volcano Engine Registry" + hostname: http://localhost-volcengine:9105 + call-timeout: PT10S + acr: + name: "Azure Container Registry" + hostname: http://localhost-acr:9106 + call-timeout: PT10S + oauth2: + base-url: http://localhost-acr:9107 + harbor: + name: "Harbor Registry" + hostname: http://localhost-harbor:9108 + call-timeout: PT10S + oauth2: + base-url: http://localhost:9109 + artifactory: + name: "JFrog Artifactory" + hostname: http://localhost-artifactory:9110 + call-timeout: PT10S + oauth2: + base-url: http://localhost-artifactory:9111 + custom-1: + name: "Custom Registry Test 1" + hostname: "custom-registry-test-1.com" + model: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg + resource: + ngc: + name: "NGC Private Registry" + hostname: "http://localhost-ngc:9094" + call-timeout: PT60S + read-timeout: PT30S + write-timeout: PT30S + connection-timeout: PT60S + oauth2: + base-url: "http://localhost:9095" + group-scope: ngc-stg diff --git a/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/bootstrap-test.yaml b/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/bootstrap-test.yaml new file mode 100644 index 000000000..2b2422d3e --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/src/test/resources/bootstrap-test.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +spring: + application: + env: test + +nv-boot: + reloadable-properties: + file: local_env/vault/secrets.json diff --git a/src/control-plane-services/cloud-tasks/pom.xml b/src/control-plane-services/cloud-tasks/pom.xml new file mode 100644 index 000000000..62047b81f --- /dev/null +++ b/src/control-plane-services/cloud-tasks/pom.xml @@ -0,0 +1,106 @@ + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 2.0.1 + + + + com.nvidia.nvct + cloud-tasks + 0.0.1-SNAPSHOT + pom + + NVCT API + NVIDIA Cloud Tasks — aggregator for core library and modules. + + + + + ${project.basedir} + 2.0.1 + + + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + nvct-core + nvct-service + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + 120 + ${root.dir} + + + + org.jacoco + jacoco-maven-plugin + + + com/nvidia/nvct/proto/** + + + + + + + + + org.codehaus.mojo + license-maven-plugin + + + + test + + ^com\.nvidia\.(nvct|boot) + + + + + diff --git a/src/libraries/java/nv-boot-parent/AGENTS.md b/src/libraries/java/nv-boot-parent/AGENTS.md new file mode 100644 index 000000000..ec383edaa --- /dev/null +++ b/src/libraries/java/nv-boot-parent/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md - nv-boot-parent + +nv-boot-parent provides the NVCF Spring Boot platform libraries inside the root +`nvcf` Bazel module. It is not a nested Bazel module and does not own a +`MODULE.bazel`, lockfile, `.bazelrc`, `.bazelversion`, downloader config, or +third-party dependency hub. + +Maven POMs remain during coexistence for Maven consumers. Bazel consumers use +the public starter source targets directly; Bazel does not generate or publish +Maven-shaped nv-boot artifacts. + +## Build And Test + +Run Bazel from the monorepo root: + +```bash +export BAZEL_OUTPUT_USER_ROOT="${TMPDIR:-/tmp}/nvcf-bazel-cache" + +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //src/libraries/java/nv-boot-parent/... + +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //src/libraries/java/nv-boot-parent/... \ + --test_tag_filters=-requires-docker +``` + +Tests tagged `requires-docker` run in the dedicated Docker integration lane. + +## Dependencies + +The root `MODULE.bazel` owns the one `nv_third_party_deps` hub and the root +`maven_install.json` and `MODULE.bazel.lock`. Starter BUILD targets declare +their own direct compile/runtime edges. Optional APIs use private `neverlink` +compile helpers plus runtime-classpath tests so frameworks such as Cassandra +are not added to unrelated consumers. + +Do not introduce `@nv_boot_parent`, `@cloud_tasks`, `@maven`, +`local_path_override`, or `git_override` for code already in this repository. + +## Layout + +- `nv-boot-starter-*/`: public nv-boot starter source targets. +- `//rules/java`: shared Java, test, runtime-scope, protobuf, and Spring Boot + rules. +- `//tools/bazel/java`: shared executable helper implementations. +- `NOTICE`, `notice_roots.json`, and `notice_metadata.json`: nv-boot's + monorepo NOTICE inputs and generated output. + +The root `.bazelrc` owns Java 25 and +`build --java_header_compilation=false`. diff --git a/src/libraries/java/nv-boot-parent/BAZEL.md b/src/libraries/java/nv-boot-parent/BAZEL.md index 0db1a11d5..a978277ad 100644 --- a/src/libraries/java/nv-boot-parent/BAZEL.md +++ b/src/libraries/java/nv-boot-parent/BAZEL.md @@ -1,44 +1,45 @@ # Bazel Developer Guide -This repository supports Bazel alongside Maven during migration. Maven remains -the canonical build and publish path until the team explicitly cuts over. +nv-boot lives inside the `NVIDIA/nvcf` monorepo. Run every command in this +guide from the monorepo root. Maven remains available during coexistence, but +Bazel configuration, dependencies, locks, and CI are owned by the monorepo +root. ## Tooling -- Bazel: 9.1.1, via `.bazelversion` +- Bazel: 9.1.1 through Bazelisk honoring the root `.bazelversion` - Dependency mode: Bzlmod with `rules_jvm_external` - Java: 25 - Python: 3.11 through `rules_python` for Bazel NOTICE actions - Docker: required for Testcontainers-backed tests such as Cassandra tests -The Bazel build/test path uses Bazel-managed Java tooling for compilation and -tests. The CI image does not need `java` or `jar` on `PATH` for the opt-in -Bazel build/test job. +The root `.bazelrc` selects the local Java 25 JDK. The GitHub build-container +lane gets Temurin 25 from its pinned CI image; the Docker-host lane uses +`actions/setup-java`. Local command examples use one portable cache location. Set it once per shell: ```bash -export BAZEL_OUTPUT_USER_ROOT="${TMPDIR:-/tmp}/nv-boot-parent-bazel-cache" +export BAZEL_OUTPUT_USER_ROOT="${TMPDIR:-/tmp}/nvcf-bazel-cache" ``` ## Understanding the Dependency Files -Bazel uses three top-level files for dependency declarations and locking. A +Bazel uses three monorepo-root files for dependency declarations and locking. A simple way to remember their responsibilities is: ```text -MODULE.bazel = what this repository wants +MODULE.bazel = what the whole monorepo wants maven_install.json = exact third-party Java artifacts that were resolved MODULE.bazel.lock = exact Bazel modules and module extensions that were resolved ``` ### `MODULE.bazel` -Developers edit this file. It declares the repository as a Bazel module and -contains inputs such as: +Developers edit the root `MODULE.bazel`. nv-boot does not have a nested module +file. The root file contains inputs such as: - `bazel_dep` entries for Bazel rules and tooling; -- overrides for Bazel source modules when needed; - Maven-compatible BOMs and third-party Java dependency roots supplied to `rules_jvm_external`; - the shared `nv_third_party_deps` hub configuration. @@ -65,7 +66,7 @@ Maven build, publish nv-boot artifacts, or make the Bazel outputs Maven-shaped. Maven does not normally have a direct checked-in equivalent to this dependency lockfile. -After changing BOMs, third-party roots, or versions in `MODULE.bazel`, +After changing BOMs, third-party roots, or versions in the root `MODULE.bazel`, regenerate it with: ```bash @@ -101,24 +102,22 @@ uses `asm-analysis` and `asm-util`. Those two unmanaged modules are explicitly aligned to 9.9 in `MODULE.bazel` so the shared hub does not contain a partially upgraded ASM runtime. -The commands below reuse `BAZEL_OUTPUT_USER_ROOT` so local builds do not fight -with other workspaces. `${TMPDIR:-/tmp}` works on macOS and Linux; the stable -project-specific suffix prevents cache collisions with other repositories. +The root `.bazel_downloader_config` maps external downloads to approved +mirrors. Bazel applies it through `.bazelrc`; nv-boot does not define a +subtree-specific downloader configuration. ## Shared Neutral Dependency Hub -`nv-boot-parent` exposes public Bazel source targets whose third-party labels -refer to one shared dependency repository: +nv-boot exposes Bazel source targets whose third-party labels refer to the +monorepo's one shared dependency repository: ```text @nv_third_party_deps ``` -The exact configured name is `nv_third_party_deps`, with underscores. Every -Bazel application that consumes nv-boot targets must use this same name for its -`rules_jvm_external` install. A different name, including -`nv-third-party-deps`, creates a different repository and does not satisfy the -labels exposed by nv-boot. +The exact configured name is `nv_third_party_deps`, with underscores. The root +`MODULE.bazel` defines it once. No library or application subtree defines +another `maven.install`. The name is intentionally neutral: @@ -127,38 +126,16 @@ The name is intentionally neutral: - it does not contain nv-boot libraries or application-owned libraries; - nv-boot and application code remain first-party Bazel source targets. -A downstream application's `MODULE.bazel` should follow this shape: - -```python -maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") -maven.install( - name = "nv_third_party_deps", - artifacts = [ - # Application-owned third-party roots only. - ], - boms = [ - # Application-owned BOMs, including the Spring Boot BOM as needed. - ], - known_contributing_modules = [ - "nv_boot_parent", - # Other Bazel modules that contribute to this same hub. - ], - lock_file = "//:maven_install.json", - ... -) -use_repo(maven, "nv_third_party_deps") -``` - -Bzlmod and `rules_jvm_external` then merge the upstream and application -contributions into one resolved graph. If an application creates a second hub, -its executable jar can contain duplicate or version-skewed libraries. Cloud -Tasks demonstrated this failure mode with an incompatible gRPC mix and a -218 MB two-hub app; the single shared hub produced the expected 142 MB app. +`rules_jvm_external` resolves the root Java dependency graph and exposes jar +targets through this hub. A coordinate in the hub is merely available; only a +BUILD dependency edge places it on a library or application's compile/runtime +classpath. This is how unrelated service dependencies stay out of nv-boot +targets even though the root lock contains all Java services. After changing third-party roots or versions, repin the shared hub: ```bash -REPIN=1 bazel --output_user_root="${TMPDIR:-/tmp}/-bazel-cache" \ +REPIN=1 bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ run @nv_third_party_deps//:pin ``` @@ -173,30 +150,31 @@ whole cache. ## Build -Build the entire repository: +Build the entire nv-boot subtree: ```bash -bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" build //... +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //src/libraries/java/nv-boot-parent/... ``` Build one module: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - build //nv-boot-starter-core:all + build //src/libraries/java/nv-boot-parent/nv-boot-starter-core:all ``` Build one module's Bazel-native Java library target: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - build //nv-boot-starter-core:nv_boot_starter_core + build //src/libraries/java/nv-boot-parent/nv-boot-starter-core:nv_boot_starter_core ``` The compiled library jar is written under `bazel-bin//`, for example: ```text -bazel-bin/nv-boot-starter-core/libnv_boot_starter_core.jar +bazel-bin/src/libraries/java/nv-boot-parent/nv-boot-starter-core/libnv_boot_starter_core.jar ``` The Bazel path does not generate POMs, Maven-named jars, sources jars for Maven @@ -208,7 +186,7 @@ Run all tests without reusing cached test results: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //... \ + test //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=errors ``` @@ -217,7 +195,7 @@ Run all tests and stream logs to the terminal: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //... \ + test //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=streamed ``` @@ -226,7 +204,7 @@ Run one module's tests: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //nv-boot-starter-core:tests \ + test //src/libraries/java/nv-boot-parent/nv-boot-starter-core:tests \ --cache_test_results=no \ --test_output=errors ``` @@ -235,7 +213,7 @@ Run one test class: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //nv-boot-starter-core:tests \ + test //src/libraries/java/nv-boot-parent/nv-boot-starter-core:tests \ --cache_test_results=no \ --test_output=streamed \ --test_arg='--exclude-classname=^(?!com\.nvidia\.boot\.core\.env\.BootCoreEnvironmentPostProcessorTest$).*' @@ -245,7 +223,7 @@ Run one test method: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //nv-boot-starter-core:tests \ + test //src/libraries/java/nv-boot-parent/nv-boot-starter-core:tests \ --cache_test_results=no \ --test_output=streamed \ --test_arg='--exclude-classname=^(?!com\.nvidia\.boot\.core\.env\.BootCoreEnvironmentPostProcessorTest$).*' \ @@ -261,13 +239,15 @@ test macro runs JUnit ConsoleLauncher directly. Test logs are under: ```text -bazel-testlogs//tests/test.log -bazel-testlogs//tests/test.outputs/junit/TEST-junit-jupiter.xml +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.log +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs/junit/TEST-junit-jupiter.xml ``` The Jupiter XML contains the real Java testcases and is the report published by -GitLab. The nearby `bazel-testlogs//tests/test.xml` describes Bazel's -single outer `sh_test` wrapper and must not be used as the JUnit report. +GitHub Actions. The nearby +`bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.xml` +describes Bazel's single outer `sh_test` wrapper and must not be used as the +JUnit report. ## Coverage @@ -280,7 +260,7 @@ Run one module's tests and generate its JaCoCo report: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //nv-boot-starter-core:tests \ + test //src/libraries/java/nv-boot-parent/nv-boot-starter-core:tests \ --cache_test_results=no \ --test_output=errors ``` @@ -288,21 +268,21 @@ bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ Open the generated HTML report: ```text -bazel-testlogs/nv-boot-starter-core/tests/test.outputs/index.html +bazel-testlogs/src/libraries/java/nv-boot-parent/nv-boot-starter-core/tests/test.outputs/index.html ``` The same test output directory also contains: ```text -bazel-testlogs/nv-boot-starter-core/tests/test.outputs/jacoco.xml -bazel-testlogs/nv-boot-starter-core/tests/test.outputs/jacoco.exec +bazel-testlogs/src/libraries/java/nv-boot-parent/nv-boot-starter-core/tests/test.outputs/jacoco.xml +bazel-testlogs/src/libraries/java/nv-boot-parent/nv-boot-starter-core/tests/test.outputs/jacoco.exec ``` Run all module tests and generate one JaCoCo report per test target: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //... \ + test //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=errors ``` @@ -310,10 +290,10 @@ bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ Per-module reports are written under each test target's output directory: ```text -bazel-testlogs//tests/test.outputs/junit/TEST-junit-jupiter.xml -bazel-testlogs//tests/test.outputs/index.html -bazel-testlogs//tests/test.outputs/jacoco.xml -bazel-testlogs//tests/test.outputs/jacoco.exec +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs/junit/TEST-junit-jupiter.xml +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs/index.html +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs/jacoco.xml +bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs/jacoco.exec ``` For CI/Sonar Java coverage, publish the generated `jacoco.xml` files and pass @@ -333,11 +313,11 @@ standard Bazel `java_test` targets. In that case, run: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - coverage //... \ + coverage //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=errors \ --combined_report=lcov \ - --instrumentation_filter=//nv-boot + --instrumentation_filter=//src/libraries/java/nv-boot-parent ``` For standard `java_test` targets, the combined LCOV report is written to: @@ -349,7 +329,7 @@ bazel-out/_coverage/_coverage_report.dat Convert the combined LCOV report to SonarQube generic coverage XML: ```bash -python3 tools/bazel/lcov_to_sonar_generic.py \ +python3 tools/bazel/java/lcov_to_sonar_generic.py \ --input bazel-out/_coverage/_coverage_report.dat \ --output "${TMPDIR:-/tmp}/nv-boot-parent-sonar-coverage.xml" ``` @@ -363,101 +343,189 @@ sonar.coverageReportPaths= ## License And Notice -During Maven/Bazel coexistence, the root `NOTICE` file remains the canonical -third-party notice artifact. It is generated by Bazel-native tooling from: +The monorepo has two NOTICE levels: + +1. nv-boot's Bazel target generates and checks the complete third-party NOTICE + for the nv-boot libraries. +2. The existing root `tools/scripts/collect-notices` process records the + nv-boot NOTICE path in the monorepo's top-level `NOTICE`. + +The root owns the shared implementation: + +```text +//rules/java:notice.bzl +//tools/bazel/java:generate_notice_tool +``` + +nv-boot owns three component files: + +```text +src/libraries/java/nv-boot-parent/NOTICE +src/libraries/java/nv-boot-parent/notice_roots.json +src/libraries/java/nv-boot-parent/notice_metadata.json +``` -- explicit production dependency roots in `tools/bazel/notice_roots.json`; -- the resolved dependency graph in `maven_install.json`; -- checked-in upstream artifact metadata in - `tools/bazel/notice_metadata.json`. +Their roles are deliberately different: -The root list intentionally excludes test-only and provided dependencies. The -generator excludes first-party `com.nvidia.boot` artifacts and does not call -Maven, `license-maven-plugin`, or read project `pom.xml` files. +- `NOTICE` is the complete, human-readable generated result checked into Git. +- `notice_roots.json` lists the production dependency entry points for the + nv-boot library collection. It is required because nv-boot has no single + executable jar whose contents represent every public starter. +- `notice_metadata.json` contains the reusable name, URL, and license metadata + for nv-boot's third-party runtime dependencies. OSS services reuse this + shared metadata instead of copying it. -Bazel invokes the generator through the `//tools/bazel:generate_notice_tool` -`py_binary`, so build actions use the Bazel-declared Python runtime instead of -looking up host `python3`. Explicit roots missing from `maven_install.json` are -errors. Application runtime scans also reject a packaged jar when its path -version differs from the lockfile version. +The generator is Bazel-native. Normal generation reads the root +`maven_install.json`, not project POM files, and does not run Maven or +`license-maven-plugin`. -Refresh `NOTICE` and the checked-in artifact metadata after dependency changes: +Regenerate the checked-in nv-boot NOTICE: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - run //:generate_notice -- --update-metadata --write + run //src/libraries/java/nv-boot-parent:generate_notice -- --write ``` -The `--update-metadata` path reads upstream artifact POMs from the local Maven -cache first, then from the repositories configured in `maven_install.json` when -needed. Commit both `NOTICE` and `tools/bazel/notice_metadata.json` after a -refresh. - -Run a developer check without changing files: +When a new runtime dependency lacks metadata, refresh the component-owned +metadata and NOTICE together: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - run //:generate_notice -- --check + run //src/libraries/java/nv-boot-parent:generate_notice -- \ + --update-metadata --write ``` -Validate the checked-in `NOTICE` file: +The metadata-update mode may read an upstream dependency POM from the local +Maven cache or configured artifact repository to obtain its published name, +URL, and license declaration. That is metadata discovery only; it does not run +a Maven project build. + +Check for drift exactly as CI does: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //tools/bazel:notice_check_test \ + test //src/libraries/java/nv-boot-parent:notice_check_test \ --cache_test_results=no \ --test_output=errors ``` -Build a Bazel output copy for CI artifact collection: +Build the machine-readable nv-boot runtime inventory: ```bash bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - build //:third_party_notice + build //src/libraries/java/nv-boot-parent:nv_boot_runtime_inventory + +cat bazel-bin/src/libraries/java/nv-boot-parent/runtime_inventory.json ``` -The generated copy is written to: +The nv-boot inventory is the input for its OSRB comparison. An +`nv_boot_osrb_dependency_delta` target is intentionally not defined yet +because the monorepo does not have a checked-in approved-baseline inventory to +subtract. Do not compare nv-boot with an empty baseline and describe every +dependency as newly introduced. Once OSRB establishes the approved public +baseline, add that inventory as the explicit baseline and generate nv-boot's +license-grouped delta from the exact versioned-coordinate difference. + +After component NOTICE files are updated, validate the existing monorepo root +rollup: + +```bash +./tools/ci/check-license +``` + +`check-license` requires Bash 4 or newer. To intentionally refresh the +top-level path rollup, run `./tools/scripts/update-license`. + +## GitHub CI + +The monorepo uses `.github/workflows/bazel.yml`; there is no GitLab +`ENABLE_BAZEL_BUILD` variable for this subtree. The workflow detects nv-boot, +shared Java tooling, root dependency, or consumer changes and selects: + +- the build-container lane for tests that do not require a Docker daemon; +- the Docker-host lane for complete Testcontainers scopes; and +- reverse-dependency consumer validation, including Cloud Tasks when nv-boot + changes. + +GitHub CI builds and tests Bazel source targets. It does not publish +Maven-shaped nv-boot artifacts. + +The component-local `bazel-java-ci.json` registers nv-boot with the root +workflow. The detector infers the component path from that file and reads: ```text -bazel-bin/THIRD_PARTY_NOTICE +id +ci_lane +component_kind +tests_skip ``` -This is not a `rules_license` integration. The build and test path is stable -because the human-readable license/name/homepage metadata is checked in, while -the explicit refresh command updates that metadata from upstream Maven artifact -POMs when dependencies change. +`component_kind: java-framework` makes framework changes select every +discovered `java-service`. Shared root Java configuration, rules, and tools +select every discovered Java component. Do not add parallel component-name +lists to `.github/workflows/bazel.yml`. + +### CI Execution Environments + +The `ci_lane` descriptor field chooses where GitHub Actions executes Bazel: + +- `build-container`: the job runs inside the pinned + `ghcr.io/nvidia/nvcf/bazel-ci` image. Java, Bazelisk, and other build tools + are already installed. The host Docker daemon is not exposed there, so this + lane cannot run Testcontainers tests or build Docker images. +- `docker-host`: the job runs directly on GitHub's `ubuntu-latest` virtual + machine. The workflow installs Java and Bazelisk, and the host Docker daemon + is available to Testcontainers and Docker commands. + +This is CI routing, not a Maven-versus-Bazel difference. Local Maven and Bazel +commands both use Docker Desktop when their tests need containers. nv-boot uses +`docker-host` because its complete test scope includes Testcontainers tests. A +future Java component without such tests may use `build-container`. Under the +current one-lane-per-component policy, even one `requires-docker` test routes +the component's complete suite to `docker-host`. -## CI Opt-In +### Bazel Scope -Maven remains the default CI build and publish path during coexistence. Bazel CI -is opt-in with: +Every discovered Java component is part of the monorepo root Bazel module. +The workflow runs Bazel from the repository root and uses a scoped target such +as: -```yaml -variables: - ENABLE_BAZEL_BUILD: "true" +```text +//src/libraries/java/nv-boot-parent/... ``` -The CI handoff and GitLab job shape are documented in -`bazel-enablement/ci-bazel-handoff.md`. This branch also has a project-local -`.gitlab-ci.yml` trial job named `bazel-build-test` guarded by that flag. -Bazel CI builds and tests the Bazel target graph; it does not publish -Maven-shaped artifacts to URM/Artifactory. +The first version of the descriptor called this `scope_mode: root`. The field +was removed because there is no supported alternative for Java components. +Some existing non-Java components are independent nested Bazel modules and run +from their own directories with `//...`, but imported Java frameworks and +services must not create nested `MODULE.bazel` files. -For CI jobs that run Testcontainers-backed tests, pass Docker environment -variables into Bazel's restricted test environment: +### Downloading CI Reports -```bash -bazel --output_user_root=/tmp/nv-boot-parent-bazel-cache \ - test \ - --cache_test_results=no \ - --test_output=errors \ - --test_env=DOCKER_HOST \ - --test_env=DOCKER_TLS_VERIFY \ - --test_env=DOCKER_TLS_CERTDIR \ - --test_env=DOCKER_CERT_PATH \ - //... +Open the completed GitHub Actions workflow run and download: + +```text +bazel-nv-boot-parent-verification- +``` + +The artifact is retained for 14 days and contains: + +```text +generated/THIRD_PARTY_NOTICE +generated/runtime_inventory.json +testlogs//tests/test.log +testlogs//tests/test.outputs/junit/TEST-junit-jupiter.xml +testlogs//tests/test.outputs/jacoco.exec +testlogs//tests/test.outputs/jacoco.xml +testlogs//tests/test.outputs/index.html ``` +Use the XML under `test.outputs/junit`; Bazel's outer `test.xml` describes the +shell test wrapper rather than the individual JUnit tests. The root-owned +`tools/ci/stage-bazel-java-artifacts` helper copies through Bazel's `bazel-bin` +and `bazel-testlogs` symlinks so the download contains real files after the CI +runner is destroyed. + ## Build And Test Like `mvn clean install` For a Maven-like local validation loop: @@ -466,17 +534,16 @@ For a Maven-like local validation loop: bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" clean bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //... \ + test //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=errors bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - build //... + build //src/libraries/java/nv-boot-parent/... ``` -`bazel test //...` runs tests and builds what those tests need. Run -`bazel build //...` when you also want all non-test Bazel outputs, including -the module library jars and generated third-party NOTICE. +`bazel test` builds what the tests need. Run the scoped `bazel build` when you +also want non-test library outputs. ## Maven Coexistence @@ -488,8 +555,8 @@ During coexistence: - Maven remains the canonical remote publish path for Maven consumers. - Bazel build/test remains the canonical path for Bazel consumers. -- Downstream Bazel applications should consume `nv-boot-parent` through Bzlmod - source dependencies, such as `git_override`, not through URM Maven artifacts. +- Applications in this monorepo consume nv-boot through direct labels under + `//src/libraries/java/nv-boot-parent/...`. - Bazel does not generate, install, or publish Maven-shaped project artifacts. ## Dependency Updates @@ -498,11 +565,12 @@ When a module needs a new external dependency: 1. Add the dependency close to the module that uses it, in that module's `BUILD.bazel`. -2. Add the coordinate to `MODULE.bazel` if Bazel does not already resolve it. +2. Add the coordinate to the root `MODULE.bazel` if Bazel does not already + resolve it. 3. Prefer versionless coordinates when a BOM manages the version. 4. Add an explicit version only for intentional pins or CVE overrides. -5. If the dependency is shipped by a starter, add its direct production root - to `tools/bazel/notice_roots.json`. +5. If the dependency is shipped by a starter, update the nv-boot NOTICE roots + during the Phase 4 NOTICE workflow. 6. Repin and validate: ```bash @@ -510,7 +578,7 @@ REPIN=1 bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ run @nv_third_party_deps//:pin bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ - test //tools/bazel:notice_check_test \ + test //src/libraries/java/nv-boot-parent/... \ --cache_test_results=no \ --test_output=errors ``` @@ -523,75 +591,36 @@ unrelated transitive path. For a new nv-boot module during coexistence: -1. Add the Maven module to root `pom.xml`. +1. Add the Maven module to + `src/libraries/java/nv-boot-parent/pom.xml`. 2. Add the module's Maven `pom.xml`. 3. Add the module to `nv-boot-bom/pom.xml` if downstream apps should get its version through the BOM. -4. Add `new-module/BUILD.bazel`. +4. Add + `src/libraries/java/nv-boot-parent/new-module/BUILD.bazel`. 5. Add `nv_boot_library(...)`. 6. Add `nv_boot_library_test(...)` if the module has tests, with `coverage_library` set to the module's `nv_boot_library(...)` target. -7. Add any new shipped third-party roots to - `tools/bazel/notice_roots.json`. -8. Update migration docs or release docs if they list modules explicitly. +7. Update the nv-boot NOTICE roots during the Phase 4 NOTICE workflow. +8. Update architecture or release docs if they list modules explicitly. For an internal-only Maven module, skip the Maven BOM entry. Its Bazel target is still an ordinary source library target. ## Reusable Bazel Enablement Skills -The reusable Codex Bazel enablement skills are versioned in this repo at: - -```text -bazel-enablement/skills/maven-parent-bazel-enablement -bazel-enablement/skills/spring-boot-app-bazel-enablement -``` - -Keep these repo-owned copies centralized in `nv-boot-parent`. Application -repositories should reference the appropriate skill in their handoff docs, -but should not duplicate the skill directory. - -Use `maven-parent-bazel-enablement` for parent/aggregator and shared-library -repositories such as nv-boot-parent or nv-boot-managed-parent. Use -`spring-boot-app-bazel-enablement` for application reactors such as Cloud Tasks -or cloud-functions that must build/test libraries, package an executable app, -generate runtime NOTICE, and validate Docker/CI. - -Codex loads the installed runtime copy from: +The imported monorepo subtree deliberately does not contain +`bazel-enablement` history or skill files. The authoritative skill source +remains in the standalone nv-boot repository, with installed copies at: ```text $HOME/.codex/skills/maven-parent-bazel-enablement $HOME/.codex/skills/spring-boot-app-bazel-enablement ``` -Treat the repo copy as the source of truth. After updating and committing the -repo copy, sync it into the installed skill location: - -```bash -rsync -a --delete \ - bazel-enablement/skills/maven-parent-bazel-enablement/ \ - "${HOME}/.codex/skills/maven-parent-bazel-enablement/" - -rsync -a --delete \ - bazel-enablement/skills/spring-boot-app-bazel-enablement/ \ - "${HOME}/.codex/skills/spring-boot-app-bazel-enablement/" -``` - -Verify the two copies are aligned: - -```bash -diff -ru \ - bazel-enablement/skills/maven-parent-bazel-enablement \ - "${HOME}/.codex/skills/maven-parent-bazel-enablement" - -diff -ru \ - bazel-enablement/skills/spring-boot-app-bazel-enablement \ - "${HOME}/.codex/skills/spring-boot-app-bazel-enablement" -``` - -Until this is automated, periodically sync repo to installed copy after skill -changes so future Bazel enablement work, such as `nv-boot-managed-parent`, uses -the latest validated workflow. +Use `spring-boot-app-bazel-enablement` with its GitHub monorepo profile for +other OSS/self-hosted Spring Boot subtrees. Do not recreate skill directories +inside this monorepo. ## Bazel-Native Status @@ -605,9 +634,8 @@ Most of the migration work is Bazel-native: - build and test use Bazel Java targets and `nv_boot_library_test`, not `maven-surefire-plugin`; - coverage is generated by the Bazel test wrapper, not `jacoco-maven-plugin`; -- License/NOTICE generation uses `tools/bazel/notice_roots.json`, - `maven_install.json`, and `tools/bazel/notice_metadata.json`, not - `license-maven-plugin`; +- NOTICE generation is Bazel-native and is being completed as the layered + monorepo Phase 4 workflow, not through `license-maven-plugin`; - module library jars are ordinary Bazel Java outputs; - POM generation, Maven-shaped artifact creation, local Maven installation, and remote Maven deployment are absent from the Bazel toolchain. @@ -627,7 +655,7 @@ toolchain code: - the Bazel test wrapper preserves the JUnit exit status and then invokes the JaCoCo CLI to generate HTML and XML reports; - reports remain under - `bazel-testlogs//tests/test.outputs`; + `bazel-testlogs/src/libraries/java/nv-boot-parent//tests/test.outputs`; - focused test selection and ordinary console logging continue to use the JUnit Platform Console Launcher. @@ -647,17 +675,14 @@ separate cutover decision. Do not recreate it inside Bazel. ## Current Gaps - Maven remains the canonical publishing path during coexistence. -- Downstream Maven consumption from URM has been validated with `cloud-tasks` - using version `15665e3b`. - The Bazel remote publish/deploy bridge was removed after validation because the target Bazel-native model is source-target consumption, not URM Maven artifact consumption. - Parent/BOM POM artifacts still come from the Maven build and checked-in `pom.xml` files during coexistence. -- A project-local opt-in Bazel CI job is available; the shared CI template has - not been updated yet. -- License/NOTICE is covered by Bazel-native generation and checks from - `maven_install.json`, explicit production roots, and checked-in artifact - metadata. A formal `rules_license` integration is not implemented. +- GitHub CI build/test selection is integrated; layered NOTICE enforcement and + license-grouped OSRB delta output remain Phase 4. +- The root dependency graph currently warns that protobuf expected + `libprotoc 33.4` but selected `33.0`. - Downstream Spring Boot executable app packaging belongs to downstream app migration, not to `nv-boot-parent`. diff --git a/src/libraries/java/nv-boot-parent/BUILD.bazel b/src/libraries/java/nv-boot-parent/BUILD.bazel index 3c9cae942..a70870dd9 100644 --- a/src/libraries/java/nv-boot-parent/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/BUILD.bazel @@ -1,50 +1,19 @@ -package(default_visibility = ["//visibility:public"]) - -exports_files(["NOTICE"]) +load("//rules/java:notice.bzl", "nvcf_notice") -genrule( - name = "third_party_notice", - srcs = [ - "//:maven_install.json", - "//src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json", - "//src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json", - ], - tools = ["//src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool"], - outs = ["THIRD_PARTY_NOTICE"], - cmd = """ -set -eu -"$(execpath //src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool)" \ - --maven-install "$(location //:maven_install.json)" \ - --metadata "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json)" \ - --root-manifest "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json)" \ - --output "$@" \ - --write -""", -) +package(default_visibility = ["//visibility:public"]) -genrule( - name = "generate_notice", - srcs = [ - "//:maven_install.json", - "//src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json", - "//src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json", - ], - tools = ["//src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool"], - outs = ["generate_notice.sh"], - cmd = """ -cat > "$@" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail +# NOTICE and its generator inputs are owned by this subtree. The third-party +# artifact lock is the single root //:maven_install.json; the generation +# algorithm is the root-owned //tools/bazel/java:generate_notice_tool. +exports_files([ + "NOTICE", + "notice_metadata.json", + "notice_roots.json", +]) -workspace="$${BUILD_WORKSPACE_DIRECTORY:-$$(pwd)}" -exec "$(execpath //src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool)" \ - --maven-install "$(location //:maven_install.json)" \ - --metadata "$${workspace}/tools/bazel/notice_metadata.json" \ - --notice "$${workspace}/NOTICE" \ - --root-manifest "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json)" \ - "$$@" -EOF -chmod +x "$@" -""", - executable = True, +nvcf_notice( + checked_notice = ":NOTICE", + inventory_name = "nv_boot_runtime_inventory", + metadata = ":notice_metadata.json", + root_manifests = [":notice_roots.json"], ) diff --git a/src/libraries/java/nv-boot-parent/CLAUDE.md b/src/libraries/java/nv-boot-parent/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/libraries/java/nv-boot-parent/NOTICE b/src/libraries/java/nv-boot-parent/NOTICE index ca15f90d3..400531af2 100644 --- a/src/libraries/java/nv-boot-parent/NOTICE +++ b/src/libraries/java/nv-boot-parent/NOTICE @@ -23,7 +23,7 @@ Lists of 208 third-party dependencies. (Eclipse Public License - v 2.0) (GNU General Public License Version 2) (GNU Lesser General Public License Version 2.1) jnr-posix (com.github.jnr:jnr-posix:3.1.15 - http://nexus.sonatype.org/oss-repository-hosting.html) (MIT License) jnr-x86asm (com.github.jnr:jnr-x86asm:1.0.2 - http://github.com/jnr/jnr-x86asm) (Apache License, Version 2.0) JCIP Annotations under Apache License (com.github.stephenc.jcip:jcip-annotations:1.0-1 - http://stephenc.github.com/jcip-annotations) - (The Apache Software License, Version 2.0) FindBugs-jsr305 (com.google.code.findbugs:jsr305:2.0.1 - http://findbugs.sourceforge.net/) + (The Apache Software License, Version 2.0) FindBugs-jsr305 (com.google.code.findbugs:jsr305:3.0.2 - http://findbugs.sourceforge.net/) (Apache 2.0) error-prone annotations (com.google.errorprone:error_prone_annotations:2.49.0 - https://errorprone.info) (Apache License, Version 2.0) Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.3 - https://github.com/google/guava) (Apache License, Version 2.0) Guava: Google Core Libraries for Java (com.google.guava:guava:33.6.0-jre - https://github.com/google/guava) diff --git a/src/libraries/java/nv-boot-parent/bazel-java-ci.json b/src/libraries/java/nv-boot-parent/bazel-java-ci.json new file mode 100644 index 000000000..114fde1ae --- /dev/null +++ b/src/libraries/java/nv-boot-parent/bazel-java-ci.json @@ -0,0 +1,6 @@ +{ + "ci_lane": "docker-host", + "component_kind": "java-framework", + "id": "nv-boot-parent", + "tests_skip": false +} diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json b/src/libraries/java/nv-boot-parent/notice_metadata.json similarity index 99% rename from src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json rename to src/libraries/java/nv-boot-parent/notice_metadata.json index a6261780e..a84cb5b29 100644 --- a/src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json +++ b/src/libraries/java/nv-boot-parent/notice_metadata.json @@ -1509,5 +1509,5 @@ "url": "https://github.com/FasterXML/jackson-modules-base" } }, - "generated_by": "tools/bazel/generate_notice.py --update-metadata" + "generated_by": "tools/bazel/java/generate_notice.py --update-metadata" } diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_roots.json b/src/libraries/java/nv-boot-parent/notice_roots.json similarity index 100% rename from src/libraries/java/nv-boot-parent/tools/bazel/notice_roots.json rename to src/libraries/java/nv-boot-parent/notice_roots.json diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel index 29aa85795..23692d227 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") MOCK_SERVERS_COMPILE_DEPS = [ "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel index 83dc87abf..63ef3407f 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel @@ -1,5 +1,5 @@ load("@rules_java//java:defs.bzl", "java_library") -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") AUDIT_REQUIRED_DEPS = [ "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel index 2f68d4a3c..95c7d07cb 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") CASSANDRA_COMPILE_DEPS = [ "@nv_third_party_deps//:at_yawk_lz4_lz4_java", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel index 9ad6e524a..bb65cc339 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel @@ -1,5 +1,5 @@ load("@rules_java//java:defs.bzl", "java_library") -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") CORE_REQUIRED_DEPS = [ "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel index 39b7b73cd..bfc64bdbc 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") DATA_MIGRATION_NOTIFICATION_COMPILE_DEPS = [ "@nv_third_party_deps//:io_cloudevents_cloudevents_api", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel index fe3126253..9fb7fabc1 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel @@ -1,5 +1,5 @@ load("@rules_java//java:defs.bzl", "java_library") -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") EXCEPTIONS_REQUIRED_DEPS = [ "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel index 8e2b15054..1f246808c 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") JWT_COMPILE_DEPS = [ "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel index cc4df6788..a78a84da5 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel @@ -1,5 +1,5 @@ load("@rules_java//java:defs.bzl", "java_library") -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") OBSERVABILITY_REQUIRED_DEPS = [ "@nv_third_party_deps//:ch_qos_logback_logback_classic", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel index 733da3cc8..5074efaa2 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") REGISTRIES_COMPILE_DEPS = [ "//src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions:nv_boot_starter_exceptions", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java index bcb02575d..af689f3f9 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java @@ -64,8 +64,7 @@ public class NgcArtifactRegistryClient { "Null response from getting token from registry %s"; private static final int PAGE_SIZE = 100; private static final long OAUTH_TTL_IN_SECONDS = 900; // 15 minutes - // The Regex is inspired from NGC helm service implementation - // https://gitlab-master.nvidia.com/ngc/cloud/helmchart-registry/-/blob/main/internal/proxy/helm_registry_proxy.go?ref_type=heads#L75 + // The Regex is inspired from the NGC helm service implementation. private static final String NGC_HELM_CHART_VERSION_URL_REGEX = "^(?:/api)?/(?[^/]+(?:/[^/]+)?)/charts/(?[a-zA-Z0-9-_]+)-(?v?\\d+(\\.\\d+){0,2}([-+][\\w.+-]+)?)\\.tgz(?:\\.prov)?$"; private static final Pattern HELM_CHART_VERSION_URL_REGEX_PATTERN = diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel index 9fd2ad9a6..51be2c950 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_workspace_runfiles") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_workspace_runfiles") RELOADABLE_PROPERTIES_COMPILE_DEPS = [ "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel index 0bf2c4e7f..d5a57b8aa 100644 --- a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel @@ -1,4 +1,4 @@ -load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") +load("//rules/java:defs.bzl", "nv_boot_library", "nv_boot_library_test") TELEMETRY_COMPILE_DEPS = [ "@nv_third_party_deps//:io_cloudevents_cloudevents_api", diff --git a/src/libraries/java/nv-boot-parent/pom.xml b/src/libraries/java/nv-boot-parent/pom.xml index 96500c265..d7d223ac4 100644 --- a/src/libraries/java/nv-boot-parent/pom.xml +++ b/src/libraries/java/nv-boot-parent/pom.xml @@ -62,23 +62,23 @@ - https://gitlab-master.nvidia.com/nvcf/nvcf-libraries/java/nv-boot-parent + https://github.com/NVIDIA/nvcf - + - nv-central - https://urm.nvidia.com/artifactory/maven + google-maven-central + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2 false - + - google-maven-central - GCS Maven Central mirror - https://maven-central.storage-download.googleapis.com/maven2 + maven-central + https://repo.maven.apache.org/maven2 false @@ -86,37 +86,25 @@ - + - nv-central - https://urm.nvidia.com/artifactory/maven + google-maven-central + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2 false - + - google-maven-central - GCS Maven Central mirror - https://maven-central.storage-download.googleapis.com/maven2 + maven-central + https://repo.maven.apache.org/maven2 false - - - - nvcf - https://urm.nvidia.com/artifactory/sw-nvcf-maven - - - nvcf - https://urm.nvidia.com/artifactory/sw-nvcf-maven - - - nv-boot-bom nv-boot-mock-servers-test diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel b/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel deleted file mode 100644 index 5dca0ab32..000000000 --- a/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel +++ /dev/null @@ -1,61 +0,0 @@ -load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_plugin") -load("@rules_python//python:defs.bzl", "py_binary") -load("@rules_shell//shell:sh_test.bzl", "sh_test") - -package(default_visibility = ["//visibility:public"]) - -exports_files([ - "jacoco_test_runner.sh", - "lcov_to_sonar_generic.py", - "lcov_to_sonar_generic_test.sh", - "notice_metadata.json", - "notice_roots.json", -]) - -py_binary( - name = "generate_notice_tool", - srcs = ["generate_notice.py"], - main = "generate_notice.py", - python_version = "3.11", -) - -sh_test( - name = "lcov_to_sonar_generic_test", - srcs = ["lcov_to_sonar_generic_test.sh"], - data = [":lcov_to_sonar_generic.py"], -) - -sh_test( - name = "notice_check_test", - srcs = ["notice_check_test.sh"], - data = [ - ":generate_notice.py", - ":notice_metadata.json", - "//:MODULE.bazel", - # nv-boot-parent's own NOTICE (its Java closure), not the aggregate root - # NOTICE which also covers the Go/Rust subtrees and can never match a - # Java-only regeneration. - "//src/libraries/java/nv-boot-parent:NOTICE", - "//:maven_install.json", - ":notice_roots.json", - ], -) - -java_plugin( - name = "lombok_plugin", - generates_api = True, - processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", - deps = ["@nv_third_party_deps//:org_projectlombok_lombok"], -) - -java_binary( - name = "jacoco_cli", - main_class = "org.jacoco.cli.internal.Main", - runtime_deps = ["@nv_third_party_deps//:org_jacoco_org_jacoco_cli"], -) - -java_library( - name = "lombok_annotations", - exports = ["@nv_third_party_deps//:org_projectlombok_lombok"], - neverlink = True, -) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl b/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl deleted file mode 100644 index ef2558f31..000000000 --- a/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl +++ /dev/null @@ -1,210 +0,0 @@ -load("@rules_java//java:defs.bzl", _java_binary = "java_binary", _java_library = "java_library") -load("@rules_java//java/common:java_info.bzl", "JavaInfo") -load("@rules_shell//shell:sh_test.bzl", _sh_test = "sh_test") - -NV_JAVA_JAVACOPTS = [ - "-Xlint:deprecation", -] - -NV_LOMBOK_COMPILE_DEPS = [ - "//src/libraries/java/nv-boot-parent/tools/bazel:lombok_annotations", -] - -NV_LOMBOK_PLUGINS = [ - "//src/libraries/java/nv-boot-parent/tools/bazel:lombok_plugin", -] - -NV_JUNIT5_ARGS = [ - "execute", - "--details=flat", - "--disable-ansi-colors", - "--details-theme=ascii", - "--include-classname=.*(Test|IntegrationTest)", - "--fail-if-no-tests", -] - -NV_JUNIT5_RUNTIME_DEPS = [ - "@nv_third_party_deps//:org_junit_platform_junit_platform_console_standalone", -] - -NV_JUNIT5_COMPILE_DEPS = [ - "@nv_third_party_deps//:org_assertj_assertj_core", - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", - "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_params", - "@nv_third_party_deps//:org_mockito_mockito_core", - "@nv_third_party_deps//:org_mockito_mockito_junit_jupiter", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", - "@nv_third_party_deps//:org_springframework_spring_test", -] - -NV_MOCKITO_CORE = "@nv_third_party_deps//:org_mockito_mockito_core" - -NV_MOCKITO_AGENT_DATA = [ - NV_MOCKITO_CORE, -] - -NV_MOCKITO_AGENT_JVM_FLAGS = [ - "-javaagent:$(location %s)" % NV_MOCKITO_CORE, -] - -NV_JACOCO_AGENT = "@nv_third_party_deps//:org_jacoco_org_jacoco_agent_runtime" - -NV_JACOCO_AGENT_DATA = [ - NV_JACOCO_AGENT, -] - -NV_JACOCO_AGENT_JVM_FLAGS = [ - ( - "-javaagent:$(location %s)=destfile=jacoco.exec,append=false," - + "dumponexit=true,includes=com.nvidia.*" - ) % NV_JACOCO_AGENT, -] - -def _nv_boot_runtime_classpath_test_impl(ctx): - runtime_jars = ctx.attr.target[JavaInfo].transitive_runtime_jars.to_list() - leaked = [] - - for jar in runtime_jars: - for artifact in ctx.attr.forbidden_artifacts: - if artifact in jar.basename: - leaked.append(jar.short_path) - break - - if leaked: - fail( - "%s exports Maven-optional/provided runtime jars:\n%s" % ( - ctx.attr.target.label, - "\n".join(sorted(leaked)), - ), - ) - - executable = ctx.actions.declare_file(ctx.label.name + ".sh") - ctx.actions.write( - output = executable, - content = "#!/bin/sh\nexit 0\n", - is_executable = True, - ) - return [DefaultInfo(executable = executable)] - -nv_boot_runtime_classpath_test = rule( - implementation = _nv_boot_runtime_classpath_test_impl, - attrs = { - "forbidden_artifacts": attr.string_list(mandatory = True), - "target": attr.label(mandatory = True, providers = [JavaInfo]), - }, - test = True, -) - -def _nv_boot_workspace_runfiles_impl(ctx): - symlinks = {} - strip_prefix = ctx.attr.strip_prefix - - for src in ctx.files.srcs: - runfiles_path = src.short_path - if strip_prefix: - if not runfiles_path.startswith(strip_prefix): - fail("Expected %s to start with strip_prefix %s" % (runfiles_path, strip_prefix)) - runfiles_path = runfiles_path[len(strip_prefix):] - - if runfiles_path in symlinks: - fail("Duplicate runfiles path: %s" % runfiles_path) - symlinks[runfiles_path] = src - - return [DefaultInfo(runfiles = ctx.runfiles(symlinks = symlinks))] - -nv_boot_workspace_runfiles = rule( - implementation = _nv_boot_workspace_runfiles_impl, - attrs = { - "srcs": attr.label_list(allow_files = True), - "strip_prefix": attr.string(), - }, -) - -def nv_boot_library( - name, - srcs, - deps = [], - resources = [], - runtime_deps = [], - visibility = None, - resource_strip_prefix = ""): - _java_library( - name = name, - srcs = srcs, - deps = deps + NV_LOMBOK_COMPILE_DEPS, - javacopts = NV_JAVA_JAVACOPTS, - plugins = NV_LOMBOK_PLUGINS, - resources = resources, - resource_strip_prefix = resource_strip_prefix, - runtime_deps = runtime_deps, - visibility = visibility, - ) - -def nv_boot_library_test( - name, - srcs, - deps, - coverage_library, - data = [], - junit_classpath = [], - jvm_flags = [], - resources = [], - runtime_deps = [], - size = "small", - tags = [], - timeout = "short", - resource_strip_prefix = ""): - if type(coverage_library) != "string" or not coverage_library.startswith(":"): - fail( - "coverage_library must be the module library target as a local " - + "label starting with ':'", - ) - - coverage_sourcefiles = native.glob(["src/main/java/**/*.java"]) - coverage_source_root = native.package_name() + "/src/main/java" - junit_runner = name + "_junit_runner" - - _java_binary( - name = junit_runner, - srcs = srcs, - data = data + NV_MOCKITO_AGENT_DATA + NV_JACOCO_AGENT_DATA, - deps = deps + NV_LOMBOK_COMPILE_DEPS + NV_JUNIT5_COMPILE_DEPS, - javacopts = NV_JAVA_JAVACOPTS, - jvm_flags = NV_JACOCO_AGENT_JVM_FLAGS + NV_MOCKITO_AGENT_JVM_FLAGS + jvm_flags, - main_class = "org.junit.platform.console.ConsoleLauncher", - plugins = NV_LOMBOK_PLUGINS, - resources = resources, - resource_strip_prefix = resource_strip_prefix, - runtime_deps = runtime_deps + NV_JUNIT5_RUNTIME_DEPS, - tags = ["manual"], - testonly = True, - visibility = ["//visibility:private"], - ) - - _sh_test( - name = name, - srcs = ["//src/libraries/java/nv-boot-parent/tools/bazel:jacoco_test_runner.sh"], - args = [ - "$(location :%s)" % junit_runner, - "$(location %s)" % coverage_library, - coverage_source_root if coverage_sourcefiles else "", - native.package_name(), - "$(location //src/libraries/java/nv-boot-parent/tools/bazel:jacoco_cli)", - ] + NV_JUNIT5_ARGS + [ - "--class-path=$(location :%s.jar)" % junit_runner, - "--scan-classpath=$(location :%s.jar)" % junit_runner, - ] + [ - "--class-path=%s" % path - for path in junit_classpath - ], - data = [ - ":" + junit_runner, - ":%s.jar" % junit_runner, - coverage_library, - "//src/libraries/java/nv-boot-parent/tools/bazel:jacoco_cli", - ] + coverage_sourcefiles, - size = size, - tags = tags, - timeout = timeout, - ) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh b/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh deleted file mode 100755 index 8e8a3dc8a..000000000 --- a/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -find_workspace_above() { - local candidate="$1" - while [[ "${candidate}" != "/" ]]; do - if [[ -f "${candidate}/MODULE.bazel" ]]; then - printf '%s\n' "${candidate}" - return - fi - candidate="$(dirname "${candidate}")" - done - return 1 -} - -find_workspace() { - if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then - printf '%s\n' "${BUILD_WORKSPACE_DIRECTORY}" - return - fi - - local candidate - for runfiles_root in "${TEST_SRCDIR:-}" "${RUNFILES_DIR:-}"; do - if [[ -z "${runfiles_root}" ]]; then - continue - fi - for candidate in "${runfiles_root}/_main" "${runfiles_root}/nv_boot_parent"; do - if [[ -f "${candidate}/MODULE.bazel" ]]; then - printf '%s\n' "${candidate}" - return - fi - done - done - - local script_dir - script_dir="$(cd "$(dirname "$0")" && pwd)" - - local workspace - if workspace="$(find_workspace_above "${script_dir}")"; then - printf '%s\n' "${workspace}" - return - fi - - printf 'Could not find MODULE.bazel in test runfiles\n' >&2 - exit 1 -} - -workspace="$(find_workspace)" -# nv-boot-parent is a subtree of the monorepo, not the workspace root, so its -# notice tooling and its own NOTICE live under this prefix rather than at the -# repo root. The Maven lock, however, is the single root maven_install.json. -nvboot="${workspace}/src/libraries/java/nv-boot-parent" - -PYTHONPATH="${nvboot}/tools/bazel" python3 - <<'PY' -import pathlib -import tempfile -import zipfile - -from generate_notice import dependency_closure, parse_pom_xml, runtime_jar_coordinates - -parse_pom_xml("safe") -for unsafe_xml in ( - '', - ']>&name;', -): - try: - parse_pom_xml(unsafe_xml) - except ValueError: - continue - raise AssertionError("POM parser accepted a DTD or entity declaration") - -try: - dependency_closure( - ["example:missing:1.0"], - {"artifacts": {}, "dependencies": {}}, - (), - ) -except ValueError: - pass -else: - raise AssertionError("NOTICE closure silently accepted a missing dependency root") - -with tempfile.TemporaryDirectory() as directory: - app_jar = pathlib.Path(directory, "app.jar") - with zipfile.ZipFile(app_jar, "w") as archive: - archive.writestr( - "BOOT-INF/lib/external/rules_jvm_external++maven+nv_third_party_deps/" - "com/example/demo/1.0/demo-1.0.jar", - b"", - ) - try: - runtime_jar_coordinates( - [app_jar], - {"artifacts": {"com.example:demo": {"version": "2.0"}}}, - (), - ) - except ValueError: - pass - else: - raise AssertionError("Runtime NOTICE accepted a stale lockfile version") -PY - -exec python3 "${nvboot}/tools/bazel/generate_notice.py" \ - --maven-install "${workspace}/maven_install.json" \ - --metadata "${nvboot}/tools/bazel/notice_metadata.json" \ - --notice "${nvboot}/NOTICE" \ - --root-manifest "${nvboot}/tools/bazel/notice_roots.json" \ - --check diff --git a/tools/bazel/java/BUILD.bazel b/tools/bazel/java/BUILD.bazel new file mode 100644 index 000000000..6d45cff13 --- /dev/null +++ b/tools/bazel/java/BUILD.bazel @@ -0,0 +1,81 @@ +load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_plugin") +load("@rules_proto_grpc//:defs.bzl", "proto_plugin") +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") +load("@rules_shell//shell:sh_test.bzl", "sh_test") + +# Root-owned executable helpers and shared build targets for Java subtrees. +# Reusable Starlark macros live under //rules/java and reference these targets. +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "generate_notice.py", + "jacoco_test_runner.sh", + "license_aliases.json", + "lcov_to_sonar_generic.py", + "notice_diff_test.sh", +]) + +# Lombok annotation processing (shared by both Java compile profiles). +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@nv_third_party_deps//:org_projectlombok_lombok"], +) + +java_library( + name = "lombok_annotations", + exports = ["@nv_third_party_deps//:org_projectlombok_lombok"], + neverlink = True, +) + +# JaCoCo CLI used by the shared coverage test runner. +java_binary( + name = "jacoco_cli", + main_class = "org.jacoco.cli.internal.Main", + runtime_deps = ["@nv_third_party_deps//:org_jacoco_org_jacoco_cli"], +) + +# NOTICE generator (root-owned). Service NOTICE metadata stays service-local; +# this tool only implements the generation/drift algorithm. +py_binary( + name = "generate_notice_tool", + srcs = ["generate_notice.py"], + main = "generate_notice.py", + python_version = "3.11", +) + +py_library( + name = "generate_notice_lib", + srcs = ["generate_notice.py"], + imports = ["."], +) + +py_test( + name = "generate_notice_test", + srcs = ["generate_notice_test.py"], + deps = [":generate_notice_lib"], + main = "generate_notice_test.py", + python_version = "3.11", + size = "small", +) + +# gRPC-Java codegen plugin (pinned native binary, version tracks GRPC_VERSION +# in the root MODULE.bazel). Consumed by //rules/java:proto.bzl. +proto_plugin( + name = "grpc_java_1_63_plugin", + out = "{name}_grpc.jar", + tool = select({ + "@rules_proto_grpc//platforms:darwin_arm64": "@grpc_java_plugin_osx_aarch_64//file:protoc-gen-grpc-java.exe", + "@rules_proto_grpc//platforms:darwin_x86_64": "@grpc_java_plugin_osx_x86_64//file:protoc-gen-grpc-java.exe", + "@rules_proto_grpc//platforms:linux_aarch64": "@grpc_java_plugin_linux_aarch_64//file:protoc-gen-grpc-java.exe", + "@rules_proto_grpc//platforms:linux_x86_64": "@grpc_java_plugin_linux_x86_64//file:protoc-gen-grpc-java.exe", + "@rules_proto_grpc//platforms:windows_x86_64": "@grpc_java_plugin_windows_x86_64//file:protoc-gen-grpc-java.exe", + }), +) + +sh_test( + name = "lcov_to_sonar_generic_test", + srcs = ["lcov_to_sonar_generic_test.sh"], + data = [":lcov_to_sonar_generic.py"], +) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py b/tools/bazel/java/generate_notice.py similarity index 64% rename from src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py rename to tools/bazel/java/generate_notice.py index bb9e5f3e6..0268bae56 100644 --- a/src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py +++ b/tools/bazel/java/generate_notice.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Generates the third-party NOTICE file from Bazel dependency metadata. -The build/test path is hermetic: it reads checked-in root manifests, -`maven_install.json`, and generated upstream POM metadata. The update path can -refresh the metadata from local Maven POM cache files or remote Maven -repositories, similar to repinning `maven_install.json`. +The build/test path is hermetic: it reads `maven_install.json`, checked-in +component metadata, and either declared library roots or a packaged runtime +jar. Only the explicit metadata-update path reads dependency POMs from a local +Maven cache or remote Maven-compatible repository. """ import argparse @@ -67,6 +67,70 @@ def load_json(path): return json.loads(path.read_text()) +def metadata_artifacts(metadata): + return metadata.get("artifacts", {}) + + +def merge_metadata(shared_documents, primary_document, reject_primary_overlap=True): + """Merges shared metadata with component-owned metadata.""" + merged = {} + for source, document in shared_documents: + for coordinate, entry in metadata_artifacts(document).items(): + existing = merged.get(coordinate) + if existing is not None and existing != entry: + raise ValueError( + f"Conflicting shared NOTICE metadata for {coordinate}: {source}" + ) + merged[coordinate] = entry + + for coordinate, entry in metadata_artifacts(primary_document).items(): + existing = merged.get(coordinate) + if existing is not None: + if existing != entry: + raise ValueError( + f"Component NOTICE metadata conflicts with shared metadata for {coordinate}" + ) + if reject_primary_overlap: + raise ValueError( + f"Component NOTICE metadata duplicates shared metadata for {coordinate}" + ) + merged[coordinate] = entry + + return {"artifacts": merged} + + +def prune_shared_metadata(primary_document, shared_documents): + """Removes exact shared entries from component-owned metadata.""" + shared = merge_metadata(shared_documents, {"artifacts": {}}, False) + artifacts = {} + for coordinate, entry in metadata_artifacts(primary_document).items(): + shared_entry = metadata_artifacts(shared).get(coordinate) + if shared_entry is not None: + if shared_entry != entry: + raise ValueError( + f"Component NOTICE metadata conflicts with shared metadata for {coordinate}" + ) + continue + artifacts[coordinate] = entry + return { + "generated_by": primary_document.get( + "generated_by", + "tools/bazel/java/generate_notice.py", + ), + "artifacts": {key: artifacts[key] for key in sorted(artifacts)}, + } + + +def load_license_aliases(path): + if not path: + return {} + return load_json(path).get("aliases", {}) + + +def normalized_licenses(licenses, aliases): + return sorted({aliases.get(license_name, license_name) for license_name in licenses}) + + def load_root_manifests(paths): roots = [] for path in paths: @@ -199,18 +263,107 @@ def generated_notice(coordinates, maven_install, metadata): raise ValueError( "Missing NOTICE metadata for:\n" + "\n".join(f" {coordinate}" for coordinate in missing) - + "\nRun: python3 tools/bazel/generate_notice.py --update-metadata --write" + + "\nRun the component's Bazel generate_notice target with " + "--update-metadata --write" ) if incomplete: raise ValueError( "Incomplete NOTICE metadata for:\n" + "\n".join(f" {coordinate}" for coordinate in incomplete) - + "\nFix tools/bazel/notice_metadata.json or rerun metadata generation after POM metadata is available." + + "\nFix the component notice_metadata.json or rerun metadata " + "generation after POM metadata is available." ) lines.append("") return "\n".join(lines) +def generated_inventory(coordinates, maven_install, metadata, aliases): + dependencies = [] + for key in coordinates: + artifact = maven_install["artifacts"].get(key) + if not artifact: + continue + group_id, artifact_id = key.split(":", 1) + coordinate = versioned_coordinate( + group_id, + artifact_id, + artifact["version"], + ) + entry = metadata_artifacts(metadata).get(coordinate) + if not entry: + raise ValueError(f"Missing NOTICE metadata for {coordinate}") + dependencies.append( + { + "coordinate": coordinate, + "licenses": normalized_licenses(entry.get("licenses", []), aliases), + "name": entry.get("name") or artifact_id, + "url": entry.get("url") or "", + } + ) + return { + "generated_by": "tools/bazel/java/generate_notice.py", + "dependencies": dependencies, + } + + +def inventory_coordinates(inventory): + return { + dependency["coordinate"] + for dependency in inventory.get("dependencies", []) + } + + +def generated_delta(inventory, baseline_inventories): + baseline_coordinates = set() + for baseline in baseline_inventories: + baseline_coordinates.update(inventory_coordinates(baseline)) + + dependencies = [ + dependency + for dependency in inventory.get("dependencies", []) + if dependency["coordinate"] not in baseline_coordinates + ] + dependencies.sort(key=lambda dependency: dependency["coordinate"]) + + groups = {} + for dependency in dependencies: + licenses = dependency.get("licenses") or ["UNKNOWN"] + group = " / ".join(sorted(licenses)) + groups.setdefault(group, []).append(dependency) + + return { + "generated_by": "tools/bazel/java/generate_notice.py", + "dependencies": dependencies, + "groups": {key: groups[key] for key in sorted(groups)}, + } + + +def generated_delta_markdown(delta): + dependencies = delta["dependencies"] + lines = [ + "# OSRB Dependency Delta", + "", + ( + f"{len(dependencies)} new or additional third-party " + "dependencies require review." + ), + "", + ] + if not dependencies: + lines.extend(["No new or additional dependencies were found.", ""]) + return "\n".join(lines) + + for license_group, entries in delta["groups"].items(): + lines.extend([f"## {license_group}", ""]) + for entry in entries: + suffix = f" - {entry['url']}" if entry.get("url") else "" + lines.append( + f"- `{entry['coordinate']}` - {entry['name']}{suffix}" + ) + lines.append("") + return "\n".join(lines) + + def compare_or_write(expected, notice_path, write): if write: notice_path.write_text(expected) @@ -381,9 +534,15 @@ def resolve(self, group_id, artifact_id, version): return result -def update_metadata(coordinates, maven_install, existing_metadata): +def update_metadata( + coordinates, + maven_install, + existing_metadata, + shared_metadata=None, +): resolver = PomMetadataResolver(maven_install) artifacts = dict(existing_metadata.get("artifacts", {})) + shared_artifacts = metadata_artifacts(shared_metadata or {}) for key in coordinates: artifact = maven_install["artifacts"].get(key) if not artifact: @@ -391,6 +550,8 @@ def update_metadata(coordinates, maven_install, existing_metadata): group_id, artifact_id = key.split(":", 1) version = artifact["version"] versioned = versioned_coordinate(group_id, artifact_id, version) + if versioned in shared_artifacts: + continue resolved = resolver.resolve(group_id, artifact_id, version) artifacts[versioned] = { "licenses": resolved["licenses"], @@ -398,7 +559,7 @@ def update_metadata(coordinates, maven_install, existing_metadata): "url": resolved["url"], } return { - "generated_by": "tools/bazel/generate_notice.py --update-metadata", + "generated_by": "tools/bazel/java/generate_notice.py --update-metadata", "artifacts": {key: artifacts[key] for key in sorted(artifacts)}, } @@ -417,22 +578,81 @@ def current_notice_coordinates(path): def main(): parser = argparse.ArgumentParser() parser.add_argument("--maven-install", default="maven_install.json") - parser.add_argument("--metadata", default="tools/bazel/notice_metadata.json") + parser.add_argument("--metadata") + parser.add_argument("--shared-metadata", action="append", default=[]) parser.add_argument("--notice", default="NOTICE") parser.add_argument("--root-manifest", action="append", default=[]) parser.add_argument("--runtime-jar", action="append", default=[]) parser.add_argument("--first-party-group", action="append", default=[]) + parser.add_argument("--license-aliases") parser.add_argument("--output") + parser.add_argument("--inventory-output") + parser.add_argument("--delta-inventory") + parser.add_argument("--baseline-inventory", action="append", default=[]) + parser.add_argument("--delta-json-output") + parser.add_argument("--delta-markdown-output") parser.add_argument("--check", action="store_true") parser.add_argument("--write", action="store_true") parser.add_argument("--update-metadata", action="store_true") + parser.add_argument("--prune-shared-metadata", action="store_true") args = parser.parse_args() + if args.delta_inventory: + if not args.delta_json_output or not args.delta_markdown_output: + parser.error( + "--delta-inventory requires --delta-json-output and " + "--delta-markdown-output" + ) + inventory = load_json(pathlib.Path(args.delta_inventory)) + baselines = [ + load_json(pathlib.Path(path)) + for path in args.baseline_inventory + ] + delta = generated_delta(inventory, baselines) + pathlib.Path(args.delta_json_output).write_text( + json.dumps(delta, indent=2, sort_keys=True) + "\n" + ) + pathlib.Path(args.delta_markdown_output).write_text( + generated_delta_markdown(delta) + ) + print( + f"OSRB delta generated for {len(delta['dependencies'])} " + "new or additional dependencies" + ) + return 0 + + if not args.metadata: + parser.error("--metadata is required for NOTICE generation") + maven_install_path = pathlib.Path(args.maven_install) metadata_path = pathlib.Path(args.metadata) notice_path = pathlib.Path(args.notice) maven_install = load_json(maven_install_path) - metadata = load_json(metadata_path) if metadata_path.exists() else {"artifacts": {}} + primary_metadata = ( + load_json(metadata_path) + if metadata_path.exists() + else {"artifacts": {}} + ) + shared_documents = [ + (path, load_json(pathlib.Path(path))) + for path in args.shared_metadata + ] + + if args.prune_shared_metadata: + primary_metadata = prune_shared_metadata( + primary_metadata, + shared_documents, + ) + metadata_path.write_text( + json.dumps(primary_metadata, indent=2, sort_keys=True) + "\n" + ) + + shared_metadata = merge_metadata( + shared_documents, + {"artifacts": {}}, + False, + ) + metadata = merge_metadata(shared_documents, primary_metadata) first_party_groups = tuple(FIRST_PARTY_GROUPS) + tuple(args.first_party_group) roots = load_root_manifests([pathlib.Path(path) for path in args.root_manifest]) @@ -448,8 +668,16 @@ def main(): raise ValueError("At least one --root-manifest or --runtime-jar is required") if args.update_metadata: - metadata = update_metadata(coordinates, maven_install, metadata) - metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") + primary_metadata = update_metadata( + coordinates, + maven_install, + primary_metadata, + shared_metadata, + ) + metadata_path.write_text( + json.dumps(primary_metadata, indent=2, sort_keys=True) + "\n" + ) + metadata = merge_metadata(shared_documents, primary_metadata) notice = generated_notice(coordinates, maven_install, metadata) output_path = pathlib.Path(args.output) if args.output else notice_path @@ -457,11 +685,26 @@ def main(): if diff: sys.stderr.write(diff) sys.stderr.write( - "\nRun: python3 tools/bazel/generate_notice.py --update-metadata --write " - "with the same --root-manifest or --runtime-jar inputs\n" + "\nRun the component's Bazel generate_notice target with --write " + "(and --update-metadata when dependency metadata changed)\n" ) return 1 + if args.inventory_output: + inventory = generated_inventory( + coordinates, + maven_install, + metadata, + load_license_aliases( + pathlib.Path(args.license_aliases) + if args.license_aliases + else None + ), + ) + pathlib.Path(args.inventory_output).write_text( + json.dumps(inventory, indent=2, sort_keys=True) + "\n" + ) + if args.check: generated_coordinates = set() for key in coordinates: diff --git a/tools/bazel/java/generate_notice_test.py b/tools/bazel/java/generate_notice_test.py new file mode 100644 index 000000000..203d1aa85 --- /dev/null +++ b/tools/bazel/java/generate_notice_test.py @@ -0,0 +1,86 @@ +import unittest + +import generate_notice + + +class NoticeMetadataTest(unittest.TestCase): + def test_merge_rejects_component_duplicate(self): + entry = {"licenses": ["MIT"], "name": "Example", "url": ""} + shared = [("shared.json", {"artifacts": {"g:a:1": entry}})] + + with self.assertRaisesRegex(ValueError, "duplicates shared metadata"): + generate_notice.merge_metadata( + shared, + {"artifacts": {"g:a:1": entry}}, + ) + + def test_prune_removes_exact_shared_entries(self): + shared_entry = {"licenses": ["MIT"], "name": "Shared", "url": ""} + local_entry = {"licenses": ["Apache-2.0"], "name": "Local", "url": ""} + result = generate_notice.prune_shared_metadata( + {"artifacts": {"g:a:1": shared_entry, "g:b:2": local_entry}}, + [("shared.json", {"artifacts": {"g:a:1": shared_entry}})], + ) + + self.assertEqual({"g:b:2": local_entry}, result["artifacts"]) + + def test_inventory_normalizes_unambiguous_license_aliases(self): + inventory = generate_notice.generated_inventory( + ["g:a"], + {"artifacts": {"g:a": {"version": "1"}}}, + { + "artifacts": { + "g:a:1": { + "licenses": ["Apache License, Version 2.0"], + "name": "Example", + "url": "https://example.invalid", + } + } + }, + {"Apache License, Version 2.0": "Apache-2.0"}, + ) + + self.assertEqual( + ["Apache-2.0"], + inventory["dependencies"][0]["licenses"], + ) + + def test_delta_uses_exact_versioned_coordinates(self): + current = { + "dependencies": [ + { + "coordinate": "g:a:2", + "licenses": ["MIT"], + "name": "A", + "url": "", + }, + { + "coordinate": "g:b:1", + "licenses": ["Apache-2.0"], + "name": "B", + "url": "", + }, + ] + } + baseline = { + "dependencies": [ + { + "coordinate": "g:a:1", + "licenses": ["MIT"], + "name": "A", + "url": "", + } + ] + } + + delta = generate_notice.generated_delta(current, [baseline]) + + self.assertEqual( + ["g:a:2", "g:b:1"], + [entry["coordinate"] for entry in delta["dependencies"]], + ) + self.assertIn("## MIT", generate_notice.generated_delta_markdown(delta)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/jacoco_test_runner.sh b/tools/bazel/java/jacoco_test_runner.sh similarity index 100% rename from src/libraries/java/nv-boot-parent/tools/bazel/jacoco_test_runner.sh rename to tools/bazel/java/jacoco_test_runner.sh diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic.py b/tools/bazel/java/lcov_to_sonar_generic.py similarity index 100% rename from src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic.py rename to tools/bazel/java/lcov_to_sonar_generic.py diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic_test.sh b/tools/bazel/java/lcov_to_sonar_generic_test.sh similarity index 100% rename from src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic_test.sh rename to tools/bazel/java/lcov_to_sonar_generic_test.sh diff --git a/tools/bazel/java/license_aliases.json b/tools/bazel/java/license_aliases.json new file mode 100644 index 000000000..81e47f869 --- /dev/null +++ b/tools/bazel/java/license_aliases.json @@ -0,0 +1,23 @@ +{ + "aliases": { + "Apache 2": "Apache-2.0", + "Apache 2.0": "Apache-2.0", + "Apache License 2.0": "Apache-2.0", + "Apache License, Version 2.0": "Apache-2.0", + "Apache License, version 2.0": "Apache-2.0", + "Apache Software License, version 2.0": "Apache-2.0", + "EPL 2.0": "EPL-2.0", + "Eclipse Distribution License - v 1.0": "EDL-1.0", + "Eclipse Public License - v 2.0": "EPL-2.0", + "GNU General Public License Version 2": "GPL-2.0-only", + "GNU Lesser General Public License Version 2.1": "LGPL-2.1-only", + "GPL2 w/ CPE": "GPL-2.0-only WITH Classpath-exception-2.0", + "Lesser General Public License, version 3 or greater": "LGPL-3.0-or-later", + "MIT License": "MIT", + "MIT license": "MIT", + "Public Domain, per Creative Commons CC0": "CC0-1.0", + "The Apache License, Version 2.0": "Apache-2.0", + "The Apache Software License, Version 2.0": "Apache-2.0", + "The MIT License": "MIT" + } +} diff --git a/tools/bazel/java/notice_diff_test.sh b/tools/bazel/java/notice_diff_test.sh new file mode 100755 index 000000000..4fc035622 --- /dev/null +++ b/tools/bazel/java/notice_diff_test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +generated="$1" +checked_in="$2" + +if ! diff -u "${checked_in}" "${generated}"; then + printf '\nNOTICE is stale. Run the package generate_notice target with --write.\n' >&2 + exit 1 +fi diff --git a/tools/ci/check-java-import-boundaries b/tools/ci/check-java-import-boundaries new file mode 100755 index 000000000..a133c06d5 --- /dev/null +++ b/tools/ci/check-java-import-boundaries @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +import_roots=( + "src/libraries/java/nv-boot-parent" + "src/control-plane-services/cloud-tasks" +) + +root_owned_files=( + ".bazel_downloader_config" + ".bazelignore" + ".bazelrc" + ".bazelversion" + "MODULE.bazel" + "MODULE.bazel.lock" + "WORKSPACE" + "WORKSPACE.bazel" + "maven_install.json" +) + +failed=false + +for import_root in "${import_roots[@]}"; do + for root_owned_file in "${root_owned_files[@]}"; do + path="${repo_root}/${import_root}/${root_owned_file}" + if [[ -e "${path}" ]]; then + echo "ERROR: root-owned Bazel file reintroduced: ${import_root}/${root_owned_file}" >&2 + failed=true + fi + done + + while IFS= read -r migration_dir; do + [[ -z "${migration_dir}" ]] && continue + echo "ERROR: standalone migration directory reintroduced: ${migration_dir#${repo_root}/}" >&2 + failed=true + done < <( + find "${repo_root}/${import_root}" -type d \ + \( -name bazel-enablement -o -name bazel-migration \) -print + ) +done + +if [[ "${failed}" == true ]]; then + cat >&2 <<'EOF' +nv-boot-parent and Cloud Tasks are packages in the root nvcf Bazel module. +Keep Bazel root configuration and dependency locks at the repository root, and +keep standalone migration records out of imported source trees. +EOF + exit 1 +fi + +echo "Java import boundaries: PASS" diff --git a/tools/ci/stage-bazel-java-artifacts b/tools/ci/stage-bazel-java-artifacts new file mode 100755 index 000000000..72723d178 --- /dev/null +++ b/tools/ci/stage-bazel-java-artifacts @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +subtree_id="${1:?usage: stage-bazel-java-artifacts SUBTREE_ID SUBTREE_PATH}" +subtree_path="${2:?usage: stage-bazel-java-artifacts SUBTREE_ID SUBTREE_PATH}" +artifact_root="${RUNNER_TEMP:?RUNNER_TEMP is required}/bazel-java-verification/${subtree_id}" +generated_root="${artifact_root}/generated" +testlogs_root="${artifact_root}/testlogs" + +mkdir -p "${generated_root}" "${testlogs_root}" + +source_testlogs="bazel-testlogs/${subtree_path}" +if [[ -d "${source_testlogs}" ]]; then + cp -R -L "${source_testlogs}/." "${testlogs_root}/" +fi + +source_outputs="bazel-bin/${subtree_path}" +for filename in \ + THIRD_PARTY_NOTICE \ + runtime_inventory.json \ + osrb_dependency_delta.json \ + osrb_dependency_delta.md; do + if [[ -f "${source_outputs}/${filename}" ]]; then + cp -L "${source_outputs}/${filename}" "${generated_root}/${filename}" + fi +done + +file_count="$(find "${artifact_root}" -type f | wc -l | tr -d ' ')" +junit_count="$(find "${testlogs_root}" -name 'TEST-junit-jupiter.xml' -type f | wc -l | tr -d ' ')" +jacoco_count="$(find "${testlogs_root}" -name 'jacoco.xml' -type f | wc -l | tr -d ' ')" + +echo "Staged ${file_count} files: ${junit_count} JUnit reports, ${jacoco_count} JaCoCo XML reports" +find "${generated_root}" -type f -print | LC_ALL=C sort diff --git a/tools/workspace_status.sh b/tools/workspace_status.sh index 1393379f0..562aecdd8 100755 --- a/tools/workspace_status.sh +++ b/tools/workspace_status.sh @@ -44,6 +44,18 @@ echo "STABLE_BUILD_USER ${BUILD_USER}" echo "STABLE_GO_VERSION ${GO_VERSION}" echo "STABLE_OCI_TAG ${VERSION}-${COMMIT}${DIRTY}" +# Java build metadata. The Spring Boot packaging macro (//rules/java:spring.bzl) +# stamps git.properties / maven.properties from these keys. Empty values are +# handled gracefully by the packaging rule. +FULL_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") +GIT_TAGS=$(git tag --points-at HEAD 2>/dev/null | paste -sd, - || true) +CLOSEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) +echo "STABLE_GIT_COMMIT_ID_FULL ${FULL_COMMIT}" +echo "STABLE_GIT_COMMIT_ID_ABBREV ${COMMIT}" +echo "STABLE_GIT_TAGS ${GIT_TAGS}" +echo "STABLE_GIT_CLOSEST_TAG_NAME ${CLOSEST_TAG}" +echo "STABLE_BUILD_VERSION ${NEXT_VERSION:-${VERSION}}" + # Stack OCI refs for the nvcf-cli ldflag stamping. Set by CI at release # build time; empty string is the correct default for dev builds (users # must pass --control-plane-stack / --compute-plane-stack explicitly).