feat: extend cluster k8s_options with multi-vendor manifest support

- New k8s_pod_spec form sections (image credentials, node selector,
  gpu vendor overrides) under k8s_options, replacing the legacy flat
  k8s_volume_mounts list. UI keys aligned to the backend wire shape
  (snake_case k8s_options + camelCase inside).
- System default container registry is pre-filled into the first image
  credential when creating a new cluster; empty username/password are
  coerced to null on submit to match the Optional[str] backend schema.
- Register cluster flow supports multi-runtime selection gated by the
  cluster's gpuVendorOverrides: non-override vendors stay single-select
  with an inline hint; multi-add only opens once an override vendor is
  picked, and non-override cards become disabled in that state.
- Manifest URL emits multiple ?runtime= params; check-env step combines
  per-vendor commands; downstream steps are disabled when no vendor is
  selected.
- Pre-validate gpuVendorOverrides at save time (non-empty selector, no
  duplicates across vendors, no key clash with base nodeSelector) so
  the user sees the error before hitting the manifest endpoint.
- Misc: dark-mode background of the k8s_pod_spec / volume mount titles
  no longer clashes with the drawer; cluster Steps no longer leaks the
  internal showModules/showForms props to the DOM.
This commit is contained in:
Yuxing Deng
2026-05-26 16:54:14 +08:00
parent 91c3d0b814
commit acb90531b2
21 changed files with 1023 additions and 112 deletions
@@ -1,3 +1,4 @@
import { systemConfigAtom } from '@/atoms/system';
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
@@ -8,7 +9,9 @@ import {
Textarea as SealTextArea
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { Form, message } from 'antd';
import { useAtomValue } from 'jotai';
import _ from 'lodash';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import {
@@ -32,6 +35,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom);
const handleOnCollapseChange = async (keys: string | string[]) => {
setActiveKey(Array.isArray(keys) ? keys : [keys]);
@@ -50,20 +54,107 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}
}, [activeKey, action]);
const handleOnFinish = (values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
// Mirror the backend's `_validate_multi_vendor_overrides` check so the
// user sees the problem at save time instead of as a 400 when they
// run the register command. Returns the i18n'd message of the first
// problem found, or null when clean.
const validateGpuVendorOverrides = (values: any): string | null => {
const opts = values?.k8s_options;
const overrides = opts?.gpuVendorOverrides;
if (!overrides) return null;
const entries = Object.entries(overrides) as [string, any][];
if (entries.length === 0) return null;
onFinish({
...values,
worker_config: {
...workerConfig
// 1. Every override entry must declare a non-empty nodeSelector,
// otherwise it can't actually pin its DaemonSet to anything.
for (const [vendor, override] of entries) {
const sel = override?.nodeSelector;
if (!sel || Object.keys(sel).length === 0) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.emptySelector' },
{ vendor }
);
}
});
}
// 2. Two vendors with the same selector would fight for the same
// nodes — backend rejects this at manifest render.
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [v1, o1] = entries[i];
const [v2, o2] = entries[j];
if (_.isEqual(o1?.nodeSelector, o2?.nodeSelector)) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.duplicate' },
{ v1, v2 }
);
}
}
}
// 3. Base nodeSelector keys can't be reused in any override —
// the CPU worker would require AND forbid the same key.
const baseKeys = Object.keys(opts?.nodeSelector || {});
if (baseKeys.length > 0) {
for (const [vendor, override] of entries) {
const overrideKeys = Object.keys(override?.nodeSelector || {});
const clash = overrideKeys.filter((k) => baseKeys.includes(k));
if (clash.length > 0) {
return intl.formatMessage(
{ id: 'clusters.gpuVendorOverrides.validate.keyConflict' },
{ vendor, keys: clash.join(', ') }
);
}
}
}
return null;
};
// Empty username/password aren't meaningful as credentials — the backend
// models them as Optional[str] and treats null as "no auth". Coerce
// before sending so a public registry placeholder stays unambiguous.
const normalizeOutgoing = (values: any): any => {
const creds = values?.k8s_options?.imageCredentials;
if (!Array.isArray(creds)) return values;
const fixed = creds.map((c: any) => ({
...c,
username: c?.username ? c.username : null,
password: c?.password ? c.password : null
}));
return {
...values,
k8s_options: { ...values.k8s_options, imageCredentials: fixed }
};
};
const handleOnFinish = (_values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
// antd's onFinish only delivers values for registered Form.Items.
// Spreading those on top of `getFieldsValue(true)` clobbers nested
// objects (e.g. `k8s_options` would lose `gpuVendorOverrides`, which is
// only set via setFieldValue), so we go straight to the full store.
const fullValues = form.getFieldsValue(true);
const overridesErr = validateGpuVendorOverrides(fullValues);
if (overridesErr) {
message.error(overridesErr);
return;
}
onFinish(
normalizeOutgoing({
...fullValues,
worker_config: {
...workerConfig
}
})
);
};
useEffect(() => {
if (currentData) {
const volumeMounts = currentData?.k8s_volume_mounts || [];
const volumeMounts = currentData?.k8s_options?.volumeMounts || [];
const realVolumeList = (volumeMounts || []).map(
(item: any, index: number) => ({
...item,
@@ -72,27 +163,40 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
);
form.setFieldsValue({
...currentData,
k8s_volume_mounts: realVolumeList
k8s_options: {
...(currentData?.k8s_options || {}),
volumeMounts: realVolumeList
}
});
} else {
const defaultRegistry = systemConfig?.system_default_container_registry;
form.setFieldsValue({
k8s_volume_mounts: [
{
name: 'gpustack-data-dir',
mountPath: '/var/lib/gpustack',
readOnly: false,
sourceType: 'hostPath',
volumeSource: {
hostPath: {
path: '/var/lib/gpustack',
type: 'DirectoryOrCreate'
k8s_options: {
volumeMounts: [
{
name: 'gpustack-data-dir',
mountPath: '/var/lib/gpustack',
readOnly: false,
sourceType: 'hostPath',
volumeSource: {
hostPath: {
path: '/var/lib/gpustack',
type: 'DirectoryOrCreate'
}
}
}
}
]
],
...(defaultRegistry
? {
imageCredentials: [
{ registry: defaultRegistry, username: '', password: '' }
]
}
: {})
}
});
}
}, [currentData]);
}, [currentData, systemConfig?.system_default_container_registry]);
useEffect(() => {
if (currentData) {
@@ -130,17 +234,33 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
};
},
validateFields: async () => {
const values = await form.validateFields();
// Run validation first to display any field errors. Then read the
// FULL store via `getFieldsValue(true)` so values that were set via
// setFieldValue on non-registered paths (e.g. gpuVendorOverrides)
// are still included in what we hand to the API.
await form.validateFields();
const values = form.getFieldsValue(true);
// Mirror the backend invariants for gpuVendorOverrides so the user
// sees the same constraint before submit instead of at register time.
const overridesErr = validateGpuVendorOverrides(values);
if (overridesErr) {
message.error(overridesErr);
// Reject so the step-flow's Promise.allSettled marks this form
// as failed and the outer onNext skips the submit callback.
throw new Error(overridesErr);
}
const workerConfig = yaml2Json(
advanceConfigRef.current?.getYamlValue()
);
return {
return normalizeOutgoing({
...values,
worker_config: {
...workerConfig
}
};
});
}
}));
@@ -207,6 +327,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
<AdvanceConfig
action={action}
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
)