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
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export function SourceField(props: FieldProps) {
</p>
{option === "upload" && (
<p className="text-xs text-blue-600 mt-2">
After creating the disk, you can upload an image using the UI or virtctl command.
After creating the disk, upload the image with the virtctl command shown
on the disk's page. Browsers cannot reach the CDI upload proxy, so there
is no in-page upload.
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export function SourceWidget(props: WidgetProps) {
</p>
{option === "upload" && (
<p className="text-xs text-blue-600 mt-2">
After creating the disk, you can upload an image using the UI or virtctl command.
After creating the disk, upload the image with the virtctl command shown
on the disk's page. Browsers cannot reach the CDI upload proxy, so there
is no in-page upload.
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest"
import {
isUploadSource,
uploadState,
usableProxyURL,
virtctlUploadCommand,
UPLOAD_PROXY_URL_PLACEHOLDER,
type DataVolume,
} from "./vm-disk-upload.ts"

function dv(
source: Record<string, unknown> | undefined,
status?: DataVolume["status"],
): DataVolume {
return {
apiVersion: "cdi.kubevirt.io/v1beta1",
kind: "DataVolume",
metadata: { name: "vm-disk-demo", namespace: "tenant-root" },
...(source === undefined ? {} : { spec: { source } }),
...(status ? { status } : {}),
}
}

describe("isUploadSource", () => {
it("recognises an upload source", () => {
expect(isUploadSource(dv({ upload: {} }))).toBe(true)
})

it("rejects the other vm-disk sources", () => {
expect(isUploadSource(dv({ http: { url: "https://example.org/i.qcow2" } }))).toBe(false)
expect(isUploadSource(dv({ pvc: { name: "vm-default-images-ubuntu" } }))).toBe(false)
expect(isUploadSource(dv({ blank: {} }))).toBe(false)
})

it("rejects a DataVolume with no source and an unreadable one", () => {
expect(isUploadSource(dv(undefined))).toBe(false)
expect(isUploadSource(undefined)).toBe(false)
})

it("does not mistake an inherited property for an upload source", () => {
// Object.create puts `upload` on the prototype, where `in` would find it.
const inherited = Object.create({ upload: {} }) as Record<string, unknown>
expect(isUploadSource(dv(inherited))).toBe(false)
})
})

describe("uploadState", () => {
it("is awaiting-upload only at UploadReady — the one phase CDI accepts data in", () => {
expect(uploadState(dv({ upload: {} }, { phase: "UploadReady" })).stage).toBe(
"awaiting-upload",
)
})

it.each([
"",
"Pending",
"PVCBound",
"WaitForFirstConsumer",
"PendingPopulation",
"UploadScheduled",
])("treats phase %s as preparing", (phase) => {
expect(uploadState(dv({ upload: {} }, { phase })).stage).toBe("preparing")
})

it("reports Succeeded and Failed distinctly", () => {
expect(uploadState(dv({ upload: {} }, { phase: "Succeeded" })).stage).toBe("succeeded")
expect(uploadState(dv({ upload: {} }, { phase: "Failed" })).stage).toBe("failed")
})

it("surfaces the failure message from the Running condition", () => {
const state = uploadState(
dv({ upload: {} }, {
phase: "Failed",
conditions: [
{ type: "Bound", message: "bound" },
{ type: "Running", message: "upload server crashed" },
],
}),
)
expect(state.message).toBe("upload server crashed")
})

it("falls back to the Bound condition when Running carries no message", () => {
const state = uploadState(
dv({ upload: {} }, {
phase: "Failed",
conditions: [{ type: "Bound", message: "no capacity" }],
}),
)
expect(state.message).toBe("no capacity")
})

it("falls back to the Bound condition when Running carries an empty message", () => {
const state = uploadState(
dv({ upload: {} }, {
phase: "Failed",
conditions: [
{ type: "Bound", message: "no capacity" },
{ type: "Running", message: "" },
],
}),
)
expect(state.message).toBe("no capacity")
})

it("carries progress through at UploadReady", () => {
expect(
uploadState(dv({ upload: {} }, { phase: "UploadReady", progress: "42.0%" })).progress,
).toBe("42.0%")
})

it("is unknown for a missing DataVolume and for an unrecognised phase", () => {
expect(uploadState(undefined).stage).toBe("unknown")
expect(uploadState(dv({ upload: {} }, { phase: "ImportInProgress" })).stage).toBe(
"unknown",
)
})

it("does not report a phase it was never given", () => {
expect(uploadState(dv({ upload: {} }, {})).stage).toBe("preparing")
expect(uploadState(dv({ upload: {} }, {})).phase).toBe("")
})
})

describe("usableProxyURL", () => {
it("treats a blank or whitespace-only URL as no URL at all", () => {
expect(usableProxyURL(undefined)).toBeUndefined()
expect(usableProxyURL("")).toBeUndefined()
expect(usableProxyURL(" ")).toBeUndefined()
expect(usableProxyURL(" https://cdi-uploadproxy.example.org ")).toBe(
"https://cdi-uploadproxy.example.org",
)
})
})

describe("virtctlUploadCommand", () => {
it("reuses the chart-created DataVolume and skips certificate verification", () => {
const cmd = virtctlUploadCommand({
name: "vm-disk-demo",
namespace: "tenant-root",
uploadProxyURL: "https://cdi-uploadproxy.example.org",
})
expect(cmd).toContain("virtctl image-upload dv vm-disk-demo")
// The Helm release owns the DataVolume, so creating another one fails.
expect(cmd).toContain("--no-create")
// cdi-uploadproxy is published with TLS passthrough: CDI's own cert is on the wire.
expect(cmd).toContain("--insecure")
expect(cmd).toContain("--namespace=tenant-root")
expect(cmd).toContain("--uploadproxy-url=https://cdi-uploadproxy.example.org")
})

it("falls back to a visible placeholder when the cluster published no proxy URL", () => {
for (const uploadProxyURL of [undefined, "", " "]) {
const cmd = virtctlUploadCommand({
name: "vm-disk-demo",
namespace: "tenant-root",
uploadProxyURL,
})
expect(cmd).toContain(`--uploadproxy-url=${UPLOAD_PROXY_URL_PLACEHOLDER}`)
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { K8sResource } from "@cozystack/k8s-client"

export interface DataVolumeSpec {
source?: Record<string, unknown>
}

export interface DataVolumeStatus {
phase?: string
progress?: string
conditions?: {
type?: string
status?: string
reason?: string
message?: string
}[]
}

export type DataVolume = K8sResource<DataVolumeSpec, DataVolumeStatus>

export interface CDIConfigStatus {
uploadProxyURL?: string
}

export type CDIConfig = K8sResource<unknown, CDIConfigStatus>

/**
* Where an upload-source disk sits in the CDI lifecycle.
*
* `awaiting-upload` is the only stage in which the CDI upload server exists and
* accepts data — a token minted at any other stage has nothing to talk to.
*/
export type UploadStage =
| "preparing"
| "awaiting-upload"
| "succeeded"
| "failed"
| "unknown"

export interface UploadState {
stage: UploadStage
phase: string
progress?: string
message?: string
}

// CDI DataVolumePhase values that precede the upload server being reachable.
const PREPARING_PHASES = new Set([
"",
"Pending",
"PVCBound",
"WaitForFirstConsumer",
"PendingPopulation",
"UploadScheduled",
])

export function isUploadSource(dv: DataVolume | undefined): boolean {
const source = dv?.spec?.source
return !!source && Object.prototype.hasOwnProperty.call(source, "upload")
}

function failureMessage(dv: DataVolume): string | undefined {
const running = dv.status?.conditions?.find((c) => c.type === "Running")
const bound = dv.status?.conditions?.find((c) => c.type === "Bound")
// `||`, not `??`: an empty Running message must not shadow the Bound one.
return running?.message || bound?.message
}

export function uploadState(dv: DataVolume | undefined): UploadState {
if (!dv) return { stage: "unknown", phase: "" }
const phase = dv.status?.phase ?? ""
if (phase === "Succeeded") return { stage: "succeeded", phase }
if (phase === "Failed") {
return { stage: "failed", phase, message: failureMessage(dv) }
}
if (phase === "UploadReady") {
return { stage: "awaiting-upload", phase, progress: dv.status?.progress }
}
if (PREPARING_PHASES.has(phase)) return { stage: "preparing", phase }
return { stage: "unknown", phase }
}

export interface UploadCommandOptions {
name: string
namespace: string
/** CDIConfig.status.uploadProxyURL — absent when the platform published none. */
uploadProxyURL?: string
}

export const UPLOAD_PROXY_URL_PLACEHOLDER = "https://cdi-uploadproxy.<your-cozystack-domain>"

/**
* A whitespace-only `CDIConfig.status.uploadProxyURL` is as unusable as an
* absent one. The command builder and the panel's placeholder warning both
* read the URL through this, so they cannot disagree over which values need
* the placeholder.
*/
export function usableProxyURL(uploadProxyURL: string | undefined): string | undefined {
return uploadProxyURL?.trim() || undefined
}

/**
* The disk's DataVolume is created by the vm-disk Helm release, so the upload
* has to reuse it (`--no-create`); `--insecure` is required because Cozystack
* publishes cdi-uploadproxy through TLS passthrough, leaving CDI's internal
* self-signed certificate on the wire.
*/
export function virtctlUploadCommand(opts: UploadCommandOptions): string {
const proxy = usableProxyURL(opts.uploadProxyURL) ?? UPLOAD_PROXY_URL_PLACEHOLDER

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NIT] uploadProxyURL interpolated into the shell command without quoting or validation

[NIT] usableProxyURL only trims the edges. CDIConfig.status.uploadProxyURL is a free-form platform-controlled string: an internal space silently splits the command's arguments, and a value like https://x ; curl ... | sh would execute in the shell of anyone who copies the command. In the cozystack trust model this is not an escalation (the platform admin already owns the console JS), but a new URL() validation or single-quoting the URL is a one-line hardening. name/namespace are safe (DNS-1123 names of existing objects).

return [
"virtctl image-upload dv",
opts.name,
"--no-create",
`--namespace=${opts.namespace}`,
"--image-path=./disk.qcow2",
`--uploadproxy-url=${proxy}`,
"--insecure",
].join(" ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { SecretsTab } from "./SecretsTab.tsx"
import { EventsTab } from "./EventsTab.tsx"
import { VncTab } from "./VncTab.tsx"
import { VMPowerControls } from "./VMPowerControls.tsx"
import { DiskUploadPanel } from "./DiskUploadPanel.tsx"

export function ApplicationDetailPage() {
const { plural, name } = useParams<{ plural: string; name: string }>()
Expand Down Expand Up @@ -184,7 +185,17 @@ export function ApplicationDetailPage() {

<div className="flex-1 overflow-auto">
<Routes>
<Route index element={<OverviewTab ad={ad} instance={instance} />} />
<Route
index
element={
<>
{kind === "VMDisk" && (
<DiskUploadPanel ad={ad} instance={instance} />
)}
<OverviewTab ad={ad} instance={instance} />
</>
}
/>
<Route
path="workloads"
element={<WorkloadsTab ad={ad} instance={instance} />}
Expand Down
Loading
Loading