diff --git a/src/components/forms/SshKeyForm.tsx b/src/components/forms/SshKeyForm.tsx new file mode 100644 index 0000000000..e1de178126 --- /dev/null +++ b/src/components/forms/SshKeyForm.tsx @@ -0,0 +1,213 @@ +import type { FC } from "react"; +import { Button, Icon, Input, Notification } from "@canonical/react-components"; +import type { + InstanceAndProfileFormikProps, + InstanceAndProfileFormValues, +} from "./instanceAndProfileFormValues"; +import { ensureEditMode } from "util/instanceEdit"; +import { getInheritedSshKeys } from "util/configInheritance"; +import { useProfiles } from "context/useProfiles"; +import Loader from "components/Loader"; + +export interface SshKey { + id: string; + name: string; + user: string; + fingerprint: string; +} + +export interface SshKeyFormValues { + cloud_init_ssh_keys: SshKey[]; +} + +export const sshKeyPayload = (values: InstanceAndProfileFormValues) => { + const result: Record = {}; + + values.cloud_init_ssh_keys?.forEach((record) => { + result[`cloud-init.ssh-keys.${record.name}`] = + `${record.user}:${record.fingerprint}`; + }); + + return result; +}; + +interface Props { + formik: InstanceAndProfileFormikProps; + project: string; +} + +const SshKeyForm: FC = ({ formik, project }) => { + const { data: profiles = [], isLoading: isProfileLoading } = + useProfiles(project); + + if (isProfileLoading) { + return ; + } + + const inheritedSshKeys = getInheritedSshKeys(formik.values, profiles); + + const getWarningMessage = () => { + if (formik.values.entityType === "profile") { + return "Changes get applied on instance creation or restart."; + } + if (formik.values.entityType === "instance" && !formik.values.isCreating) { + return "Changes get applied on instance restart."; + } + return undefined; + }; + + const warningMessage = getWarningMessage(); + + return ( +
+ {warningMessage && ( + {warningMessage} + )} + {inheritedSshKeys.length > 0 && ( +

Inherited SSH Keys

+ )} + {inheritedSshKeys.map((record) => ( +
+
+ + + +
+

+ From: {record.source} profile +

+
+ ))} + {formik.values.cloud_init_ssh_keys.length > 0 && ( +

Custom SSH Keys

+ )} + {formik.values.cloud_init_ssh_keys?.map((record) => ( +
+ { + ensureEditMode(formik); + formik.setFieldValue( + "cloud_init_ssh_keys", + formik.values.cloud_init_ssh_keys.map((key) => { + if (key.id !== record.id) { + return key; + } + return { + ...key, + name: e.target.value, + }; + }), + ); + }} + /> + { + ensureEditMode(formik); + formik.setFieldValue( + "cloud_init_ssh_keys", + formik.values.cloud_init_ssh_keys.map((key) => { + if (key.id !== record.id) { + return key; + } + return { + ...key, + user: e.target.value, + }; + }), + ); + }} + /> + { + ensureEditMode(formik); + formik.setFieldValue( + "cloud_init_ssh_keys", + formik.values.cloud_init_ssh_keys.map((key) => { + if (key.id !== record.id) { + return key; + } + return { + ...key, + fingerprint: e.target.value, + }; + }), + ); + }} + /> +
+ +
+
+ ))} + +
+ ); +}; + +export default SshKeyForm; diff --git a/src/components/forms/YamlForm.tsx b/src/components/forms/YamlForm.tsx index 3412e70ef2..384000bb14 100644 --- a/src/components/forms/YamlForm.tsx +++ b/src/components/forms/YamlForm.tsx @@ -3,8 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Editor, loader } from "@monaco-editor/react"; import { updateMaxHeight } from "util/updateMaxHeight"; import useEventListener from "util/useEventListener"; -import { editor } from "monaco-editor/esm/vs/editor/editor.api"; -import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; +import type { editor } from "monaco-editor/esm/vs/editor/editor.api"; import classnames from "classnames"; export interface YamlFormValues { @@ -28,7 +27,9 @@ const YamlForm: FC = ({ readOnly = false, readOnlyMessage, }) => { - const [editor, setEditor] = useState(null); + const [editor, setEditor] = useState( + null, + ); const containerRef = useRef(null); loader.config({ paths: { vs: "/ui/monaco-editor/min/vs" } }); @@ -77,7 +78,7 @@ const YamlForm: FC = ({ readOnly: readOnly, readOnlyMessage: { value: readOnlyMessage ?? "" }, }} - onMount={(editor: IStandaloneCodeEditor) => { + onMount={(editor: editor.IStandaloneCodeEditor) => { setEditor(editor); editor.focus(); }} diff --git a/src/pages/instances/CreateInstance.tsx b/src/pages/instances/CreateInstance.tsx index 8c32924b87..d49eeca45a 100644 --- a/src/pages/instances/CreateInstance.tsx +++ b/src/pages/instances/CreateInstance.tsx @@ -59,6 +59,7 @@ import InstanceFormMenu, { GPU_DEVICES, OTHER_DEVICES, PROXY_DEVICES, + SSH_KEYS, } from "pages/instances/forms/InstanceFormMenu"; import useEventListener from "util/useEventListener"; import { updateMaxHeight } from "util/updateMaxHeight"; @@ -93,6 +94,8 @@ import type { InstanceIconType } from "components/ResourceIcon"; import type { BootFormValues } from "components/forms/BootForm"; import BootForm, { bootPayload } from "components/forms/BootForm"; import { useProfiles } from "context/useProfiles"; +import type { SshKeyFormValues } from "components/forms/SshKeyForm"; +import SshKeyForm, { sshKeyPayload } from "components/forms/SshKeyForm"; export type CreateInstanceFormValues = InstanceDetailsFormValues & FormDeviceValues & @@ -102,6 +105,7 @@ export type CreateInstanceFormValues = InstanceDetailsFormValues & MigrationFormValues & BootFormValues & CloudInitFormValues & + SshKeyFormValues & YamlFormValues; interface PresetFormState { @@ -351,6 +355,7 @@ const CreateInstance: FC = () => { instanceType: "container", profiles: ["default"], devices: [], + cloud_init_ssh_keys: [], readOnly: false, entityType: "instance", isCreating: true, @@ -420,6 +425,7 @@ const CreateInstance: FC = () => { ...migrationPayload(values), ...bootPayload(values), ...cloudInitPayload(values), + ...sshKeyPayload(values), }, }; }; @@ -506,6 +512,10 @@ const CreateInstance: FC = () => { {section === CLOUD_INIT && } + {section === SSH_KEYS && ( + + )} + {section === YAML_CONFIGURATION && ( = ({ instance }) => { )} + {section === slugify(SSH_KEYS) && ( + + )} + {section === slugify(YAML_CONFIGURATION) && ( = ({ + diff --git a/src/pages/profiles/CreateProfile.tsx b/src/pages/profiles/CreateProfile.tsx index cf1031689a..80f7e624b7 100644 --- a/src/pages/profiles/CreateProfile.tsx +++ b/src/pages/profiles/CreateProfile.tsx @@ -50,6 +50,8 @@ import ProfileFormMenu, { NETWORK_DEVICES, GPU_DEVICES, OTHER_DEVICES, + PROXY_DEVICES, + SSH_KEYS, } from "pages/profiles/forms/ProfileFormMenu"; import type { ProfileDetailsFormValues } from "pages/profiles/forms/ProfileDetailsForm"; import ProfileDetailsForm, { @@ -74,10 +76,11 @@ import OtherDeviceForm from "components/forms/OtherDeviceForm"; import YamlSwitch from "components/forms/YamlSwitch"; import YamlNotification from "components/forms/YamlNotification"; import ProxyDeviceForm from "components/forms/ProxyDeviceForm"; -import { PROXY_DEVICES } from "pages/instances/forms/InstanceFormMenu"; import ResourceLink from "components/ResourceLink"; import type { BootFormValues } from "components/forms/BootForm"; import BootForm, { bootPayload } from "components/forms/BootForm"; +import type { SshKeyFormValues } from "components/forms/SshKeyForm"; +import SshKeyForm, { sshKeyPayload } from "components/forms/SshKeyForm"; export type CreateProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -87,6 +90,7 @@ export type CreateProfileFormValues = ProfileDetailsFormValues & MigrationFormValues & BootFormValues & CloudInitFormValues & + SshKeyFormValues & YamlFormValues; const CreateProfile: FC = () => { @@ -124,6 +128,7 @@ const CreateProfile: FC = () => { initialValues: { name: "", devices: [], + cloud_init_ssh_keys: [], readOnly: false, entityType: "profile", }, @@ -174,6 +179,7 @@ const CreateProfile: FC = () => { ...migrationPayload(values), ...bootPayload(values), ...cloudInitPayload(values), + ...sshKeyPayload(values), }, }; }; @@ -244,6 +250,10 @@ const CreateProfile: FC = () => { {section === CLOUD_INIT && } + {section === SSH_KEYS && ( + + )} + {section === YAML_CONFIGURATION && ( = ({ profile, featuresProfiles }) => { )} + {section === slugify(SSH_KEYS) && ( + + )} + {section === slugify(YAML_CONFIGURATION) && ( = ({ + diff --git a/src/sass/_forms.scss b/src/sass/_forms.scss index 24bc4c53b0..d231b63748 100644 --- a/src/sass/_forms.scss +++ b/src/sass/_forms.scss @@ -230,6 +230,26 @@ } } + .ssh-key-form { + .ssh-key { + align-items: flex-end; + display: flex; + gap: $sp-medium; + + .fingerprint { + flex-grow: 1; + } + + .name { + max-width: 4rem; + } + + .user { + max-width: 4rem; + } + } + } + .device-form { .configuration { padding-left: 0; diff --git a/src/util/configInheritance.tsx b/src/util/configInheritance.tsx index ec8d15f308..d8942c9d16 100644 --- a/src/util/configInheritance.tsx +++ b/src/util/configInheritance.tsx @@ -39,6 +39,8 @@ import type { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { useSettings } from "context/useSettings"; import { useProfiles } from "context/useProfiles"; import { useStoragePool } from "context/useStoragePools"; +import { parseSshKeys } from "util/instanceEdit"; +import type { SshKey } from "components/forms/SshKeyForm"; export interface ConfigRowMetadata { value?: string; @@ -372,3 +374,25 @@ export const getAppliedProfiles = ( values.profiles.indexOf(b.name) - values.profiles.indexOf(a.name), ); }; + +interface InheritedSshKey { + sshKey: SshKey; + source: string; +} + +export const getInheritedSshKeys = ( + values: InstanceAndProfileFormValues, + profiles: LxdProfile[], +): InheritedSshKey[] => { + const inheritedKeys: InheritedSshKey[] = []; + if (values.entityType === "instance") { + const appliedProfiles = getAppliedProfiles(values, profiles); + for (const profile of appliedProfiles) { + const profileKeys = parseSshKeys(profile); + profileKeys.forEach((sshKey) => + inheritedKeys.push({ sshKey, source: profile.name }), + ); + } + } + return inheritedKeys; +}; diff --git a/src/util/formFields.tsx b/src/util/formFields.tsx index 94c20b3c64..c0840c36d5 100644 --- a/src/util/formFields.tsx +++ b/src/util/formFields.tsx @@ -15,7 +15,10 @@ export const getUnhandledKeyValues = ( handledKeys: Set, ) => { return Object.fromEntries( - Object.entries(item).filter(([key]) => !handledKeys.has(key)), + Object.entries(item).filter( + ([key]) => + !handledKeys.has(key) && !key.startsWith("cloud-init.ssh-keys."), + ), ); }; diff --git a/src/util/instanceEdit.tsx b/src/util/instanceEdit.tsx index 618ad5203a..c0184b8444 100644 --- a/src/util/instanceEdit.tsx +++ b/src/util/instanceEdit.tsx @@ -15,6 +15,8 @@ import type { EditProfileFormValues } from "pages/profiles/EditProfile"; import { migrationPayload } from "components/forms/MigrationForm"; import type { ConfigurationRowFormikProps } from "components/ConfigurationRow"; import { bootPayload } from "components/forms/BootForm"; +import type { SshKey } from "components/forms/SshKeyForm"; +import { sshKeyPayload } from "components/forms/SshKeyForm"; const getEditValues = ( item: LxdProfile | LxdInstance, @@ -65,9 +67,27 @@ const getEditValues = ( cloud_init_network_config: item.config["cloud-init.network-config"], cloud_init_user_data: item.config["cloud-init.user-data"], cloud_init_vendor_data: item.config["cloud-init.vendor-data"], + cloud_init_ssh_keys: parseSshKeys(item), }; }; +export const parseSshKeys = (item: LxdProfile | LxdInstance): SshKey[] => { + const sshConfigKeys = Object.keys(item.config).filter((item) => + item.startsWith("cloud-init.ssh-keys."), + ); + + return sshConfigKeys.map((key) => { + const [user, fingerprint] = (item.config[key] as string).split(/:(.*)/s); // split on first occurrence of ":" + const name = key.split(".")[2]; + return { + id: name, + name: name, + user: user, + fingerprint: fingerprint, + }; + }); +}; + export const getInstanceEditValues = ( instance: LxdInstance, editRestriction?: string, @@ -120,6 +140,7 @@ export const getInstancePayload = ( ...migrationPayload(values), ...bootPayload(values), ...cloudInitPayload(values), + ...sshKeyPayload(values), ...getUnhandledKeyValues(instance.config, handledConfigKeys), }, ...getUnhandledKeyValues(instance, handledKeys), diff --git a/src/util/profileEdit.tsx b/src/util/profileEdit.tsx index 7bd04225ca..c772a07ad3 100644 --- a/src/util/profileEdit.tsx +++ b/src/util/profileEdit.tsx @@ -10,6 +10,7 @@ import type { EditProfileFormValues } from "pages/profiles/EditProfile"; import type { LxdProfile } from "types/profile"; import { migrationPayload } from "components/forms/MigrationForm"; import { bootPayload } from "components/forms/BootForm"; +import { sshKeyPayload } from "components/forms/SshKeyForm"; export const getProfilePayload = ( profile: LxdProfile, @@ -28,6 +29,7 @@ export const getProfilePayload = ( ...migrationPayload(values), ...bootPayload(values), ...cloudInitPayload(values), + ...sshKeyPayload(values), ...getUnhandledKeyValues(profile.config, handledConfigKeys), }, ...getUnhandledKeyValues(profile, handledKeys),