Skip to content

Commit

Permalink
feat(ssh-keys) add ssh key configuration for instance edit
Browse files Browse the repository at this point in the history
Signed-off-by: David Edler <[email protected]>
  • Loading branch information
edlerd committed Mar 5, 2025
1 parent 8988790 commit 81ca7f7
Show file tree
Hide file tree
Showing 13 changed files with 331 additions and 7 deletions.
213 changes: 213 additions & 0 deletions src/components/forms/SshKeyForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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<Props> = ({ formik, project }) => {
const { data: profiles = [], isLoading: isProfileLoading } =
useProfiles(project);

if (isProfileLoading) {
return <Loader />;
}

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 (
<div className="ssh-key-form">
{warningMessage && (
<Notification severity="information">{warningMessage}</Notification>
)}
{inheritedSshKeys.length > 0 && (
<h2 className="p-heading--4">Inherited SSH Keys</h2>
)}
{inheritedSshKeys.map((record) => (
<div key={record.sshKey.name}>
<div className="ssh-key">
<Input
label="Name"
type="text"
value={record.sshKey.name}
className="name"
disabled
readOnly
/>
<Input
label="User"
type="text"
value={record.sshKey.user}
className="user"
disabled
readOnly
/>
<Input
label="Fingerprint"
type="text"
value={record.sshKey.fingerprint}
wrapperClassName="fingerprint"
disabled
readOnly
/>
</div>
<p className="p-text--small u-text--muted">
From: {record.source} profile
</p>
</div>
))}
{formik.values.cloud_init_ssh_keys.length > 0 && (
<h2 className="p-heading--4">Custom SSH Keys</h2>
)}
{formik.values.cloud_init_ssh_keys?.map((record) => (
<div key={record.id} className="ssh-key">
<Input
label="Name"
type="text"
value={record.name}
className="name"
onChange={(e) => {
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,
};
}),
);
}}
/>
<Input
label="User"
type="text"
value={record.user}
className="user"
onChange={(e) => {
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,
};
}),
);
}}
/>
<Input
label="Fingerprint"
type="text"
value={record.fingerprint}
wrapperClassName="fingerprint"
onChange={(e) => {
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,
};
}),
);
}}
/>
<div>
<Button
onClick={() => {
ensureEditMode(formik);
formik.setFieldValue(
"cloud_init_ssh_keys",
formik.values.cloud_init_ssh_keys.filter(
(key) => key.name !== record.name,
),
);
}}
type="button"
title="Remove key"
hasIcon
>
<Icon name="delete" />
</Button>
</div>
</div>
))}
<Button
type="button"
onClick={() => {
ensureEditMode(formik);
formik.setFieldValue("cloud_init_ssh_keys", [
...formik.values.cloud_init_ssh_keys,
{
id: `ssh-key-${formik.values.cloud_init_ssh_keys.length + 1}`,
name: `ssh-key-${formik.values.cloud_init_ssh_keys.length + 1}`,
user: "root",
fingerprint: "",
},
]);
}}
hasIcon
>
<Icon name="plus" />
<span>Attach SSH key</span>
</Button>
</div>
);
};

export default SshKeyForm;
9 changes: 5 additions & 4 deletions src/components/forms/YamlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,7 +27,9 @@ const YamlForm: FC<Props> = ({
readOnly = false,
readOnlyMessage,
}) => {
const [editor, setEditor] = useState<IStandaloneCodeEditor | null>(null);
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor | null>(
null,
);
const containerRef = useRef<HTMLDivElement>(null);

loader.config({ paths: { vs: "/ui/monaco-editor/min/vs" } });
Expand Down Expand Up @@ -77,7 +78,7 @@ const YamlForm: FC<Props> = ({
readOnly: readOnly,
readOnlyMessage: { value: readOnlyMessage ?? "" },
}}
onMount={(editor: IStandaloneCodeEditor) => {
onMount={(editor: editor.IStandaloneCodeEditor) => {
setEditor(editor);
editor.focus();
}}
Expand Down
10 changes: 10 additions & 0 deletions src/pages/instances/CreateInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 &
Expand All @@ -102,6 +105,7 @@ export type CreateInstanceFormValues = InstanceDetailsFormValues &
MigrationFormValues &
BootFormValues &
CloudInitFormValues &
SshKeyFormValues &
YamlFormValues;

interface PresetFormState {
Expand Down Expand Up @@ -351,6 +355,7 @@ const CreateInstance: FC = () => {
instanceType: "container",
profiles: ["default"],
devices: [],
cloud_init_ssh_keys: [],
readOnly: false,
entityType: "instance",
isCreating: true,
Expand Down Expand Up @@ -420,6 +425,7 @@ const CreateInstance: FC = () => {
...migrationPayload(values),
...bootPayload(values),
...cloudInitPayload(values),
...sshKeyPayload(values),
},
};
};
Expand Down Expand Up @@ -506,6 +512,10 @@ const CreateInstance: FC = () => {

{section === CLOUD_INIT && <CloudInitForm formik={formik} />}

{section === SSH_KEYS && (
<SshKeyForm formik={formik} project={project} />
)}

{section === YAML_CONFIGURATION && (
<YamlForm
yaml={getYaml()}
Expand Down
8 changes: 8 additions & 0 deletions src/pages/instances/EditInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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";
Expand Down Expand Up @@ -66,6 +67,8 @@ import BootForm from "components/forms/BootForm";
import { useInstanceEntitlements } from "util/entitlements/instances";
import InstanceProfilesWarning from "./InstanceProfilesWarning";
import { useProfiles } from "context/useProfiles";
import type { SshKeyFormValues } from "components/forms/SshKeyForm";
import SshKeyForm from "components/forms/SshKeyForm";

export interface InstanceEditDetailsFormValues {
name: string;
Expand All @@ -87,6 +90,7 @@ export type EditInstanceFormValues = InstanceEditDetailsFormValues &
MigrationFormValues &
BootFormValues &
CloudInitFormValues &
SshKeyFormValues &
YamlFormValues;

interface Props {
Expand Down Expand Up @@ -262,6 +266,10 @@ const EditInstance: FC<Props> = ({ instance }) => {
<CloudInitForm key={`yaml-form-${version}`} formik={formik} />
)}

{section === slugify(SSH_KEYS) && (
<SshKeyForm formik={formik} project={project} />
)}

{section === slugify(YAML_CONFIGURATION) && (
<YamlForm
key={`yaml-form-${version}`}
Expand Down
2 changes: 2 additions & 0 deletions src/pages/instances/forms/InstanceFormMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const MIGRATION = "Migration";
export const SNAPSHOTS = "Snapshots";
export const BOOT = "Boot";
export const CLOUD_INIT = "Cloud init";
export const SSH_KEYS = "SSH keys";
export const YAML_CONFIGURATION = "YAML configuration";

interface Props {
Expand Down Expand Up @@ -102,6 +103,7 @@ const InstanceFormMenu: FC<Props> = ({
<MenuItem label={MIGRATION} {...menuItemProps} />
<MenuItem label={BOOT} {...menuItemProps} />
<MenuItem label={CLOUD_INIT} {...menuItemProps} />
<MenuItem label={SSH_KEYS} {...menuItemProps} />
</ul>
</nav>
</div>
Expand Down
Loading

0 comments on commit 81ca7f7

Please sign in to comment.