diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 516598bc9c..d56c7fed54 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -258,6 +258,7 @@ "reusability", "rproj", "rstudio", + "runai", "scala", "scrollable", "selectautosuggest", diff --git a/client/.prettierignore b/client/.prettierignore index 567609b123..eeb9979ddd 100644 --- a/client/.prettierignore +++ b/client/.prettierignore @@ -1 +1,5 @@ build/ +public/config.json +# Do not format generated files +*generated-api.ts +.react-router/types diff --git a/client/scripts/update_api_spec.js b/client/scripts/update_api_spec.js index 0676c0f19c..8c44f04463 100644 --- a/client/scripts/update_api_spec.js +++ b/client/scripts/update_api_spec.js @@ -24,7 +24,7 @@ import { parseDocument } from "yaml"; const GH_BASE_URL = "https://raw.githubusercontent.com"; const DATA_SERVICES_REPO = "SwissDataScienceCenter/renku-data-services"; -const DATA_SERVICES_RELEASE = "main"; +const DATA_SERVICES_RELEASE = "ciyer/amalthea-runai"; async function main() { argv.forEach((arg) => { diff --git a/client/src/features/admin/AddResourcePoolButton.tsx b/client/src/features/admin/AddResourcePoolButton.tsx index a3334fbc6c..7091eb1dd9 100644 --- a/client/src/features/admin/AddResourcePoolButton.tsx +++ b/client/src/features/admin/AddResourcePoolButton.tsx @@ -109,11 +109,16 @@ function AddResourcePoolModal({ isOpen, toggle }: AddResourcePoolModalProps) { clusterId: "", remote: { enabled: false, - kind: "firecrest", - providerId: "", - apiUrl: "", - systemName: "", - partition: "", + kind: null, + firecrestConfiguration: { + providerId: "", + apiUrl: "", + systemName: "", + partition: "", + }, + runaiConfiguration: { + baseUrl: "", + }, }, }, }); @@ -136,19 +141,27 @@ function AddResourcePoolModal({ isOpen, toggle }: AddResourcePoolModalProps) { const clusterId = data.clusterId?.trim() ? data.clusterId.trim() : undefined; - const remote: RemoteConfiguration | undefined = data.remote.enabled - ? { - kind: data.remote.kind, - provider_id: data.remote.providerId?.trim() - ? data.remote.providerId.trim() - : undefined, - api_url: data.remote.apiUrl.trim(), - system_name: data.remote.systemName.trim(), - partition: data.remote.partition?.trim() - ? data.remote.partition.trim() - : undefined, - } - : undefined; + const remote: RemoteConfiguration | undefined = + data.remote.enabled == null + ? undefined + : data.remote.kind == "firecrest" + ? { + kind: "firecrest", + provider_id: data.remote.firecrestConfiguration.providerId?.trim() + ? data.remote.firecrestConfiguration.providerId.trim() + : undefined, + api_url: data.remote.firecrestConfiguration.apiUrl.trim(), + system_name: data.remote.firecrestConfiguration.systemName.trim(), + partition: data.remote.firecrestConfiguration.partition?.trim() + ? data.remote.firecrestConfiguration.partition.trim() + : undefined, + } + : data.remote.kind == "runai" + ? { + kind: "runai", + base_url: data.remote.runaiConfiguration.baseUrl.trim(), + } + : undefined; addResourcePool({ resourcePool: { classes: populatedClass ? [populatedClass] : [], @@ -185,10 +198,15 @@ function AddResourcePoolModal({ isOpen, toggle }: AddResourcePoolModalProps) { remote: { enabled: false, kind: "firecrest", - providerId: "", - apiUrl: "", - systemName: "", - partition: "", + firecrestConfiguration: { + providerId: "", + apiUrl: "", + systemName: "", + partition: "", + }, + runaiConfiguration: { + baseUrl: "", + }, }, }); }, [defaultQuota, reset]); diff --git a/client/src/features/admin/UpdateResourcePoolRemoteButton.tsx b/client/src/features/admin/UpdateResourcePoolRemoteButton.tsx index 8ff4c2cd29..0c7060b29f 100644 --- a/client/src/features/admin/UpdateResourcePoolRemoteButton.tsx +++ b/client/src/features/admin/UpdateResourcePoolRemoteButton.tsx @@ -35,9 +35,30 @@ import { usePatchResourcePoolsByResourcePoolIdMutation, type ResourcePoolWithId, } from "../sessionsV2/api/computeResources.api"; -import type { ResourcePoolForm } from "./adminComputeResources.types"; +import type { + RemoteConfiguration, + ResourcePoolForm, +} from "./adminComputeResources.types"; import ResourcePoolRemoteSection from "./forms/ResourcePoolRemoteSection"; +function remoteDefaultValues( + remote: ResourcePoolWithId["remote"] +): RemoteConfiguration { + return { + enabled: remote?.kind != null, + kind: remote?.kind ?? null, + firecrestConfiguration: { + providerId: remote?.provider_id ?? "", + apiUrl: remote?.kind === "firecrest" ? remote?.api_url ?? "" : "", + systemName: remote?.kind === "firecrest" ? remote?.system_name ?? "" : "", + partition: remote?.kind === "firecrest" ? remote?.partition ?? "" : "", + }, + runaiConfiguration: { + baseUrl: remote?.kind === "runai" ? remote?.base_url ?? "" : "", + }, + }; +} + interface UpdateResourcePoolRemoteButtonProps { resourcePool: ResourcePoolWithId; } @@ -87,31 +108,29 @@ function UpdateResourcePoolRemoteModal({ reset, } = useForm({ defaultValues: { - remote: { - enabled: remote != null, - kind: remote?.kind ?? "firecrest", - providerId: remote?.provider_id ?? "", - apiUrl: remote?.api_url ?? "", - systemName: remote?.system_name ?? "", - partition: remote?.partition ?? "", - }, + remote: remoteDefaultValues(remote), }, }); const onSubmit = useCallback( (data: UpdateResourcePoolRemoteForm) => { - const remote = data.remote.enabled + const remote = !data.remote.enabled + ? {} + : data.remote.kind === "runai" ? { kind: data.remote.kind, - provider_id: data.remote.providerId?.trim() - ? data.remote.providerId.trim() + base_url: data.remote.runaiConfiguration.baseUrl.trim(), + } + : { + kind: data.remote.kind, + provider_id: data.remote.firecrestConfiguration.providerId?.trim() + ? data.remote.firecrestConfiguration.providerId.trim() : undefined, - api_url: data.remote.apiUrl.trim(), - system_name: data.remote.systemName.trim(), - partition: data.remote.partition?.trim() - ? data.remote.partition.trim() + api_url: data.remote.firecrestConfiguration.apiUrl.trim(), + system_name: data.remote.firecrestConfiguration.systemName.trim(), + partition: data.remote.firecrestConfiguration.partition?.trim() + ? data.remote.firecrestConfiguration.partition.trim() : undefined, - } - : {}; + }; updateResourcePool({ resourcePoolId: id, resourcePoolPatch: { remote } }); }, [id, updateResourcePool] @@ -125,14 +144,7 @@ function UpdateResourcePoolRemoteModal({ useEffect(() => { reset({ - remote: { - enabled: remote != null, - kind: remote?.kind ?? "firecrest", - providerId: remote?.provider_id ?? "", - apiUrl: remote?.api_url ?? "", - systemName: remote?.system_name ?? "", - partition: remote?.partition ?? "", - }, + remote: remoteDefaultValues(remote), }); }, [remote, reset]); diff --git a/client/src/features/admin/adminComputeResources.types.ts b/client/src/features/admin/adminComputeResources.types.ts index 9c43cd5342..8a3134dc3c 100644 --- a/client/src/features/admin/adminComputeResources.types.ts +++ b/client/src/features/admin/adminComputeResources.types.ts @@ -16,6 +16,8 @@ * limitations under the License. */ +import { type RemoteConfiguration as BackendRemoteConfiguration } from "../sessionsV2/api/computeResources.generated-api"; + export interface ResourcePoolForm { name: string; public: boolean; @@ -34,14 +36,22 @@ export interface ResourcePoolFormQuota { export interface RemoteConfiguration { enabled: boolean; - /** Kind of remote resource pool */ - kind: "firecrest"; + kind: BackendRemoteConfiguration["kind"] | null; + firecrestConfiguration: RemoteConfigurationDetailsFirecrest; + runaiConfiguration: RemoteConfigurationDetailsRunai; +} + +interface RemoteConfigurationDetailsFirecrest { providerId?: string; apiUrl: string; systemName: string; partition?: string; } +interface RemoteConfigurationDetailsRunai { + baseUrl: string; +} + export interface ResourceClassForm { name: string; cpu: number; diff --git a/client/src/features/admin/forms/ResourcePoolRemoteSection.tsx b/client/src/features/admin/forms/ResourcePoolRemoteSection.tsx index cd24480220..9d63402c85 100644 --- a/client/src/features/admin/forms/ResourcePoolRemoteSection.tsx +++ b/client/src/features/admin/forms/ResourcePoolRemoteSection.tsx @@ -31,8 +31,6 @@ import { ExternalLink } from "~/components/LegacyExternalLinks"; import { NEW_DOCS_ADMIN_OPERATIONS_REMOTE_SESSIONS } from "~/utils/constants/NewDocs"; import type { RemoteConfiguration } from "../adminComputeResources.types"; -const DEFAULT_REMOTE_KIND_VALUE: RemoteConfiguration["kind"] = "firecrest"; - interface ResourcePoolRemoteSectionProps { className?: string; control: Control; @@ -49,6 +47,10 @@ export default function ResourcePoolRemoteSection({ const inputId = `${formPrefix}Remote`; const remoteEnabled = `${name}.enabled` as FieldPathByValue; const remoteEnabledWatch = useWatch({ control, name: remoteEnabled }); + const remoteKindWatch = useWatch({ + control, + name: `${name}.kind` as FieldPathByValue, + }); return (
@@ -93,59 +95,149 @@ export default function ResourcePoolRemoteSection({ - - - - - - - - + {remoteKindWatch === "firecrest" && ( + + } + /> + )} + {remoteKindWatch === "runai" && ( + + } + /> + )}
); } -interface ResourcePoolRemoteKindProps { +interface ResourcePoolRemoteKindProps { + control: Control; formPrefix: string; + name: FieldPathByValue; } -function ResourcePoolRemoteKind({ formPrefix }: ResourcePoolRemoteKindProps) { +function ResourcePoolRemoteKind({ + control, + formPrefix, + name, +}: ResourcePoolRemoteKindProps) { const inputId = `${formPrefix}Kind`; + const fieldName = `${name}.kind` as FieldPathByValue< + T, + RemoteConfiguration["kind"] + >; + + const kindOptions: { + value: RemoteConfiguration["kind"]; + label: string; + }[] = [ + { value: null, label: "None" }, + { value: "firecrest", label: "Firecrest" }, + { value: "runai", label: "Run:AI" }, + ]; + return (
- - Kind + ( +
+ {kindOptions.map(({ value, label }) => ( +
+ field.onChange(value)} + /> + +
+ ))} +
+ )} />
); } +function ResourcePoolRemoteSectionFirecrest({ + control, + formPrefix, + name, +}: ResourcePoolRemoteKindProps) { + return ( + <> + + + + + + + + + ); +} + +function ResourcePoolRemoteSectionRunai({ + control, + formPrefix, + name, +}: ResourcePoolRemoteKindProps) { + return ( + <> + + + ); +} + interface ResourcePoolRemoteStringInputProps { control: Control; formPrefix: string; @@ -226,9 +318,8 @@ function ResourcePoolRemoteApiUrl({ )} rules={{ validate: { - required: (value, formValues) => { - const remote = formValues[name] as RemoteConfiguration; - if (!remote.enabled || value) { + required: (value) => { + if (value) { return true; } return "Please provide a value for the API URL."; @@ -240,6 +331,51 @@ function ResourcePoolRemoteApiUrl({ ); } +function ResourcePoolRemoteBaseUrl({ + control, + formPrefix, + name, +}: ResourcePoolRemoteStringInputProps) { + const inputId = `${formPrefix}RemoteBaseUrl`; + const fieldName = `${name}.baseUrl` as FieldPathByValue; + + return ( +
+ + ( + <> + +
+ {error?.message ?? "Please provide a valid value for Base URL."} +
+ + )} + rules={{ + validate: { + required: (value) => { + if (!value || value.trim().length < 1) + return "Please provide a value for the Base URL."; + return true; + }, + }, + }} + /> +
+ ); +} + function ResourcePoolRemoteSystemName({ control, formPrefix, @@ -275,9 +411,8 @@ function ResourcePoolRemoteSystemName({ )} rules={{ validate: { - required: (value, formValues) => { - const remote = formValues[name] as RemoteConfiguration; - if (!remote.enabled || value) { + required: (value) => { + if (value) { return true; } return "Please provide a value for the system name."; diff --git a/client/src/features/sessionsV2/api/computeResources.generated-api.ts b/client/src/features/sessionsV2/api/computeResources.generated-api.ts index 9e5376adbe..98c48a1239 100644 --- a/client/src/features/sessionsV2/api/computeResources.generated-api.ts +++ b/client/src/features/sessionsV2/api/computeResources.generated-api.ts @@ -655,7 +655,16 @@ export type RemoteConfigurationFirecrest = { system_name: RemoteConfigurationFirecrestSystemName; partition?: RemoteConfigurationFirecrestPartition; }; -export type RemoteConfiguration = RemoteConfigurationFirecrest; +export type RemoteConfigurationRunaiApiUrl = string; +export type RemoteConfigurationRunai = { + /** Kind of remote resource pool */ + kind: "runai"; + base_url: RemoteConfigurationRunaiApiUrl; + provider_id?: RemoteConfigurationFirecrestProviderId; +}; +export type RemoteConfiguration = + | RemoteConfigurationFirecrest + | RemoteConfigurationRunai; export type IdleThreshold = number; export type HibernationThreshold = number; export type HibernationWarningPeriod = number; @@ -768,9 +777,16 @@ export type RemoteConfigurationFirecrestPatch = { system_name?: RemoteConfigurationFirecrestSystemName; partition?: RemoteConfigurationFirecrestPartition; }; +export type RemoteConfigurationRunaiPatch = { + /** Kind of remote resource pool */ + kind?: "runai"; + base_url?: RemoteConfigurationRunaiApiUrl; + provider_id?: RemoteConfigurationFirecrestProviderId; +}; export type RemoteConfigurationPatch = | RemoteConfigurationPatchReset - | RemoteConfigurationFirecrestPatch; + | RemoteConfigurationFirecrestPatch + | RemoteConfigurationRunaiPatch; export type ResourcePoolPatch = { quota?: QuotaPatch; classes?: ResourceClassesPatchWithId; diff --git a/client/src/features/sessionsV2/api/computeResources.openapi.json b/client/src/features/sessionsV2/api/computeResources.openapi.json index 745aa7c543..4363155b77 100644 --- a/client/src/features/sessionsV2/api/computeResources.openapi.json +++ b/client/src/features/sessionsV2/api/computeResources.openapi.json @@ -2622,6 +2622,9 @@ "oneOf": [ { "$ref": "#/components/schemas/RemoteConfigurationFirecrest" + }, + { + "$ref": "#/components/schemas/RemoteConfigurationRunai" } ] }, @@ -2671,6 +2674,31 @@ "description": "The partition to use when submitting jobs", "example": "normal" }, + "RemoteConfigurationRunai": { + "type": "object", + "description": "The configuration for starting sessions remotely using Runai\n", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["runai"], + "description": "Kind of remote resource pool", + "example": "runai" + }, + "base_url": { + "$ref": "#/components/schemas/RemoteConfigurationRunaiApiUrl" + }, + "provider_id": { + "$ref": "#/components/schemas/RemoteConfigurationFirecrestProviderId" + } + }, + "required": ["kind", "base_url"] + }, + "RemoteConfigurationRunaiApiUrl": { + "type": "string", + "description": "The base URL of the Runai server", + "example": "https://sdsc.run.ai" + }, "RemoteConfigurationPatch": { "type": "object", "description": "Patch for the configuration used by to start sessions remotely\n", @@ -2680,6 +2708,9 @@ }, { "$ref": "#/components/schemas/RemoteConfigurationFirecrestPatch" + }, + { + "$ref": "#/components/schemas/RemoteConfigurationRunaiPatch" } ] }, @@ -2713,6 +2744,25 @@ } } }, + "RemoteConfigurationRunaiPatch": { + "type": "object", + "description": "The configuration for starting sessions remotely using Runai\n", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["runai"], + "description": "Kind of remote resource pool", + "example": "runai" + }, + "base_url": { + "$ref": "#/components/schemas/RemoteConfigurationRunaiApiUrl" + }, + "provider_id": { + "$ref": "#/components/schemas/RemoteConfigurationFirecrestProviderId" + } + } + }, "IdleThreshold": { "type": "integer", "description": "A threshold in seconds after which a session gets hibernated (0 means no threshold)", diff --git a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts index 7e1f4eb78d..f57cac0ced 100644 --- a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts +++ b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts @@ -61,7 +61,8 @@ const injectedRtkApi = api.injectEndpoints({ export { injectedRtkApi as sessionsV2GeneratedApi }; export type PostSessionsApiResponse = /** status 200 The session already exists */ - SessionResponse | /** status 201 The session was created */ SessionResponse; + | SessionResponse + | /** status 201 The session was created */ SessionResponse; export type PostSessionsApiArg = { sessionPostRequest: SessionPostRequest; }; diff --git a/tests/cypress/e2e/adminPage.spec.ts b/tests/cypress/e2e/adminPage.spec.ts index 73b2e57bfc..b7e482ddec 100644 --- a/tests/cypress/e2e/adminPage.spec.ts +++ b/tests/cypress/e2e/adminPage.spec.ts @@ -203,4 +203,37 @@ describe("admin page", () => { .should("be.visible"); cy.get(".modal").contains("button", "Cancel").should("be.visible").click(); }); + + it("should show support remote resources", () => { + fixtures + .userAdmin() + .resourcePoolsTest() + .adminResourcePoolUsers() + .adminKeycloakUser() + .postResourcePoolWithRunaiRemote(); + cy.visit("/"); + cy.wait("@getUser"); + + cy.visit("/admin"); + + cy.get("h1").contains("Admin Panel").should("be.visible"); + + // Check the "Add Resource Pool" button + cy.get("button").contains("Add Resource Pool").should("be.visible").click(); + cy.get(".modal").within(() => { + cy.contains(".modal-title", "Add resource pool").should("be.visible"); + cy.get("#addResourcePoolRemote").should("be.visible").click(); + cy.get("#addResourcePoolName").should("be.visible").type("Remote pool"); + cy.get("#addResourcePoolKind-firecrest").should("be.visible"); + cy.get("#addResourcePoolKind-runai").should("be.visible").click(); + cy.get("#addResourcePoolRemoteBaseUrl") + .should("be.visible") + .type("https://runai.example.com"); + }); + cy.get(".modal") + .contains("button", "Add Resource Pool") + .should("be.visible") + .click(); + cy.wait("@postResourcePool"); + }); }); diff --git a/tests/cypress/fixtures/dataServices/resource-pools.json b/tests/cypress/fixtures/dataServices/resource-pools.json index 57b49b7ef9..de294e0b80 100644 --- a/tests/cypress/fixtures/dataServices/resource-pools.json +++ b/tests/cypress/fixtures/dataServices/resource-pools.json @@ -214,5 +214,36 @@ "matching": true } ] + }, + { + "id": 5, + "name": "Runai pool", + "default": false, + "public": false, + "idle_threshold": 99999, + "hibernation_threshold": 999999, + "quota": { + "cpu": 200, + "memory": 64000, + "gpu": 40, + "storage": 10000000 + }, + "remote": { + "kind": "runai", + "base_url": "https://runai.example.com" + }, + "classes": [ + { + "id": 11, + "name": "runai class 1", + "cpu": 2, + "memory": 64, + "gpu": 0, + "max_storage": 40, + "default_storage": 10, + "default": false, + "matching": true + } + ] } ] diff --git a/tests/cypress/support/renkulab-fixtures/dataServices.ts b/tests/cypress/support/renkulab-fixtures/dataServices.ts index 8deba032c7..e34e5a16de 100644 --- a/tests/cypress/support/renkulab-fixtures/dataServices.ts +++ b/tests/cypress/support/renkulab-fixtures/dataServices.ts @@ -32,6 +32,10 @@ interface ExactUser { last_name?: string; } +interface PostResourcePoolWithRunaiRemoteArgs extends SimpleFixture { + base_url?: string; +} + interface UrlRedirectFixture extends NameOnlyFixture { sourceUrl: string; targetUrl: string | null; @@ -53,6 +57,30 @@ export function DataServices(Parent: T) { return this; } + postResourcePoolWithRunaiRemote( + args?: PostResourcePoolWithRunaiRemoteArgs + ) { + const { + fixture = "dataServices/resource-pools.json", + name = "postResourcePool", + base_url = "https://runai.example.com", + } = args ?? {}; + cy.fixture(fixture).then((resourcePool) => { + cy.intercept("POST", "/api/data/resource_pools", (req) => { + if (req.body.remote.kind != "runai") { + throw new Error("remote.kind must be 'runai'"); + } + if (req.body.remote.base_url != base_url) { + throw new Error( + `remote.base_url ${req.body.remote.base_url} must equal ${base_url}` + ); + } + req.reply({ body: resourcePool, statusCode: 201, delay: 1000 }); + }).as(name); + }); + return this; + } + adminResourcePoolUsers( name = "getAdminResourcePoolUsers", fixture = "dataServices/resource-pool-users.json"