diff --git a/client/scripts/update_api_spec.js b/client/scripts/update_api_spec.js index 5c4786f02a..d2ae8e1eb9 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 = "build/support-build-arm"; async function main() { argv.forEach((arg) => { diff --git a/client/src/features/sessionsV2/SessionImageModal.tsx b/client/src/features/sessionsV2/SessionImageModal.tsx index b87b6f6232..e19cb0285a 100644 --- a/client/src/features/sessionsV2/SessionImageModal.tsx +++ b/client/src/features/sessionsV2/SessionImageModal.tsx @@ -76,7 +76,7 @@ export default function SessionImageModal({ ) : ( <>
- +
{!data.connection && !data.provider ? ( <> diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx index 18d0b1b3c5..808b630105 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx @@ -18,7 +18,7 @@ import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import { CircleFill, Link45deg, Pencil, Trash } from "react-bootstrap-icons"; import { Card, CardBody, Col, DropdownItem, Row } from "reactstrap"; @@ -29,6 +29,7 @@ import { DEFAULT_APP_PARAMS } from "../../../utils/context/appParams.constants"; import PermissionsGuard from "../../permissionsV2/PermissionsGuard"; import useProjectPermissions from "../../ProjectPageV2/utils/useProjectPermissions.hook"; import { Project } from "../../projectsV2/api/projectV2.api"; +import { computeResourcesApi } from "../api/computeResources.api"; import type { SessionLauncher } from "../api/sessionLaunchersV2.api"; import { sessionLaunchersV2Api, @@ -127,15 +128,24 @@ export default function SessionLauncherCard({ "text-muted", ]; - const { data: containerImage, isLoading: loadingContainerImage } = + const { data: containerImage, isLoading: isLoadingContainerImage } = useGetSessionsImagesQuery( - environment && - environment.environment_kind === "CUSTOM" && - environment.container_image + environment?.container_image != null ? { imageUrl: environment.container_image } : skipToken ); + const { data: resourcePools, isLoading: isLoadingResourcePools } = + computeResourcesApi.endpoints.getResourcePools.useQueryState({}); + const resourcePool = useMemo(() => { + if (launcher?.resource_class_id == null || resourcePools == null) { + return undefined; + } + return resourcePools.find(({ classes }) => + classes.some(({ id }) => id === launcher.resource_class_id) + ); + }, [launcher?.resource_class_id, resourcePools]); + return ( - {isCodeEnvironment && ( + {isCodeEnvironment ? ( - {isCodeEnvironment && isLoading ? ( + {isCodeEnvironment && + (isLoading || + isLoadingContainerImage || + isLoadingResourcePools) ? ( @@ -220,7 +233,11 @@ export default function SessionLauncherCard({ ) : isCodeEnvironment && lastBuild ? ( - + ) : !hasSession ? ( - )} - {isExternalImageEnvironment && ( + ) : ( diff --git a/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx b/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx index 26759f4853..965431306b 100644 --- a/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx +++ b/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx @@ -32,6 +32,7 @@ import AppContext from "../../../utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "../../../utils/context/appParams.constants"; import useAppDispatch from "../../../utils/customHooks/useAppDispatch.hook"; import { toHumanDateTime } from "../../../utils/helpers/DateTimeUtils"; +import { computeResourcesApi } from "../api/computeResources.api"; import type { SessionLauncher } from "../api/sessionLaunchersV2.api"; import { sessionLaunchersV2Api, @@ -115,6 +116,7 @@ export default function EnvironmentCard({ {environment_kind === "GLOBAL" && ( <> + {environment?.description ? (

@@ -150,11 +152,45 @@ export default function EnvironmentCard({ ); } +function GlobalEnvironmentSessionImageBadge({ + launcher, +}: { + launcher: SessionLauncher; +}) { + const environment = launcher.environment; + const { data, isLoading } = useGetSessionsImagesQuery( + environment && environment.container_image + ? { imageUrl: environment.container_image } + : skipToken + ); + const { data: resourcePools, isLoading: isLoadingResourcePools } = + computeResourcesApi.endpoints.getResourcePools.useQueryState({}); + const resourcePool = useMemo(() => { + if (launcher?.resource_class_id == null || resourcePools == null) { + return undefined; + } + return resourcePools.find(({ classes }) => + classes.some(({ id }) => id === launcher.resource_class_id) + ); + }, [launcher?.resource_class_id, resourcePools]); + + return ( +

+ +
+ ); +} + function CustomEnvironmentValues({ launcher }: { launcher: SessionLauncher }) { const { environment } = launcher; if (environment.environment_image_source === "image") { - return ; + return ; } return ; @@ -162,8 +198,10 @@ function CustomEnvironmentValues({ launcher }: { launcher: SessionLauncher }) { function CustomImageEnvironmentValues({ launcher, + showImageBadge, }: { launcher: SessionLauncher; + showImageBadge?: boolean; }) { const { pathname, hash } = useLocation(); const environment = launcher.environment; @@ -175,6 +213,16 @@ function CustomImageEnvironmentValues({ ? { imageUrl: environment.container_image } : skipToken ); + const { data: resourcePools, isLoading: isLoadingResourcePools } = + computeResourcesApi.endpoints.getResourcePools.useQueryState({}); + const resourcePool = useMemo(() => { + if (launcher?.resource_class_id == null || resourcePools == null) { + return undefined; + } + return resourcePools.find(({ classes }) => + classes.some(({ id }) => id === launcher.resource_class_id) + ); + }, [launcher?.resource_class_id, resourcePools]); const search = useMemo(() => { return `?${new URLSearchParams({ targetProvider: data?.provider?.id ?? "", @@ -188,7 +236,14 @@ function CustomImageEnvironmentValues({ return ( <>
- + {showImageBadge && ( + + )} {!isLoading && data?.accessible === false && (
{!data.connection && !data.provider ? ( @@ -336,6 +391,23 @@ function CustomBuildEnvironmentValues({ } ); + const { data: imageCheck, isLoading: isLoadingContainerImage } = + useGetSessionsImagesQuery( + environment.container_image != null + ? { imageUrl: environment.container_image } + : skipToken + ); + const { data: resourcePools, isLoading: isLoadingResourcePools } = + computeResourcesApi.endpoints.getResourcePools.useQueryState({}); + const resourcePool = useMemo(() => { + if (launcher?.resource_class_id == null || resourcePools == null) { + return undefined; + } + return resourcePools.find(({ classes }) => + classes.some(({ id }) => id === launcher.resource_class_id) + ); + }, [launcher?.resource_class_id, resourcePools]); + // Invalidate launchers if the container image is not the same as the // image from the last successful build const dispatch = useAppDispatch(); @@ -368,7 +440,12 @@ function CustomBuildEnvironmentValues({ ) : ( <> - + {lastSuccessfulBuild && ( - +
)} @@ -505,25 +582,6 @@ function EnvironmentJSONArrayRowWithLabel({ ); } -function ReadyStatusBadge() { - return ( - - - Ready - - ); -} - function NotReadyStatusBadge() { return ( { + if (imageCheck == null || resourcePool == null) { + return "unknown"; + } + return isImageCompatibleWith(imageCheck, resourcePool.platform); + }, [imageCheck, resourcePool]); + const badgeIcon = - status === "in_progress" ? ( + buildStatus === "in_progress" ? ( ) : ( ); const badgeText = - status === "in_progress" + isCompatible === false + ? "Image incompatible" + : buildStatus === "in_progress" ? "Build in progress" - : status === "cancelled" + : buildStatus === "cancelled" ? "Build cancelled" - : status === "succeeded" + : buildStatus === "succeeded" ? "Build succeeded" : "Build failed"; const badgeColorClasses = - status === "in_progress" + isCompatible === false + ? ["border-danger", "bg-danger-subtle", "text-danger-emphasis"] + : buildStatus === "in_progress" ? ["border-warning", "bg-warning-subtle", "text-warning-emphasis"] - : status === "succeeded" + : buildStatus === "succeeded" ? ["border-success", "bg-success-subtle", "text-success-emphasis"] : ["border-danger", "bg-danger-subtle", "text-danger-emphasis"]; diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderAdvancedSettings.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderAdvancedSettings.tsx new file mode 100644 index 0000000000..e0299328e3 --- /dev/null +++ b/client/src/features/sessionsV2/components/SessionForm/BuilderAdvancedSettings.tsx @@ -0,0 +1,138 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * 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 cx from "classnames"; +import { useCallback, useMemo, useState } from "react"; +import { + Controller, + useController, + type Control, + type FieldValues, + type UseControllerProps, +} from "react-hook-form"; +import { Collapse, Label } from "reactstrap"; + +import ChevronFlippedIcon from "~/components/icons/ChevronFlippedIcon"; +import { BUILDER_PLATFORMS } from "../../session.constants"; +import type { SessionLauncherForm } from "../../sessionsV2.types"; +import BuilderSelectorCommon from "./BuilderSelectorCommon"; + +interface BuilderAdvancedSettingsProps { + control: Control; +} + +export default function BuilderAdvancedSettings({ + control, +}: BuilderAdvancedSettingsProps) { + const { + formState: { defaultValues }, + } = useController({ control, name: "platform" }); + defaultValues?.platform; + const isDefaultPlatform = + defaultValues?.platform == null || + defaultValues.platform === BUILDER_PLATFORMS[0].value; + const [isOpen, setIsOpen] = useState(!isDefaultPlatform); + const toggleIsOpen = useCallback( + () => setIsOpen((isAdvancedSettingOpen) => !isAdvancedSettingOpen), + [] + ); + return ( +
+ + +
+ +
+
+
+ ); +} + +interface BuilderPlatformSelectorProps + extends UseControllerProps {} + +function BuilderPlatformSelector({ + ...controllerProps +}: BuilderPlatformSelectorProps) { + const defaultValue = useMemo( + () => + controllerProps.defaultValue + ? controllerProps.defaultValue + : BUILDER_PLATFORMS[0], + [controllerProps.defaultValue] + ); + + return ( +
+ + ( + <> +
+ +
+
+ {error?.message ? ( + <>{error.message} + ) : ( + <>Please select a valid platform. + )} +
+ + )} + rules={ + controllerProps.rules ?? { + required: "Please select a platform.", + } + } + /> +
+ ); +} diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx index b7dc4d95fa..0dd6ea7f1c 100644 --- a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx @@ -29,6 +29,7 @@ import { DEFAULT_APP_PARAMS } from "../../../../utils/context/appParams.constant import { useProject } from "../../../ProjectPageV2/ProjectPageContainer/ProjectPageContainer"; import { useGetRepositoriesProbesQuery } from "../../../repositories/repositories.api"; import type { SessionLauncherForm } from "../../sessionsV2.types"; +import BuilderAdvancedSettings from "./BuilderAdvancedSettings"; import BuilderFrontendSelector from "./BuilderFrontendSelector"; import BuilderTypeSelector from "./BuilderTypeSelector"; import CodeRepositoryAdvancedSettings from "./CodeRepositoryAdvancedSettings"; @@ -104,6 +105,7 @@ export default function BuilderEnvironmentFields({
+ ); diff --git a/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx b/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx index 3e4479942c..24594271ed 100644 --- a/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx +++ b/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx @@ -72,6 +72,7 @@ export default function NewSessionLauncherModal({ default_url: DEFAULT_URL, port: DEFAULT_PORT, repository: "", + platform: "", }, }); const { diff --git a/client/src/features/sessionsV2/components/SessionStatus/SessionImageBadge.tsx b/client/src/features/sessionsV2/components/SessionStatus/SessionImageBadge.tsx index 7f2eaead09..6f9e3820e8 100644 --- a/client/src/features/sessionsV2/components/SessionStatus/SessionImageBadge.tsx +++ b/client/src/features/sessionsV2/components/SessionStatus/SessionImageBadge.tsx @@ -17,26 +17,43 @@ */ import cx from "classnames"; +import { useMemo } from "react"; import { CircleFill } from "react-bootstrap-icons"; import { Loader } from "~/components/Loader"; import RenkuBadge from "~/components/renkuBadge/RenkuBadge"; -import { ImageCheckResponse } from "../../api/sessionsV2.generated-api"; +import type { ResourcePoolWithId } from "../../api/computeResources.api"; +import type { ImageCheckResponse } from "../../api/sessionsV2.api"; +import { isImageCompatibleWith } from "../../session.utils"; interface SessionImageBadgeProps { data?: ImageCheckResponse | null; - loading: boolean; + isLoading: boolean; + + resourcePool?: ResourcePoolWithId; + isLoadingResourcePools?: boolean; } export default function SessionImageBadge({ data, - loading, + isLoading, + resourcePool, + isLoadingResourcePools, }: SessionImageBadgeProps) { + const isCompatible = useMemo(() => { + if (data == null || resourcePool == null) { + return "unknown"; + } + return isImageCompatibleWith(data, resourcePool.platform); + }, [data, resourcePool]); + return ( - {loading ? ( + {isLoading || isLoadingResourcePools ? ( <> Checking image status. @@ -55,7 +72,11 @@ export default function SessionImageBadge({ ) : ( <> - {data?.accessible + {isCompatible === false + ? `Image incompatible${ + resourcePool?.platform ? ` with ${resourcePool.platform}` : "" + }` + : data?.accessible ? "Image accessible" : data?.provider?.id && (!data?.connection || data?.connection?.status !== "connected") diff --git a/client/src/features/sessionsV2/session.constants.tsx b/client/src/features/sessionsV2/session.constants.tsx index 1621292ddc..69ed4c3471 100644 --- a/client/src/features/sessionsV2/session.constants.tsx +++ b/client/src/features/sessionsV2/session.constants.tsx @@ -136,6 +136,21 @@ export const BUILDER_FRONTENDS = [ }, ] as readonly BuilderSelectorOption[]; +export const BUILDER_PLATFORMS = [ + { + value: "linux/amd64" as const, + label: "linux/amd64", + description: + "The default runtime platform. Select this option unless you know your session will run on a different platform.", + }, + { + value: "linux/arm64" as const, + label: "linux/arm64", + description: + "Select this option if your session will run on ARM64 compute resources.", + }, +] as readonly BuilderSelectorOption<"linux/amd64" | "linux/arm64">[]; + export const IMAGE_BUILD_DOCS = "https://renku.notion.site/How-to-create-a-custom-environment-from-a-code-repository-1960df2efafc801b88f6da59a0aa8234"; diff --git a/client/src/features/sessionsV2/session.utils.ts b/client/src/features/sessionsV2/session.utils.ts index 989ecba0be..a0577a9673 100644 --- a/client/src/features/sessionsV2/session.utils.ts +++ b/client/src/features/sessionsV2/session.utils.ts @@ -18,17 +18,20 @@ import { FaviconStatus } from "../display/display.types"; import { SessionStatusState } from "../session/sessions.types"; +import type { ResourcePoolWithId } from "./api/computeResources.api"; import type { EnvironmentList as SessionEnvironmentList, SessionLauncher, SessionLauncherEnvironmentParams, SessionLauncherEnvironmentPatchParams, } from "./api/sessionLaunchersV2.api"; +import type { ImageCheckResponse } from "./api/sessionsV2.api"; import { + BUILDER_PLATFORMS, DEFAULT_URL, ENV_VARIABLES_RESERVED_PREFIX, } from "./session.constants"; -import { SessionLauncherForm } from "./sessionsV2.types"; +import type { SessionLauncherForm } from "./sessionsV2.types"; export function getSessionFavicon( sessionState?: SessionStatusState, @@ -103,6 +106,7 @@ export function getFormattedEnvironmentValues(data: SessionLauncherForm): { gid, mount_directory, name, + platform: platform_, port, repository_revision: repository_revision_, repository, @@ -118,6 +122,10 @@ export function getFormattedEnvironmentValues(data: SessionLauncherForm): { if (environmentSelect === "custom + build") { const context_dir = context_dir_?.trim(); const repository_revision = repository_revision_?.trim(); + const platform = + BUILDER_PLATFORMS.map(({ value }) => value).find( + (value) => value === platform_ + ) ?? BUILDER_PLATFORMS[0].value; return { success: true, data: { @@ -125,6 +133,7 @@ export function getFormattedEnvironmentValues(data: SessionLauncherForm): { builder_variant, frontend_variant, repository, + platforms: [platform], ...(context_dir ? { context_dir } : {}), ...(repository_revision ? { repository_revision } : {}), }, @@ -203,9 +212,14 @@ export function getFormattedEnvironmentValuesForEdit( builder_variant, context_dir, frontend_variant, + platform: platform_, repository_revision, repository, } = data; + const platform = + BUILDER_PLATFORMS.map(({ value }) => value).find( + (value) => value === platform_ + ) ?? BUILDER_PLATFORMS[0].value; return { success: true, @@ -218,6 +232,7 @@ export function getFormattedEnvironmentValuesForEdit( repository, repository_revision: repository_revision ?? "", context_dir: context_dir ?? "", + platforms: [platform], }, }, }; @@ -277,6 +292,10 @@ export function getLauncherDefaultValues( launcher.environment.environment_image_source === "build" ? launcher.environment.build_parameters.context_dir ?? "" : "", + platform: + launcher.environment.environment_image_source === "build" + ? launcher.environment.build_parameters.platforms?.at(0) ?? "" + : "", }; } @@ -385,3 +404,16 @@ export function validateEnvVariableName(name: string): true | string { } return true; } + +export function isImageCompatibleWith( + image: ImageCheckResponse, + platform: ResourcePoolWithId["platform"] +): boolean | "unknown" { + if (image.platforms == null) { + return "unknown"; + } + const imagePlatforms = image.platforms?.map( + ({ os, architecture }) => `${os}/${architecture}` + ); + return imagePlatforms.some((p) => p === platform); +} diff --git a/client/src/features/sessionsV2/sessionsV2.types.ts b/client/src/features/sessionsV2/sessionsV2.types.ts index 0d1bafa637..b2eb027cf5 100644 --- a/client/src/features/sessionsV2/sessionsV2.types.ts +++ b/client/src/features/sessionsV2/sessionsV2.types.ts @@ -19,7 +19,7 @@ import type { ReactNode } from "react"; import type { CloudStorageDetailsOptions } from "../project/components/cloudStorage/projectCloudStorage.types"; -import type { ResourceClassWithId } from "./api/computeResources.generated-api"; +import type { ResourceClassWithId } from "./api/computeResources.api"; import type { BuildParametersPost, DefaultUrl, @@ -103,7 +103,7 @@ export interface SessionLauncherForm // For "global" environments environmentId: EnvironmentId; - // For "custom" + "image" environments + // For "custom + image" environments default_url: DefaultUrl; uid: EnvironmentUid; gid: EnvironmentGid; @@ -112,6 +112,9 @@ export interface SessionLauncherForm args: string; command: string; strip_path_prefix: boolean; + + // For "custom + build" environments + platform: string; } export interface SessionResources { @@ -182,9 +185,9 @@ export interface DockerImage { error?: unknown; } -export interface BuilderSelectorOption { +export interface BuilderSelectorOption { label: string; - value: string; + value: T; description?: ReactNode; }