From f1f7d1d053b4ca0cde027628f1e0cf9c02347936 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Wed, 3 Jun 2026 14:27:10 +0200 Subject: [PATCH] refactor(sdl): promote placements to a top-level array in the form values schema Reshapes SdlBuilderFormValuesSchema so placements are first-class: each placement carries a stable id and an optional region; services reference one via placementId and own their own pricing. generateSdl and importSimpleSdl read/write the new shape; SDL YAML output is byte-equivalent to the previous structure for the same logical input. The legacy single-placement wizard's form controls are rebound to the new field paths and behave identically. Bonus pre-existing bug fixes surfaced by the refactor: - signedBy.allOf-only placements were silently dropped from SDL output (typo in the legacy guard tested anyOf twice instead of anyOf || allOf). - A second IP-using service overwrote the first's endpoints block due to non-idempotent endpoints initialization. --- .../components/new-deployment/SdlBuilder.tsx | 46 +++++---- .../update/RemoteDeployUpdate.tsx | 20 ++-- .../components/sdl/AttributesFormControl.tsx | 10 +- .../src/components/sdl/ImportSdlModal.tsx | 10 +- .../LogCollectorControl.spec.tsx | 31 ++++++- .../LogCollectorControl.tsx | 28 ++++-- .../src/components/sdl/PlacementFormModal.tsx | 36 ++++--- .../components/sdl/SignedByFormControl.tsx | 12 +-- .../components/sdl/SimpleSdlBuilderForm.tsx | 33 ++++--- .../sdl/SimpleServiceFormControl.tsx | 27 +++--- .../src/hooks/useImportSimpleSdl.ts | 2 +- .../src/hooks/useSdlEnv/useSdlEnv.spec.tsx | 1 + .../useSdlServiceManager.spec.tsx | 1 + .../useSdlServiceManager.ts | 23 ++++- .../src/types/sdlBuilder/sdlBuilder.spec.ts | 33 ++++++- .../src/types/sdlBuilder/sdlBuilder.ts | 48 ++++++++-- apps/deploy-web/src/utils/sdl/data.spec.ts | 39 ++++---- apps/deploy-web/src/utils/sdl/data.ts | 72 +++++++++----- .../src/utils/sdl/sdlGenerator.spec.ts | 67 +++++++++++-- apps/deploy-web/src/utils/sdl/sdlGenerator.ts | 74 +++++++-------- .../src/utils/sdl/sdlImport.spec.ts | 78 +++++++++++++++- apps/deploy-web/src/utils/sdl/sdlImport.ts | 93 ++++++++++++------- .../src/utils/sdl/transformCustomSdlFields.ts | 9 +- apps/deploy-web/tests/seeders/sdlService.ts | 15 +-- 24 files changed, 556 insertions(+), 252 deletions(-) diff --git a/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx b/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx index a1d59a504a..d0f983aa5c 100644 --- a/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx +++ b/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx @@ -11,7 +11,7 @@ import { useSdlServiceManager } from "@src/hooks/useSdlServiceManager/useSdlServ import { useGpuModels } from "@src/queries/useGpuQuery"; import type { SdlBuilderFormValuesType, ServiceType } from "@src/types"; import { SdlBuilderFormValuesSchema } from "@src/types"; -import { getDefaultService } from "@src/utils/sdl/data"; +import { defaultServiceWithPlacement, sshServiceOverrides } from "@src/utils/sdl/data"; import { generateSdl } from "@src/utils/sdl/sdlGenerator"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import { transformCustomSdlFields, TransformError } from "@src/utils/sdl/transformCustomSdlFields"; @@ -52,12 +52,13 @@ export const SdlBuilder = React.forwardRef( const formRef = useRef(null); const [isInit, setIsInit] = useState(false); const { hasComponent, imageList } = d.useSdlBuilder(); + const initialValues = useRef({ + ...defaultServiceWithPlacement(hasComponent("ssh") ? sshServiceOverrides : undefined), + imageList: imageList, + hasSSHKey: hasComponent("ssh") + }).current; const form = useForm({ - defaultValues: { - services: [getDefaultService({ supportsSSH: hasComponent("ssh") })], - imageList: imageList, - hasSSHKey: hasComponent("ssh") - }, + defaultValues: initialValues, resolver: zodResolver(SdlBuilderFormValuesSchema) }); const { control, trigger, watch, setValue, formState } = form; @@ -71,10 +72,10 @@ export const SdlBuilder = React.forwardRef( useEffect(() => { formServices.forEach((service, index) => { - const { denom } = service.placement.pricing; + const { denom } = service.pricing; if (denom !== wallet.denom) { - setValue(`services.${index}.placement.pricing.denom`, wallet.denom); + setValue(`services.${index}.pricing.denom`, wallet.denom); } }); }, [formServices, sdlString, wallet.denom]); @@ -90,9 +91,14 @@ export const SdlBuilder = React.forwardRef( useEffect(() => { const { unsubscribe } = watch(data => { - const sdl = generateSdl(data.services as ServiceType[]); - lastSyncedSdlRef.current = sdl; - setEditedManifest(sdl); + try { + const sdl = generateSdl(data as SdlBuilderFormValuesType); + setError(null); + lastSyncedSdlRef.current = sdl; + setEditedManifest(sdl); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate SDL"); + } }); return () => { unsubscribe(); @@ -102,17 +108,17 @@ export const SdlBuilder = React.forwardRef( useEffect(() => { if (sdlString && sdlString !== lastSyncedSdlRef.current) { try { - const services = createAndValidateSdl(sdlString); - if (services) { + const imported = createAndValidateSdl(sdlString); + if (imported) { lastSyncedSdlRef.current = sdlString; - setValue("services", services as ServiceType[]); + form.reset({ ...form.getValues(), placements: imported.placements, services: imported.services }); } } catch (error) { setError("Error importing SDL"); } } setIsInit(true); - }, [sdlString, setValue]); + }, [sdlString, form]); useEffect(() => { onValidate?.({ isValid: formState.isValid }); @@ -120,23 +126,25 @@ export const SdlBuilder = React.forwardRef( const getSdl = () => { try { - return generateSdl(transformCustomSdlFields(formServices, { withSSH: hasComponent("ssh") })); + return generateSdl(transformCustomSdlFields(form.getValues(), { withSSH: hasComponent("ssh") })); } catch (err) { if (err instanceof TransformError) { setError(err.message); + } else { + setError(err instanceof Error ? err.message : String(err)); } } }; const createAndValidateSdl = (yamlStr: string) => { try { - if (!yamlStr) return []; + if (!yamlStr) return null; - const services = importSimpleSdl(yamlStr); + const formValues = importSimpleSdl(yamlStr); setError(null); - return services; + return formValues; } catch (err: any) { if (err.name === "YAMLException" || err.name === "CustomValidationError") { setError(err.message); diff --git a/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx b/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx index 3583d083ab..d8504f4d1c 100644 --- a/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx +++ b/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx @@ -11,8 +11,8 @@ import { SdlBuilderProvider } from "@src/context/SdlBuilderProvider"; import { useServices } from "@src/context/ServicesProvider"; import { EnvVarManagerService } from "@src/services/remote-deploy/env-var-manager.service"; import { tokens } from "@src/store/remoteDeployStore"; -import type { SdlBuilderFormValuesType, ServiceType } from "@src/types"; -import { getDefaultService } from "@src/utils/sdl/data"; +import type { SdlBuilderFormValuesType } from "@src/types"; +import { defaultServiceWithPlacement } from "@src/utils/sdl/data"; import { generateSdl } from "@src/utils/sdl/sdlGenerator"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import BitBranches from "../bitbucket/BitBucketBranches"; @@ -24,20 +24,26 @@ const RemoteDeployUpdate = ({ sdlString, onManifestChange }: { sdlString: string const [token] = useAtom(tokens); const { enqueueSnackbar } = useSnackbar(); const [isEditingEnv, setIsEditingEnv] = useState(false); - const { control, watch, setValue } = useForm({ defaultValues: { services: [getDefaultService()] } }); + const initialValues = useMemo(() => defaultServiceWithPlacement(), []); + const { control, watch, setValue } = useForm({ + defaultValues: initialValues + }); const { fields: services } = useFieldArray({ control, name: "services", keyName: "id" }); const envVarManagerService = useMemo(() => new EnvVarManagerService(services), [services]); const { publicConfig } = useServices(); useEffect(() => { const { unsubscribe }: any = watch(data => { - const sdl = generateSdl(data.services as ServiceType[]); + const sdl = generateSdl(data as SdlBuilderFormValuesType); onManifestChange(sdl); }); try { if (sdlString) { - const services = createAndValidateSdl(sdlString); - setValue("services", services as ServiceType[]); + const imported = createAndValidateSdl(sdlString); + if (imported) { + setValue("placements", imported.placements); + setValue("services", imported.services); + } } } catch (error) { enqueueSnackbar(, { variant: "error" }); @@ -50,7 +56,7 @@ const RemoteDeployUpdate = ({ sdlString, onManifestChange }: { sdlString: string const createAndValidateSdl = (yamlStr: string) => { try { - return yamlStr ? importSimpleSdl(yamlStr) : []; + return yamlStr ? importSimpleSdl(yamlStr) : null; } catch (err: any) { if (err.name === "YAMLException" || err.name === "CustomValidationError") { enqueueSnackbar(, { variant: "error" }); diff --git a/apps/deploy-web/src/components/sdl/AttributesFormControl.tsx b/apps/deploy-web/src/components/sdl/AttributesFormControl.tsx index e923098ac3..22758b14f2 100644 --- a/apps/deploy-web/src/components/sdl/AttributesFormControl.tsx +++ b/apps/deploy-web/src/components/sdl/AttributesFormControl.tsx @@ -12,7 +12,7 @@ import type { PlacementAttributeType, SdlBuilderFormValuesType } from "@src/type import { FormPaper } from "./FormPaper"; type Props = { - serviceIndex: number; + placementIndex: number; control: Control; children?: ReactNode; attributes: PlacementAttributeType[]; @@ -22,14 +22,14 @@ export type AttributesRefType = { _removeAttribute: (index: number | number[]) => void; }; -export const AttributesFormControl = forwardRef(({ control, serviceIndex, attributes: _attributes = [] }, ref) => { +export const AttributesFormControl = forwardRef(({ control, placementIndex, attributes: _attributes = [] }, ref) => { const { fields: attributes, remove: removeAttribute, append: appendAttribute } = useFieldArray({ control, - name: `services.${serviceIndex}.placement.attributes`, + name: `placements.${placementIndex}.attributes`, keyName: "id" }); @@ -69,7 +69,7 @@ export const AttributesFormControl = forwardRef(({ con
( field.onChange(event.target.value)} /> )} @@ -79,7 +79,7 @@ export const AttributesFormControl = forwardRef(({ con
( field.onChange(event.target.value)} /> )} diff --git a/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx b/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx index e926f1f9c1..4f66c01687 100644 --- a/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx +++ b/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx @@ -9,7 +9,7 @@ import { useTheme } from "next-themes"; import { useSnackbar } from "notistack"; import { useServices } from "@src/context/ServicesProvider"; -import type { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import type { SdlBuilderFormValuesType } from "@src/types"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import { SDLEditor } from "./SDLEditor/SDLEditor"; @@ -33,11 +33,11 @@ export const ImportSdlModal: React.FunctionComponent = ({ onClose, setVal try { if (!yamlStr) return null; - const services = importSimpleSdl(yamlStr); + const formValues = importSimpleSdl(yamlStr); setParsingError(null); - return services; + return formValues; } catch (err: any) { if (err.name === "YAMLException" || err.name === "CustomValidationError") { setParsingError(err.message); @@ -45,7 +45,6 @@ export const ImportSdlModal: React.FunctionComponent = ({ onClose, setVal setParsingError(err.message); } else { setParsingError("Error while parsing SDL file"); - // setParsingError(err.message); console.error(err); } } @@ -56,7 +55,8 @@ export const ImportSdlModal: React.FunctionComponent = ({ onClose, setVal if (!result) return; - setValue("services", result as ServiceType[]); + setValue("placements", result.placements); + setValue("services", result.services); enqueueSnackbar(, { variant: "success", diff --git a/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.spec.tsx b/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.spec.tsx index 3f9e9886fd..7cc8b39ffb 100644 --- a/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.spec.tsx +++ b/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.spec.tsx @@ -22,7 +22,8 @@ describe(LogCollectorControl.name, () => { const logCollectorService = formValues.services.find(service => service.title === `${targetService.title}-log-collector`); expect(logCollectorService).toBeDefined(); expect(logCollectorService?.image).toMatch(/ghcr\.io\/akash-network\/log-collector:\d+\.\d+\.\d+/); - expect(logCollectorService?.placement).toMatchObject(targetService.placement); + expect(logCollectorService?.placementId).toBe(targetService.placementId); + expect(logCollectorService?.pricing).toMatchObject(targetService.pricing); }); it("removes log-collector service when checkbox is unchecked", async () => { @@ -90,14 +91,36 @@ describe(LogCollectorControl.name, () => { expect(form.getValues("services.1.title")).toBe(`${targetService.title}-log-collector`); }); - const newPlacement = buildSDLService().placement; + const newPlacementId = "placement-new"; await act(async () => { - form.setValue("services.0.placement", newPlacement); + form.setValue("services.0.placementId", newPlacementId); }); await vi.waitFor( () => { - expect(form.getValues("services.1.placement.name")).toBe(newPlacement.name); + expect(form.getValues("services.1.placementId")).toBe(newPlacementId); + }, + { timeout: 1000 } + ); + }); + + it("updates log-collector pricing when target service pricing is changed", async () => { + const { user, form, targetService } = await setup(); + const checkbox = screen.getByRole("checkbox"); + await user.click(checkbox); + + await vi.waitFor(() => { + expect(form.getValues("services.1.title")).toBe(`${targetService.title}-log-collector`); + }); + + const newPricing = { amount: 42_000, denom: "uact" }; + await act(async () => { + form.setValue("services.0.pricing", newPricing); + }); + + await vi.waitFor( + () => { + expect(form.getValues("services.1.pricing")).toEqual(newPricing); }, { timeout: 1000 } ); diff --git a/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.tsx b/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.tsx index 8eb2ad0be0..4e47822241 100644 --- a/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.tsx +++ b/apps/deploy-web/src/components/sdl/LogCollectorControl/LogCollectorControl.tsx @@ -67,14 +67,18 @@ export const LogCollectorControl: FC = ({ serviceIndex, dependencies: d = } const nextTitle = toLogCollectorTitle(targetService); - const changes: Partial> = {}; + const changes: Partial> = {}; if (logCollectorService.title !== nextTitle) { changes.title = nextTitle; } - if (targetService.placement.name !== logCollectorService.placement.name) { - changes.placement = targetService.placement; + if (targetService.placementId !== logCollectorService.placementId) { + changes.placementId = targetService.placementId; + } + + if (targetService.pricing.amount !== logCollectorService.pricing.amount || targetService.pricing.denom !== logCollectorService.pricing.denom) { + changes.pricing = targetService.pricing; } if (Object.keys(changes).length > 0) { @@ -84,7 +88,16 @@ export const LogCollectorControl: FC = ({ serviceIndex, dependencies: d = }); } }, - [logCollectorService, logCollectorServiceIndex, targetService.placement.name, targetService.title, update, env] + [ + logCollectorService, + logCollectorServiceIndex, + targetService.placementId, + targetService.pricing.amount, + targetService.pricing.denom, + targetService.title, + update, + env + ] ); useThrottledEffect(() => { @@ -200,12 +213,15 @@ export function findOwnLogCollectorServiceIndex(service: ServiceType, services: return services.findIndex(s => s.title === toLogCollectorTitle(service)); } -function generateLogCollectorService(targetService: T): Pick & Omit { +function generateLogCollectorService( + targetService: T +): Pick & Omit { return { id: toLogCollectorId(targetService), title: toLogCollectorTitle(targetService), image: LOG_COLLECTOR_IMAGE, - placement: targetService.placement, + placementId: targetService.placementId, + pricing: targetService.pricing, env: [ { key: "PROVIDER", value: "DATADOG" }, { key: "POD_LABEL_SELECTOR", value: `akash.network/manifest-service=${targetService.title}` }, diff --git a/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx b/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx index 18f3834d8c..b0b187be89 100644 --- a/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx +++ b/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { useRef } from "react"; import type { Control } from "react-hook-form"; +import { useWatch } from "react-hook-form"; import { FormattedNumber } from "react-intl"; import { CustomTooltip, FormField, FormInput, Popup } from "@akashnetwork/ui/components"; import { InfoCircle } from "iconoir-react"; @@ -33,9 +34,10 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se const attritubesRef = useRef(null); const supportedSdlDenoms = useSupportedDenoms(); const currentService = services[serviceIndex]; - const selectedDenom = supportedSdlDenoms.find(x => x.value === currentService.placement.pricing.denom); + const placementIndex = usePlacementIndexForService(control, serviceIndex); + const selectedDenom = supportedSdlDenoms.find(x => x.value === currentService.pricing.denom); - const _onClose = () => { + const closeAfterPruningEmptyRows = () => { const attributesToRemove: number[] = []; const signedByAnyToRemove: number[] = []; const signedByAllToRemove: number[] = []; @@ -77,10 +79,10 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se color: "secondary", variant: "ghost", side: "right", - onClick: _onClose + onClick: closeAfterPruningEmptyRows } ]} - onClose={_onClose} + onClose={closeAfterPruningEmptyRows} maxWidth="xl" enableCloseOnBackdropClick > @@ -90,7 +92,7 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se
( = ({ control, se
( - Pricing, ${toReadableDenom(currentService.placement.pricing.denom)} + Pricing, ${toReadableDenom(currentService.pricing.denom)} @@ -129,12 +131,12 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se {selectedDenom?.value === UAKT_DENOM ? ( <> - ~ + ~ ) : ( <> - + @@ -163,7 +165,7 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se
= ({ control, se
- +
@@ -179,3 +181,15 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se ); }; + +/** + * Resolves the index of the placement referenced by the service at + * `serviceIndex` inside `placements`. Returns `-1` when the reference can't be + * resolved so callers can gate placement-bound UI on a valid index instead of + * silently editing the first placement. + */ +export function usePlacementIndexForService(control: Control, serviceIndex: number) { + const placementId = useWatch({ control, name: `services.${serviceIndex}.placementId` }); + const placements = useWatch({ control, name: "placements" }); + return placements?.findIndex(p => p.id === placementId) ?? -1; +} diff --git a/apps/deploy-web/src/components/sdl/SignedByFormControl.tsx b/apps/deploy-web/src/components/sdl/SignedByFormControl.tsx index 94eb4a1b66..1204e2be6b 100644 --- a/apps/deploy-web/src/components/sdl/SignedByFormControl.tsx +++ b/apps/deploy-web/src/components/sdl/SignedByFormControl.tsx @@ -12,7 +12,7 @@ import type { SdlBuilderFormValuesType, SignedByType } from "@src/types"; import { FormPaper } from "./FormPaper"; type Props = { - serviceIndex: number; + placementIndex: number; control: Control; children?: ReactNode; signedByAnyOf: SignedByType[]; @@ -25,14 +25,14 @@ export type SignedByRefType = { }; export const SignedByFormControl = forwardRef( - ({ control, serviceIndex, signedByAnyOf: _signedByAnyOf = [], signedByAllOf: _signedByAllOf = [] }, ref) => { + ({ control, placementIndex, signedByAnyOf: _signedByAnyOf = [], signedByAllOf: _signedByAllOf = [] }, ref) => { const { fields: signedByAnyOf, remove: removeAnyOf, append: appendAnyOf } = useFieldArray({ control, - name: `services.${serviceIndex}.placement.signedBy.anyOf`, + name: `placements.${placementIndex}.signedBy.anyOf`, keyName: "id" }); const { @@ -41,7 +41,7 @@ export const SignedByFormControl = forwardRef( append: appendAllOf } = useFieldArray({ control, - name: `services.${serviceIndex}.placement.signedBy.allOf`, + name: `placements.${placementIndex}.signedBy.allOf`, keyName: "id" }); @@ -109,7 +109,7 @@ export const SignedByFormControl = forwardRef( {/** TODO Add list of auditors */} ( field.onChange(event.target.value)} /> )} @@ -152,7 +152,7 @@ export const SignedByFormControl = forwardRef( {/** TODO Add list of auditors */} ( field.onChange(event.target.value)} /> )} diff --git a/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx b/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx index 109dd02082..c9559bcd7f 100644 --- a/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx +++ b/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx @@ -20,7 +20,7 @@ import type { ITemplate, SdlBuilderFormValuesType, ServiceType } from "@src/type import { SdlBuilderFormValuesSchema } from "@src/types"; import { RouteStep } from "@src/types/route-steps.type"; import { memoryUnits, storageUnits } from "@src/utils/akash/units"; -import { getDefaultService } from "@src/utils/sdl/data"; +import { defaultServiceWithPlacement } from "@src/utils/sdl/data"; import { generateSdl } from "@src/utils/sdl/sdlGenerator"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import { UrlService } from "@src/utils/urlUtils"; @@ -43,16 +43,16 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { const [sdlBuilderSdl, setSdlBuilderSdl] = useAtom(sdlStore.sdlBuilderSdl); const { data: gpuModels } = useGpuModels(); const { enqueueSnackbar } = useSnackbar(); - const defaultServices = useMemo(() => ({ services: [getDefaultService()] }), []); + const initialValues = useMemo(() => defaultServiceWithPlacement(), []); const form = useForm({ resolver: zodResolver(SdlBuilderFormValuesSchema), - defaultValues: defaultServices + defaultValues: initialValues }); const { handleSubmit, reset, control, trigger, watch, setValue } = form; const { clear: clearFormStorage } = useFormPersist("sdl-builder-form", { watch, setValue, - defaultValues: defaultServices, + defaultValues: initialValues, storage: typeof window === "undefined" ? undefined : window.localStorage }); const { services: _services } = watch(); @@ -63,6 +63,9 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { useEffect(() => { if (sdlBuilderSdl && sdlBuilderSdl.services) { + if (sdlBuilderSdl.placements) { + setValue("placements", sdlBuilderSdl.placements); + } setValue("services", sdlBuilderSdl.services); } }, []); @@ -82,11 +85,13 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { } }, [templateQueryId, templateMetadata, clearFormStorage, setSdlBuilderSdl, reset]); + const { placements: _placements } = watch(); + useEffect(() => { if (_services) { - setSdlBuilderSdl({ services: _services as ServiceType[] }); + setSdlBuilderSdl({ placements: _placements as SdlBuilderFormValuesType["placements"], services: _services as ServiceType[] }); } - }, [_services]); + }, [_services, _placements]); const loadTemplate = async (id: string) => { try { @@ -94,13 +99,14 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { const response = await consoleApiHttpClient.get(`/v1/user/template/${id}`); const template: ITemplate = response.data; - const services = importSimpleSdl(template.sdl); + const imported = importSimpleSdl(template.sdl); setIsLoadingTemplate(false); reset(); - setValue("services", services as ServiceType[]); - setServiceCollapsed(services.map((x, i) => i)); + setValue("placements", imported.placements); + setValue("services", imported.services); + setServiceCollapsed(imported.services.map((x, i) => i)); setTemplateMetadata(template); } catch (error) { enqueueSnackbar(, { @@ -115,7 +121,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { setError(null); try { - const sdl = generateSdl(data.services); + const sdl = generateSdl(data); setDeploySdl({ title: "", @@ -148,7 +154,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { setError(null); try { - const sdl = generateSdl(_services as ServiceType[]); + const sdl = generateSdl(form.getValues()); setSdlResult(sdl); setIsPreviewingSdl(true); @@ -162,7 +168,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { }; const getTemplateData = () => { - const sdl = generateSdl(_services as ServiceType[]); + const sdl = generateSdl(form.getValues()); const template: Partial = { id: templateMetadata?.id || undefined, sdl, @@ -262,7 +268,8 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = () => { label: "Reset SDL" }); - setValue("services", defaultServices.services); + setValue("placements", initialValues.placements); + setValue("services", initialValues.services); }} > Reset diff --git a/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx b/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx index a7281f31ab..5ca0323d78 100644 --- a/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx +++ b/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx @@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; import type { Control, UseFormSetValue, UseFormTrigger } from "react-hook-form"; -import { useFieldArray } from "react-hook-form"; +import { useFieldArray, useWatch } from "react-hook-form"; import { Button, Card, @@ -92,6 +92,9 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ const isDesktop = useMediaQuery(muiTheme.breakpoints.up("sm")); const expanded = !serviceCollapsed.some(x => x === serviceIndex); const currentService: ServiceType = _services[serviceIndex]; + const placements = useWatch({ control, name: "placements" }) || []; + const currentPlacementIndex = placements.findIndex(p => p.id === currentService?.placementId); + const currentPlacement = currentPlacementIndex >= 0 ? placements[currentPlacementIndex] : undefined; const _isEditingEnv = serviceIndex === isEditingEnv; const _isEditingCommands = serviceIndex === isEditingCommands; const _isEditingExpose = serviceIndex === isEditingExpose; @@ -149,13 +152,13 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ /> )} {/** Edit Placement */} - {_isEditingPlacement && ( + {_isEditingPlacement && currentPlacement && ( setIsEditingPlacement(null)} serviceIndex={serviceIndex} services={_services} - placement={currentService.placement} + placement={currentPlacement} /> )}
= ({
Name   - {currentService.placement.name} + {currentPlacement?.name}
Pricing   - Max {udenomToDenom(currentService.placement.pricing.amount, 6)} ACT per block + Max {udenomToDenom(currentService.pricing.amount, 6)} ACT per block @@ -481,7 +484,7 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
~ - +   per month
@@ -495,8 +498,8 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
Attributes   - {(currentService.placement.attributes?.length || 0) > 0 - ? currentService.placement.attributes?.map((a, i) => ( + {(currentPlacement?.attributes?.length || 0) > 0 + ? currentPlacement?.attributes?.map((a, i) => ( {a.key}={a.value} @@ -507,8 +510,8 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
Signed by any of   - {(currentService.placement.signedBy?.anyOf?.length || 0) > 0 - ? currentService.placement.signedBy?.anyOf?.map((a, i) => ( + {(currentPlacement?.signedBy?.anyOf?.length || 0) > 0 + ? currentPlacement?.signedBy?.anyOf?.map((a, i) => ( {a.value} @@ -519,8 +522,8 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
Signed by all of   - {(currentService.placement.signedBy?.allOf?.length || 0) > 0 - ? currentService.placement.signedBy?.allOf?.map((a, i) => ( + {(currentPlacement?.signedBy?.allOf?.length || 0) > 0 + ? currentPlacement?.signedBy?.allOf?.map((a, i) => ( {a.value} diff --git a/apps/deploy-web/src/hooks/useImportSimpleSdl.ts b/apps/deploy-web/src/hooks/useImportSimpleSdl.ts index 309813f41c..eb63878c4c 100644 --- a/apps/deploy-web/src/hooks/useImportSimpleSdl.ts +++ b/apps/deploy-web/src/hooks/useImportSimpleSdl.ts @@ -7,7 +7,7 @@ export function useImportSimpleSdl(sdl: string | null | undefined) { if (!sdl) return []; try { - return importSimpleSdl(sdl); + return importSimpleSdl(sdl).services; } catch { return []; } diff --git a/apps/deploy-web/src/hooks/useSdlEnv/useSdlEnv.spec.tsx b/apps/deploy-web/src/hooks/useSdlEnv/useSdlEnv.spec.tsx index 47f7d3dfc1..191a4c04b7 100644 --- a/apps/deploy-web/src/hooks/useSdlEnv/useSdlEnv.spec.tsx +++ b/apps/deploy-web/src/hooks/useSdlEnv/useSdlEnv.spec.tsx @@ -103,6 +103,7 @@ describe("useSdlEnv", () => { }; const defaultFormValues: SdlBuilderFormValuesType = { + placements: [{ id: "p-1", name: "dcloud" }], services: [buildSDLService({ env: [] })], imageList: [], hasSSHKey: false diff --git a/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.spec.tsx b/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.spec.tsx index 307a7edf39..f538bdf413 100644 --- a/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.spec.tsx +++ b/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.spec.tsx @@ -169,6 +169,7 @@ describe(useSdlServiceManager.name, () => { }; const defaultFormValues: SdlBuilderFormValuesType = { + placements: [{ id: "p-1", name: "dcloud" }], services: defaultServices, imageList: [], hasSSHKey: false diff --git a/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.ts b/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.ts index 2276733c24..ad22247a6c 100644 --- a/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.ts +++ b/apps/deploy-web/src/hooks/useSdlServiceManager/useSdlServiceManager.ts @@ -5,7 +5,7 @@ import { nanoid } from "nanoid"; import { findOwnLogCollectorServiceIndex, isLogCollectorService } from "@src/components/sdl/LogCollectorControl/LogCollectorControl"; import type { SdlBuilderFormValuesType } from "@src/types"; -import { getDefaultService } from "@src/utils/sdl/data"; +import { defaultPlacement, defaultService } from "@src/utils/sdl/data"; type Props = { control: Control; @@ -13,7 +13,12 @@ type Props = { export const useSdlServiceManager = ({ control }: Props) => { const watchedServices = useWatch({ control, name: "services", defaultValue: [] }); + const watchedPlacements = useWatch({ control, name: "placements", defaultValue: [] }); const services = useMemo(() => (Array.isArray(watchedServices) ? (watchedServices as SdlBuilderFormValuesType["services"]) : []), [watchedServices]); + const placements = useMemo( + () => (Array.isArray(watchedPlacements) ? (watchedPlacements as SdlBuilderFormValuesType["placements"]) : []), + [watchedPlacements] + ); const { remove: removeService, append: appendService } = useFieldArray({ control, @@ -21,6 +26,12 @@ export const useSdlServiceManager = ({ control }: Props) => { keyName: "id" }); + const { append: appendPlacement } = useFieldArray({ + control, + name: "placements", + keyName: "id" + }); + const calcNextServiceTitle = useCallback(() => { const visibleServices = services.filter(service => !isLogCollectorService(service)); const lastService = visibleServices[visibleServices.length - 1]; @@ -41,8 +52,14 @@ export const useSdlServiceManager = ({ control }: Props) => { }, [services]); const add = useCallback(() => { - appendService({ ...getDefaultService(), id: nanoid(), title: calcNextServiceTitle() }); - }, [appendService, calcNextServiceTitle]); + let placementId = placements[0]?.id; + if (!placementId) { + const placement = defaultPlacement(); + placementId = placement.id; + appendPlacement(placement); + } + appendService({ ...defaultService(placementId), id: nanoid(), title: calcNextServiceTitle() }); + }, [appendService, appendPlacement, calcNextServiceTitle, placements]); const remove = useCallback( (index: number) => { diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts index 335fa84f24..8381dafa40 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ServiceSchema } from "./sdlBuilder"; +import { SdlBuilderFormValuesSchema, ServiceSchema } from "./sdlBuilder"; describe("ServiceSchema", () => { it("validates a minimal valid service", () => { @@ -14,13 +14,36 @@ describe("ServiceSchema", () => { storage: [{ size: 512, unit: "Mi" }] }, expose: [{ port: 80, as: 80, global: true }], - placement: { - name: "dcloud", - pricing: { amount: 1000, denom: "uakt" } - }, + placementId: "placement-1", + pricing: { amount: 1000, denom: "uakt" }, count: 1 }); expect(result.success).toBe(true); }); }); + +describe("SdlBuilderFormValuesSchema", () => { + it("rejects a service whose placementId does not exist in placements[]", () => { + const result = SdlBuilderFormValuesSchema.safeParse({ + placements: [{ id: "p-1", name: "dcloud" }], + services: [ + { + title: "web", + image: "nginx:latest", + profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, + expose: [{ port: 80, as: 80, global: true }], + placementId: "p-MISSING", + pricing: { amount: 1000, denom: "uakt" }, + count: 1 + } + ] + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual( + expect.objectContaining({ path: ["services", 0, "placementId"], message: "Service references a placement that does not exist." }) + ); + }); +}); diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts index 3543a31a88..e184214245 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts @@ -198,23 +198,21 @@ export const ExposeSchema = z.object({ }); export const PlacementSchema = z.object({ + id: z.string().min(1, { message: "Placement id is required." }), name: z .string() .min(1, { message: "Placement name is required." }) .regex(/^[a-z0-9-]+$/, { message: "Invalid placement name. It must only be lower case letters, numbers and dashes." }) .regex(/^[a-z]/, { message: "Invalid starting character. It can only start with a lowercase letter." }) .regex(/[^-]$/, { message: "Invalid ending character. It can only end with a lowercase letter or number" }), + region: z.string().optional(), attributes: z.array(PlacementAttributeSchema).optional(), signedBy: z .object({ allOf: z.array(SignedBySchema), anyOf: z.array(SignedBySchema) }) - .optional(), - pricing: z.object({ - amount: z.number().min(1, { message: "Pricing amount is required." }), - denom: z.string().min(1, { message: "Pricing denom is required." }) - }) + .optional() }); const validateCpuAmount = (value: number, serviceCount: number, context: z.RefinementCtx) => { @@ -348,7 +346,11 @@ export const ServiceSchema = z expose: z.array(ExposeSchema), command: CommandSchema.optional(), env: z.array(EnvironmentVariableSchema).optional(), - placement: PlacementSchema, + placementId: z.string().min(1, { message: "Placement reference is required." }), + pricing: z.object({ + amount: z.number().min(1, { message: "Pricing amount is required." }), + denom: z.string().min(1, { message: "Pricing denom is required." }) + }), count: z.number().min(1, { message: "Service count is required." }), sshPubKey: z.string().optional(), params: z @@ -383,12 +385,39 @@ const logProviderVars = z.discriminatedUnion("PROVIDER", [ ]); export const SdlBuilderFormValuesSchema = z - .object({ services: z.array(ServiceSchema) }) + .object({ + placements: z.array(PlacementSchema).min(1, { message: "At least one placement is required." }), + services: z.array(ServiceSchema).min(1, { message: "At least one service is required." }) + }) .merge(ImageList) .merge(SSHKey) .superRefine((data, ctx) => { - // Docker image name validation - // Image list is set when we deploy a linux instance + const placementIds = new Set(); + for (let i = 0; i < data.placements.length; i++) { + const placementId = data.placements[i].id; + if (placementIds.has(placementId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Placement id must be unique.", + path: ["placements", i, "id"], + fatal: true + }); + continue; + } + placementIds.add(placementId); + } + + for (let i = 0; i < data.services.length; i++) { + if (!placementIds.has(data.services[i].placementId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service references a placement that does not exist.", + path: ["services", i, "placementId"], + fatal: true + }); + } + } + if (data.imageList && data.imageList.length > 0) { for (let i = 0; i < data.services.length; i++) { if (!data.imageList.includes(data.services[i].image)) { @@ -415,7 +444,6 @@ export const SdlBuilderFormValuesSchema = z } } - // SSH key validation if (data.hasSSHKey) { for (let i = 0; i < data.services.length; i++) { if (!data.services[i].sshPubKey) { diff --git a/apps/deploy-web/src/utils/sdl/data.spec.ts b/apps/deploy-web/src/utils/sdl/data.spec.ts index bf72a0febd..d7a4e1fa4b 100644 --- a/apps/deploy-web/src/utils/sdl/data.spec.ts +++ b/apps/deploy-web/src/utils/sdl/data.spec.ts @@ -1,28 +1,27 @@ import { describe, expect, it } from "vitest"; -import { UACT_DENOM } from "@src/config/denom.config"; -import { getDefaultService, sshVmDistros } from "./data"; - -describe(getDefaultService.name, () => { - it("sets denom to uact by default", () => { - const result = getDefaultService(); - - expect(result.placement.pricing.denom).toBe(UACT_DENOM); +import { defaultPlacement, defaultService } from "./data"; + +describe("default factories", () => { + it("defaultPlacement returns an object with a stable id and a default name", () => { + const placement = defaultPlacement(); + expect(placement.id).toEqual(expect.any(String)); + expect(placement.id.length).toBeGreaterThan(0); + expect(placement.name).toBe("dcloud"); }); - it("configures SSH image and clears expose when supportsSSH is true", () => { - const result = getDefaultService({ supportsSSH: true }); - - expect(result.image).toBe(sshVmDistros[0]); - expect(result.expose).toEqual([]); + it("defaultService references the provided placementId", () => { + const placement = defaultPlacement(); + const service = defaultService(placement.id); + expect(service.placementId).toBe(placement.id); + expect(service.pricing.denom).toEqual(expect.any(String)); }); - it("returns independent instances on each call", () => { - const a = getDefaultService(); - const b = getDefaultService(); - - a.placement.name = "modified"; - - expect(b.placement.name).not.toBe("modified"); + it("defaultService returns independent copies", () => { + const placement = defaultPlacement(); + const a = defaultService(placement.id); + const b = defaultService(placement.id); + a.title = "modified"; + expect(b.title).not.toBe("modified"); }); }); diff --git a/apps/deploy-web/src/utils/sdl/data.ts b/apps/deploy-web/src/utils/sdl/data.ts index f7d2cbb869..8333f38576 100644 --- a/apps/deploy-web/src/utils/sdl/data.ts +++ b/apps/deploy-web/src/utils/sdl/data.ts @@ -1,8 +1,7 @@ -import cloneDeep from "lodash/cloneDeep"; import { nanoid } from "nanoid"; import { UACT_DENOM } from "@src/config/denom.config"; -import type { ServiceType } from "@src/types"; +import type { PlacementType, SdlBuilderFormValuesType, ServiceType } from "@src/types"; export const protoTypes = [ { id: 1, name: "http" }, @@ -19,7 +18,25 @@ export const defaultHttpOptions = { nextTimeout: 60000 }; -const defaultService: ServiceType = { +/** + * Builds a fresh placement with a generated id and the dcloud defaults. + */ +export const defaultPlacement = (overrides?: Partial): PlacementType => ({ + id: nanoid(), + name: "dcloud", + signedBy: { + anyOf: [], + allOf: [] + }, + attributes: [], + ...overrides +}); + +/** + * Builds a fresh service bound to the given placement id and seeded with the + * standard single-port HTTP expose / compute / pricing defaults. + */ +export const defaultService = (placementId: string, overrides?: Partial): ServiceType => ({ id: nanoid(), title: "service-1", image: "", @@ -54,7 +71,7 @@ const defaultService: ServiceType = { maxBodySize: defaultHttpOptions.maxBodySize, readTimeout: defaultHttpOptions.readTimeout, sendTimeout: defaultHttpOptions.sendTimeout, - nextCases: defaultHttpOptions.nextCases, + nextCases: [...defaultHttpOptions.nextCases], nextTries: defaultHttpOptions.nextTries, nextTimeout: defaultHttpOptions.nextTimeout } @@ -62,19 +79,26 @@ const defaultService: ServiceType = { ], command: { command: "", arg: "" }, env: [], - placement: { - name: "dcloud", - pricing: { - amount: 100000, - denom: UACT_DENOM - }, - signedBy: { - anyOf: [], - allOf: [] - }, - attributes: [] + placementId, + pricing: { + amount: 100000, + denom: UACT_DENOM }, - count: 1 + count: 1, + ...overrides +}); + +/** + * Builds top-level form values for a brand-new deployment: one placement + * paired with one service that references it. Use this anywhere the form + * is initialized from scratch. + */ +export const defaultServiceWithPlacement = (serviceOverrides?: Partial): SdlBuilderFormValuesType => { + const placement = defaultPlacement(); + return { + placements: [placement], + services: [defaultService(placement.id, serviceOverrides)] + }; }; export const defaultPersistentStorage = { @@ -112,15 +136,13 @@ export const SSH_EXPOSE = { to: [] }; -export const getDefaultService = (options: { supportsSSH?: boolean } = {}) => { - const res = cloneDeep(defaultService); - - if (options.supportsSSH) { - res.image = sshVmDistros[0]; - res.expose = []; - } - - return res; +/** + * Overrides applied to a fresh service when the surrounding flow exposes SSH: + * picks a known SSH-enabled VM image and drops the default HTTP expose. + */ +export const sshServiceOverrides: Partial = { + image: sshVmDistros[0], + expose: [] }; export const nextCases = [ diff --git a/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts b/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts index bb2e0121ee..3739a8d4ca 100644 --- a/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts +++ b/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts @@ -2,13 +2,13 @@ import yaml from "js-yaml"; import { describe, expect, it } from "vitest"; import { LOG_COLLECTOR_IMAGE } from "@src/config/log-collector.config"; -import type { ServiceType } from "@src/types"; +import type { PlacementType, SdlBuilderFormValuesType, ServiceType } from "@src/types"; import { buildCommand, generateSdl } from "./sdlGenerator"; describe("sdlGenerator", () => { describe(generateSdl.name, () => { it("includes permissions params for log-collector services", () => { - const result = generateSdl([createLogCollectorService()]); + const result = generateSdl(buildFormValues(buildLogCollectorService())); const parsed = yaml.load(result) as { services: Record }; expect(parsed.services["web-log-collector"].params).toEqual({ @@ -19,15 +19,58 @@ describe("sdlGenerator", () => { }); it("does not include permissions params for non-log-collector services", () => { - const result = generateSdl([createLogCollectorService({ title: "web", image: "nginx:latest" })]); + const result = generateSdl(buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" }))); const parsed = yaml.load(result) as { services: Record }; expect(parsed.services["web"].params).toBeUndefined(); }); - function createLogCollectorService(overrides?: Partial): ServiceType { + it("injects location-region attribute when placement.region is set", () => { + const formValues = buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })); + formValues.placements[0].region = "us-west"; + const result = generateSdl(formValues); + const parsed = yaml.load(result) as { profiles: { placement: Record }> } }; + + expect(parsed.profiles.placement["dcloud"].attributes).toMatchObject({ "location-region": "us-west" }); + }); + + it("does not inject location-region when placement.region is undefined or 'any'", () => { + const formValuesNoRegion = buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })); + const parsedNoRegion = yaml.load(generateSdl(formValuesNoRegion)) as { + profiles: { placement: Record }> }; + }; + expect(parsedNoRegion.profiles.placement["dcloud"].attributes?.["location-region"]).toBeUndefined(); + + const formValuesAny = buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })); + formValuesAny.placements[0].region = "any"; + const parsedAny = yaml.load(generateSdl(formValuesAny)) as { profiles: { placement: Record }> } }; + expect(parsedAny.profiles.placement["dcloud"].attributes?.["location-region"]).toBeUndefined(); + }); + + it("throws when a service references a placementId that does not exist", () => { + const formValues = { + placements: [{ id: "p-1", name: "dcloud" }], + services: [buildLogCollectorService({ title: "web", image: "nginx:latest", placementId: "p-MISSING" })] + } as SdlBuilderFormValuesType; + + expect(() => generateSdl(formValues)).toThrow(/unknown placementId/); + }); + + it("deduplicates placement profiles when multiple services share a placementId", () => { + const formValues = buildFormValues( + buildLogCollectorService({ title: "web", image: "nginx:latest" }), + buildLogCollectorService({ title: "api", image: "node:18-alpine" }) + ); + const result = generateSdl(formValues); + const parsed = yaml.load(result) as { profiles: { placement: Record }> } }; + + expect(Object.keys(parsed.profiles.placement)).toEqual(["dcloud"]); + expect(Object.keys(parsed.profiles.placement["dcloud"].pricing)).toEqual(["web", "api"]); + }); + + function buildLogCollectorService(overrides?: Partial): ServiceType { return { - id: "web-log-collector", + id: overrides?.title ? `${overrides.title}-id` : "web-log-collector", title: "web-log-collector", image: LOG_COLLECTOR_IMAGE, profile: { @@ -39,14 +82,20 @@ describe("sdlGenerator", () => { gpu: 0 }, expose: [{ port: 80, as: 80, global: true, to: [] }], - placement: { - name: "dcloud", - pricing: { amount: 1000, denom: "uakt" } - }, + placementId: "p-1", + pricing: { amount: 1000, denom: "uakt" }, count: 1, ...overrides } as ServiceType; } + + function buildFormValues(...services: ServiceType[]): SdlBuilderFormValuesType { + const placement: PlacementType = { id: "p-1", name: "dcloud" }; + return { + placements: [placement], + services + } as SdlBuilderFormValuesType; + } }); describe("buildCommand", () => { diff --git a/apps/deploy-web/src/utils/sdl/sdlGenerator.ts b/apps/deploy-web/src/utils/sdl/sdlGenerator.ts index abb2d739df..a939726eef 100644 --- a/apps/deploy-web/src/utils/sdl/sdlGenerator.ts +++ b/apps/deploy-web/src/utils/sdl/sdlGenerator.ts @@ -1,7 +1,7 @@ import yaml from "js-yaml"; import { isLogCollectorService } from "@src/components/sdl/LogCollectorControl/LogCollectorControl"; -import type { ExposeType, ProfileGpuModelType, ServiceType } from "@src/types"; +import type { ExposeType, PlacementType, ProfileGpuModelType, SdlBuilderFormValuesType } from "@src/types"; import { defaultHttpOptions } from "./data"; export const buildCommand = (command: string) => { @@ -23,38 +23,38 @@ export const buildCommand = (command: string) => { return command; }; -export const generateSdl = (services: ServiceType[], region?: string) => { +export const generateSdl = (formValues: SdlBuilderFormValuesType) => { const sdl: Record = { version: "2.0", services: {}, profiles: { compute: {}, placement: {} }, deployment: {} }; + const placementById = new Map(formValues.placements.map(p => [p.id, p])); + + formValues.services.forEach(service => { + const placement = placementById.get(service.placementId); + if (!placement) { + throw new Error(`Service "${service.title}" references unknown placementId "${service.placementId}"`); + } - services.forEach(service => { sdl.services[service.title] = { image: service.image, credentials: service.hasCredentials ? service.credentials : undefined, - // Expose expose: service.expose.map(e => { - // Port const _expose: Record = { port: e.port }; - // As if (e.as) { _expose["as"] = e.as; } - // Accept const accept = e.accept?.map(a => a.value); if ((accept?.length || 0) > 0) { _expose["accept"] = accept; } - // Proto const proto = getProto(e); if (proto) { _expose["proto"] = proto; } - // To const to = e.to?.map(to => ({ ["service"]: to.value })); _expose["to"] = [ { @@ -63,7 +63,6 @@ export const generateSdl = (services: ServiceType[], region?: string) => { } ].concat(to as any); - // HTTP Options if (e.hasCustomHttpOptions) { _expose["http_options"] = { max_body_size: e.httpOptions?.maxBodySize ?? defaultHttpOptions.maxBodySize, @@ -79,19 +78,16 @@ export const generateSdl = (services: ServiceType[], region?: string) => { }) }; - // Command const trimmedCommand = service.command?.command?.trim(); if (trimmedCommand) { sdl.services[service.title].command = buildCommand(trimmedCommand); sdl.services[service.title].args = [service.command?.arg?.trim()]; } - // Env if ((service.env?.length || 0) > 0) { sdl.services[service.title].env = service.env?.map(e => `${e.key.trim()}=${e.isSecret ? "" : e.value?.trim()}`); } - // Compute sdl.profiles.compute[service.title] = { resources: { cpu: { @@ -108,7 +104,6 @@ export const generateSdl = (services: ServiceType[], region?: string) => { } }; - // GPU if (service.profile.hasGpu) { sdl.profiles.compute[service.title].resources.gpu = { units: service.profile.gpu, @@ -117,7 +112,6 @@ export const generateSdl = (services: ServiceType[], region?: string) => { } }; - // Group models by vendor const vendors = service.profile.gpuModels?.reduce>((group, model) => { const { vendor } = model; @@ -153,7 +147,6 @@ export const generateSdl = (services: ServiceType[], region?: string) => { } } - // Persistent Storage if (service.profile.storage.length > 1) { sdl.services[service.title].params = { storage: {} @@ -187,45 +180,41 @@ export const generateSdl = (services: ServiceType[], region?: string) => { }; } - // Placement - sdl.profiles.placement[service.placement.name] = sdl.profiles.placement[service.placement.name] || { pricing: {} }; - sdl.profiles.placement[service.placement.name].pricing[service.title] = { - denom: service.placement.pricing.denom, - amount: service.placement.pricing.amount + sdl.profiles.placement[placement.name] = sdl.profiles.placement[placement.name] || { pricing: {} }; + sdl.profiles.placement[placement.name].pricing[service.title] = { + denom: service.pricing.denom, + amount: service.pricing.amount }; - // Signed by - if ((service.placement.signedBy?.anyOf?.length || 0) > 0 || (service.placement.signedBy?.anyOf?.length || 0) > 0) { - if ((service.placement.signedBy?.anyOf?.length || 0) > 0) { - sdl.profiles.placement[service.placement.name].signedBy = { - anyOf: service.placement.signedBy?.anyOf.map(x => x.value) + if ((placement.signedBy?.anyOf?.length || 0) > 0 || (placement.signedBy?.allOf?.length || 0) > 0) { + if ((placement.signedBy?.anyOf?.length || 0) > 0) { + sdl.profiles.placement[placement.name].signedBy = { + anyOf: placement.signedBy?.anyOf.map(x => x.value) }; } - if ((service.placement.signedBy?.allOf?.length || 0) > 0) { - sdl.profiles.placement[service.placement.name].signedBy.allOf = service.placement.signedBy?.allOf.map(x => x.value); + if ((placement.signedBy?.allOf?.length || 0) > 0) { + sdl.profiles.placement[placement.name].signedBy = sdl.profiles.placement[placement.name].signedBy || {}; + sdl.profiles.placement[placement.name].signedBy.allOf = placement.signedBy?.allOf.map(x => x.value); } } - // Attributes - if ((service.placement.attributes?.length || 0) > 0) { - sdl.profiles.placement[service.placement.name].attributes = service.placement.attributes?.reduce>( + if ((placement.attributes?.length || 0) > 0) { + sdl.profiles.placement[placement.name].attributes = placement.attributes?.reduce>( (acc, curr) => ((acc[curr.key] = curr.value), acc), {} ); } - // Regions - if (!!region && region !== "any") { - sdl.profiles.placement[service.placement.name].attributes = { - ...(sdl.profiles.placement[service.placement.name].attributes || {}), - "location-region": region.toLowerCase() + if (!!placement.region && placement.region !== "any") { + sdl.profiles.placement[placement.name].attributes = { + ...(sdl.profiles.placement[placement.name].attributes || {}), + "location-region": placement.region.toLowerCase() }; } - // IP Lease if (service.expose.some(exp => exp.ipName)) { - sdl["endpoints"] = {}; + sdl["endpoints"] = sdl["endpoints"] || {}; service.expose .filter((exp): exp is ExposeType & { ipName: string } => !!exp.ipName) @@ -236,9 +225,8 @@ export const generateSdl = (services: ServiceType[], region?: string) => { }); } - // Count sdl.deployment[service.title] = { - [service.placement.name]: { + [placement.name]: { profile: service.title, count: service.count } @@ -249,7 +237,7 @@ export const generateSdl = (services: ServiceType[], region?: string) => { indent: 2, quotingType: '"', styles: { - "!!null": "empty" // dump null as empty value + "!!null": "empty" } }); @@ -257,6 +245,10 @@ export const generateSdl = (services: ServiceType[], region?: string) => { ${result}`; }; +/** + * Returns the SDL proto value for an expose entry. SDL omits the proto field + * for HTTP exposes (it is the default), so http maps to null. + */ const getProto = (expose: ExposeType) => { if (expose.proto && expose.proto === "http") { return null; diff --git a/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts b/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts index 16cefaba6a..68728780d0 100644 --- a/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts +++ b/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts @@ -1,7 +1,9 @@ +import yaml from "js-yaml"; import * as fs from "node:fs"; import * as path from "node:path"; import { describe, expect, it } from "vitest"; +import { generateSdl } from "./sdlGenerator"; import { importSimpleSdl, parseSvcCommand } from "./sdlImport"; describe("sdlImport", () => { @@ -47,9 +49,83 @@ describe("sdlImport", () => { it("returns services in the same order as in the SDL YAML", () => { const yml = fs.readFileSync(path.resolve(__dirname, "../../../tests/mocks/two-services-sdl.yml"), "utf8"); - const services = importSimpleSdl(yml); + const { services } = importSimpleSdl(yml); expect(services.map(service => service.title)).toEqual(["web", "service-2"]); }); + + it("produces a deduplicated placements[] array when multiple services share a placement", () => { + const yml = fs.readFileSync(path.resolve(__dirname, "../../../tests/mocks/two-services-sdl.yml"), "utf8"); + + const { placements, services } = importSimpleSdl(yml); + + expect(placements).toHaveLength(1); + expect(placements[0].name).toBe("dcloud"); + expect(services.every(service => service.placementId === placements[0].id)).toBe(true); + }); + + it("lifts location-region attribute onto placement.region", () => { + const yml = [ + "version: '2.0'", + "services:", + " web:", + " image: nginx:1.0", + " expose:", + " - port: 80", + " as: 80", + " to:", + " - global: true", + "profiles:", + " compute:", + " web:", + " resources:", + " cpu:", + " units: 0.5", + " memory:", + " size: 512Mi", + " storage:", + " - size: 512Mi", + " placement:", + " dcloud:", + " attributes:", + " location-region: us-west", + " pricing:", + " web:", + " denom: uact", + " amount: 1000", + "deployment:", + " web:", + " dcloud:", + " profile: web", + " count: 1" + ].join("\n"); + + const { placements } = importSimpleSdl(yml); + + expect(placements[0].region).toBe("us-west"); + expect(placements[0].attributes?.some(a => a.key === "location-region")).toBe(false); + }); + + it("lifts pricing onto each service", () => { + const yml = fs.readFileSync(path.resolve(__dirname, "../../../tests/mocks/two-services-sdl.yml"), "utf8"); + + const { services } = importSimpleSdl(yml); + + expect(services[0].pricing).toEqual({ amount: 1000, denom: "uact" }); + expect(services[1].pricing).toEqual({ amount: 100000, denom: "uact" }); + }); + }); + + describe("SDL roundtrip", () => { + it("imports an SDL, regenerates it, and produces semantically equal output", () => { + const yml = fs.readFileSync(path.resolve(__dirname, "../../../tests/mocks/two-services-sdl.yml"), "utf8"); + const formValues = importSimpleSdl(yml); + const regenerated = generateSdl(formValues); + + const original = yaml.load(yml) as Record; + const roundtripped = yaml.load(regenerated) as Record; + + expect(roundtripped).toEqual(original); + }); }); }); diff --git a/apps/deploy-web/src/utils/sdl/sdlImport.ts b/apps/deploy-web/src/utils/sdl/sdlImport.ts index 8565722264..8886ca7525 100644 --- a/apps/deploy-web/src/utils/sdl/sdlImport.ts +++ b/apps/deploy-web/src/utils/sdl/sdlImport.ts @@ -1,7 +1,7 @@ import yaml from "js-yaml"; import { nanoid } from "nanoid"; -import type { ExposeType, ProfileGpuModelType, ServiceType } from "@src/types"; +import type { ExposeType, PlacementAttributeType, PlacementType, ProfileGpuModelType, SdlBuilderFormValuesType, ServiceType } from "@src/types"; import { CustomValidationError } from "../deploymentData"; import { capitalizeFirstLetter } from "../stringUtils"; import { defaultHttpOptions } from "./data"; @@ -22,11 +22,18 @@ export const parseSvcCommand = (command?: string | string[]): string => { return command.filter(Boolean).join("\n"); }; -export const importSimpleSdl = (yamlStr: string) => { +export const importSimpleSdl = (yamlStr: string): SdlBuilderFormValuesType => { try { const yamlJson = yaml.load(yamlStr) as any; + const placements: PlacementType[] = []; + const placementIdByName = new Map(); const services: ServiceType[] = []; - if (!yamlJson.services) return services; + + if (!yamlJson || typeof yamlJson !== "object" || Array.isArray(yamlJson)) { + throw new CustomValidationError("SDL root must be a YAML object."); + } + + if (!yamlJson.services) return { placements, services }; Object.keys(yamlJson.services).forEach(svcName => { const svc = yamlJson.services[svcName]; @@ -42,8 +49,6 @@ export const importSimpleSdl = (yamlStr: string) => { const compute = yamlJson.profiles.compute[svcName]; const storages = compute.resources.storage.map ? compute.resources.storage : [compute.resources.storage]; - // TODO validation - // Service compute profile service.profile = { cpu: compute.resources.cpu.units, gpu: compute.resources.gpu ? compute.resources.gpu.units : 0, @@ -78,16 +83,13 @@ export const importSimpleSdl = (yamlStr: string) => { }) }; - // Command service.command = { command: parseSvcCommand(svc.command), arg: svc.args ? svc.args[0] : "" }; - // Env service.env = svc.env?.map((e: any) => ({ id: nanoid(), key: e.split("=")[0], value: e.split("=")[1] })) || []; - // Expose service.expose = []; svc.expose?.forEach((expose: any) => { const isGlobal = expose.to.find((t: any) => t.global); @@ -115,41 +117,32 @@ export const importSimpleSdl = (yamlStr: string) => { service.expose?.push(_expose); }); - // Placement const depl = yamlJson.deployment[svcName]; const sortedPlacementNames = Object.keys(depl).sort(); - // Only one placement available const placementName = sortedPlacementNames[0]; - const placement = yamlJson.profiles.placement[placementName]; + const placementProfile = yamlJson.profiles.placement[placementName]; - if (!placement) { + if (!placementProfile) { throw new CustomValidationError(`Unable to find placement: ${placementName}`); } - const placementPricing = placement.pricing[svcName]; + let placementId = placementIdByName.get(placementName); + if (!placementId) { + placementId = nanoid(); + placementIdByName.set(placementName, placementId); + placements.push(hydratePlacement(placementId, placementName, placementProfile)); + } + + const placementPricing = placementProfile.pricing?.[svcName]; + if (!placementPricing) { + throw new CustomValidationError(`Unable to find pricing for service "${svcName}" in placement "${placementName}"`); + } const deployment = depl[placementName]; - service.placement = { - name: placementName, - pricing: { - amount: placementPricing.amount, - denom: placementPricing.denom - }, - signedBy: { - anyOf: placement.signedBy && placement.signedBy?.anyOf ? placement.signedBy.anyOf.map((x: string) => ({ id: nanoid(), value: x })) : [], - allOf: placement.signedBy && placement.signedBy?.allOf ? placement.signedBy.allOf.map((x: string) => ({ id: nanoid(), value: x })) : [] - }, - attributes: placement.attributes - ? Object.keys(placement.attributes).map(attKey => { - const attVal = placement.attributes[attKey]; - - return { - id: nanoid(), - key: attKey, - value: attVal - }; - }) - : [] + service.placementId = placementId; + service.pricing = { + amount: placementPricing.amount, + denom: placementPricing.denom }; service.count = deployment.count; @@ -157,7 +150,7 @@ export const importSimpleSdl = (yamlStr: string) => { services.push(service as ServiceType); }); - return services; + return { placements, services }; } catch (error) { console.error(error); throw error; @@ -199,3 +192,33 @@ const getGpuModels = (vendor: { [key: string]: { model: string; ram: string; int return models; }; + +/** + * Builds a fresh PlacementType from a raw SDL placement profile, lifting the + * location-region attribute into a first-class field and dropping it from the + * remaining attributes list. + */ +function hydratePlacement(id: string, name: string, profile: any): PlacementType { + const rawAttributes: Record = profile.attributes || {}; + const attributes: PlacementAttributeType[] = []; + let region: string | undefined; + + for (const [key, value] of Object.entries(rawAttributes)) { + if (key === "location-region") { + region = value; + continue; + } + attributes.push({ id: nanoid(), key, value }); + } + + return { + id, + name, + region, + attributes, + signedBy: { + anyOf: profile.signedBy?.anyOf ? profile.signedBy.anyOf.map((x: string) => ({ id: nanoid(), value: x })) : [], + allOf: profile.signedBy?.allOf ? profile.signedBy.allOf.map((x: string) => ({ id: nanoid(), value: x })) : [] + } + }; +} diff --git a/apps/deploy-web/src/utils/sdl/transformCustomSdlFields.ts b/apps/deploy-web/src/utils/sdl/transformCustomSdlFields.ts index 2aa5b2e001..a019f8109c 100644 --- a/apps/deploy-web/src/utils/sdl/transformCustomSdlFields.ts +++ b/apps/deploy-web/src/utils/sdl/transformCustomSdlFields.ts @@ -2,7 +2,7 @@ import cloneDeep from "lodash/cloneDeep"; import flow from "lodash/flow"; import isMatch from "lodash/isMatch"; -import type { ServiceType } from "@src/types"; +import type { SdlBuilderFormValuesType, ServiceType } from "@src/types"; import { SSH_EXPOSE, SSH_VM_IMAGES } from "@src/utils/sdl/data"; interface TransformOptions { @@ -11,7 +11,7 @@ interface TransformOptions { export class TransformError extends Error {} -export const transformCustomSdlFields = (services: ServiceType[], options?: TransformOptions) => { +export const transformCustomSdlFields = (formValues: SdlBuilderFormValuesType, options?: TransformOptions): SdlBuilderFormValuesType => { const pipeline = [addSshPubKey, ensureServiceCount]; if (options?.withSSH) { @@ -21,7 +21,10 @@ export const transformCustomSdlFields = (services: ServiceType[], options?: Tran const transform = flow(pipeline); - return services.map(service => transform(service)); + return { + ...formValues, + services: formValues.services.map(service => transform(service)) + }; }; function addSshPubKey(input: ServiceType) { diff --git a/apps/deploy-web/tests/seeders/sdlService.ts b/apps/deploy-web/tests/seeders/sdlService.ts index 43d9564c91..493c5097d0 100644 --- a/apps/deploy-web/tests/seeders/sdlService.ts +++ b/apps/deploy-web/tests/seeders/sdlService.ts @@ -6,17 +6,10 @@ export const buildSDLService = (overrides: Partial = {}): ServiceTy id: faker.string.uuid(), title: faker.lorem.word(), image: faker.helpers.arrayElement(["nginx:latest", "node:18-alpine", "postgres:15", "redis:7-alpine", "python:3.11-slim"]), - placement: { - name: faker.lorem.word(), - pricing: { - amount: faker.number.int({ min: 100, max: 10000 }), - denom: faker.helpers.arrayElement(["uact", "uact"]) - }, - signedBy: { - anyOf: [], - allOf: [] - }, - attributes: [] + placementId: faker.string.uuid(), + pricing: { + amount: faker.number.int({ min: 100, max: 10000 }), + denom: "uact" }, profile: { cpu: faker.number.float({ min: 0.1, max: 8, fractionDigits: 1 }),