Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/dashboard/src/lib/remote/vms.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { requireProjectAccess } from '$lib/server/auth-context';
import { isProjectBillingExempt, requireProjectBillingActive } from '$lib/server/billing/autumn';
import { queueVmDeletion } from '$lib/server/vm-deletion';
import { provisionVm } from '$lib/server/vm-provisioning';
import { isValidPtrHostname } from '$lib/ptr';
import { instrument, timingLog } from '$lib/server/observability';
import {
accessibilityFixtureEnabled,
Expand Down Expand Up @@ -578,6 +579,27 @@ export const createVm = command(createParams, async (params) => {
});
});

const updateHostnameParams = type({ vmId: 'string', hostname: 'string' });
export const updateVmHostname = command(updateHostnameParams, async (params) => {
const event = getRequestEvent();
if (!event?.locals.user) error(401, 'Authentication required');

const db = initDrizzle();
const row = await db.query.vms.findFirst({ where: eq(vms.id, params.vmId) });
if (!row) error(404, `VM "${params.vmId}" not found`);
if (!row.active) error(400, 'Cannot rename an inactive VM');
if (!row.ownerProjectId) error(400, 'VM is not attached to a project');
await requireProjectAccess(db, event.locals.user.id, row.ownerProjectId, 'read_write');

const hostname = params.hostname.trim();
if (!isValidPtrHostname(hostname)) error(400, 'Hostname must be a valid hostname');

const backend = getBackend(row.backend);
await backend.updateVmHostname(row.id, hostname, row.proxmoxId ?? undefined);
await db.update(vms).set({ name: hostname }).where(eq(vms.id, row.id));
return { id: row.id, name: hostname };
});

const deleteParams = type({ vmId: 'string' });
export const deleteVm = command(deleteParams, async (params) => {
const event = getRequestEvent();
Expand Down
29 changes: 28 additions & 1 deletion apps/dashboard/src/lib/server/backends/proxmox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type ProxmoxBackendOptions = {
};

type CloudInitVendorConfigParams = {
hostname?: string;
enableSshPasswordAuth?: boolean;
};

Expand All @@ -95,6 +96,7 @@ function firstIpv6AddressInPrefix(prefix: string) {

function cloudInitVendorConfig(params: CloudInitVendorConfigParams) {
const yamlContents = `#cloud-config\n${stringifyYaml({
...(params.hostname ? { hostname: params.hostname, manage_etc_hosts: true } : {}),
write_files: [
{
path: '/etc/sysctl.d/99-ipv6-forwarding.conf',
Expand Down Expand Up @@ -540,7 +542,10 @@ export class ProxmoxBackend implements VmBackend {
),
this.uploadSnippet(
cloudInitVendorConfigFilename,
cloudInitVendorConfig({ enableSshPasswordAuth: Boolean(params.password) })
cloudInitVendorConfig({
hostname: params.name,
enableSshPasswordAuth: Boolean(params.password)
})
)
]);

Expand Down Expand Up @@ -644,6 +649,28 @@ export class ProxmoxBackend implements VmBackend {
};
}

async updateVmHostname(id: string, hostname: string, proxmoxId?: number): Promise<void> {
clearProxmoxReadCaches();
const { node, vmid } = await this.resolve(id, proxmoxId);
const storage = await this.snippetStorage(node);
const filename = `stack-${vmid}-hostname.yaml`;
await this.uploadSnippet(
filename,
`#cloud-config\\n${stringifyYaml({ hostname, manage_etc_hosts: true })}`
);

const currentConfig = await this.client.getQemuConfig(node, vmid);
const customConfigs = (currentConfig.cicustom ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry && !entry.startsWith('user='));
customConfigs.push(`user=${storage}:snippets/${filename}`);
const upid = await this.client.updateQemuConfigAsync(node, vmid, {
cicustom: customConfigs.join(',')
});
await this.client.waitForTask(node, upid);
}

async deleteVm(id: string, proxmoxId?: number): Promise<void> {
clearProxmoxReadCaches();
let resolved: ResolvedVm;
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/lib/server/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export interface VmBackend {
options?: Pick<VmLookupOptions, 'proxmoxNode'>
): Promise<VmMetricsHistorySample[]>;
createVm(params: VmCreateParams): Promise<VmCreateResult>;
updateVmHostname(id: string, hostname: string, proxmoxId?: number): Promise<void>;
deleteVm(id: string, proxmoxId?: number): Promise<void>;
startVm(id: string, proxmoxId?: number): Promise<void>;
stopVm(id: string, proxmoxId?: number): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,36 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import ComingSoon from '$lib/components/coming-soon.svelte';
import { confirmDestructive } from '$lib/confirm.svelte';
import { deleteVm } from '$lib/remote/vms.remote';
import { deleteVm, updateVmHostname } from '$lib/remote/vms.remote';
import { getErrorMessage } from '$lib/utils';

let { data }: PageProps = $props();
let selectedServer = $derived(getServerWithFallback(data.serverId, data.server));
let nameValue = $derived(selectedServer.name);
let nameValue = $state('');
let saving = $state(false);
$effect(() => {
if (!nameValue) nameValue = selectedServer.name;
});
let deleting = $state(false);
let settingsError = $state('');
let deleteError = $state('');

async function handleSave() {
const hostname = nameValue.trim();
if (!hostname || saving || hostname === selectedServer.name) return;
saving = true;
settingsError = '';
try {
await updateVmHostname({ vmId: selectedServer.id, hostname });
await invalidate('project:vms');
} catch (error) {
settingsError = getErrorMessage(error, 'Failed to update server hostname.');
} finally {
saving = false;
}
}

async function handleDelete() {
if (deleting) return;
const ok = await confirmDestructive({
Expand Down Expand Up @@ -49,11 +69,14 @@
</p>
</div>
<div class="space-y-2">
<Label for="server-name-input">Server Name</Label><Input
<Label for="server-name-input">Server hostname</Label><Input
id="server-name-input"
bind:value={nameValue}
disabled
disabled={saving || selectedServer.status === 'deleting'}
/>
<p class="text-xs text-muted-foreground">
This updates the guest hostname and the server name shown in the dashboard.
</p>
</div>
<div class="space-y-2">
<Label for="server-id-input">Server ID</Label><Input
Expand All @@ -63,12 +86,23 @@
class="font-mono"
/>
</div>
{#if settingsError}
<p class="text-xs text-red-400">{settingsError}</p>
{/if}
{#if deleteError}
<p class="text-xs text-red-400">{deleteError}</p>
{/if}
<div class="flex items-center gap-2">
<Button size="sm" disabled>Save Changes</Button>
<ComingSoon />
<Button
size="sm"
disabled={saving ||
selectedServer.status === 'deleting' ||
!nameValue.trim() ||
nameValue.trim() === selectedServer.name}
onclick={handleSave}
>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
<div class="border-t border-border pt-4">
<Button
Expand Down