-
Notifications
You must be signed in to change notification settings - Fork 1
feat(console): VM power controls (start/stop/restart) + VNC fixes #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
01e0aee
feat(console): add VM power controls (start/stop/restart)
kvaps 5a8ea58
fix(console): use prefixed VMI name for VNC connection
kvaps d59b625
fix(k8s-client): handle empty 2xx response bodies
kvaps ca15435
fix(console): don't open VNC when the VM is not running
kvaps bd62ef7
style(console): match cozyportal VNC console toolbar
kvaps 8e80930
fix(console): proxy WebSockets in dev so the VNC console connects
kvaps 7397c74
Merge remote-tracking branch 'origin/main' into tmp/fix-vm-power
kvaps 3970b99
test(k8s-client): cover subresource calls and empty 2xx body handling
lexfrei acc13ed
fix(k8s-client): refresh the target resource after a subresource action
lexfrei 9c6287b
fix(console): correct VM power controls and stream status via watch
lexfrei 610039a
fix(console): stream VNC power state via watch and share VM-name reso…
lexfrei a9883e4
fix(console): match Helm-managed resources by prefixed release name i…
lexfrei 8674911
refactor(forms): type the additionalProperties walk without as-any
lexfrei File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
118 changes: 118 additions & 0 deletions
118
apps/console/src/__tests__/k8s-client/client.subresource.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { describe, it, expect, vi, afterEach } from "vitest" | ||
| import { K8sClient } from "@cozystack/k8s-client" | ||
|
|
||
| function fakeResponse(opts: { | ||
| ok?: boolean | ||
| status?: number | ||
| statusText?: string | ||
| body?: string | ||
| }): Response { | ||
| return { | ||
| ok: opts.ok ?? true, | ||
| status: opts.status ?? 200, | ||
| statusText: opts.statusText ?? "OK", | ||
| text: async () => opts.body ?? "", | ||
| json: async () => JSON.parse(opts.body ?? "null"), | ||
| } as unknown as Response | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals() | ||
| }) | ||
|
|
||
| describe("K8sClient.subresource", () => { | ||
| it("PUTs to /{plural}/{name}/{subresource} under the aggregated API group", async () => { | ||
| const fetchMock = vi.fn(async () => fakeResponse({ body: "" })) | ||
| vi.stubGlobal("fetch", fetchMock) | ||
| const client = new K8sClient({ baseUrl: "/base" }) | ||
|
|
||
| await client.subresource( | ||
| "subresources.kubevirt.io", | ||
| "v1", | ||
| "virtualmachines", | ||
| "vm-instance-demo", | ||
| "start", | ||
| "tenant-root", | ||
| ) | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledWith( | ||
| "/base/apis/subresources.kubevirt.io/v1/namespaces/tenant-root/virtualmachines/vm-instance-demo/start", | ||
| expect.objectContaining({ method: "PUT" }), | ||
| ) | ||
| }) | ||
|
|
||
| it("POSTs when method is POST", async () => { | ||
| const fetchMock = vi.fn(async () => fakeResponse({ body: "" })) | ||
| vi.stubGlobal("fetch", fetchMock) | ||
| const client = new K8sClient() | ||
|
|
||
| await client.subresource( | ||
| "subresources.kubevirt.io", | ||
| "v1", | ||
| "virtualmachines", | ||
| "vm", | ||
| "restart", | ||
| "ns", | ||
| {}, | ||
| "POST", | ||
| ) | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ method: "POST" }), | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| describe("K8sClient.request empty-body handling", () => { | ||
| it("returns undefined for an empty 2xx body (KubeVirt action subresources)", async () => { | ||
| // virtualmachines/{name}/start|stop|restart answer 2xx with no body; | ||
| // JSON.parse("") would throw "Unexpected end of JSON input". | ||
| vi.stubGlobal("fetch", vi.fn(async () => fakeResponse({ status: 200, body: "" }))) | ||
| const client = new K8sClient() | ||
|
|
||
| const result = await client.subresource( | ||
| "subresources.kubevirt.io", | ||
| "v1", | ||
| "virtualmachines", | ||
| "vm", | ||
| "stop", | ||
| ) | ||
|
|
||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("parses the JSON body when a 2xx response is non-empty", async () => { | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async () => fakeResponse({ status: 200, body: JSON.stringify({ kind: "VirtualMachine" }) })), | ||
| ) | ||
| const client = new K8sClient() | ||
|
|
||
| const result = await client.get("kubevirt.io", "v1", "virtualmachines", "vm", "ns") | ||
|
|
||
| expect(result).toEqual({ kind: "VirtualMachine" }) | ||
| }) | ||
|
|
||
| it("short-circuits to undefined on 204 No Content without reading the body", async () => { | ||
| const textSpy = vi.fn(async () => "should-not-be-read") | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn( | ||
| async () => | ||
| ({ | ||
| ok: true, | ||
| status: 204, | ||
| statusText: "No Content", | ||
| text: textSpy, | ||
| }) as unknown as Response, | ||
| ), | ||
| ) | ||
| const client = new K8sClient() | ||
|
|
||
| const result = await client.delete("", "v1", "configmaps", "cm", "ns") | ||
|
|
||
| expect(result).toBeUndefined() | ||
| expect(textSpy).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
127 changes: 127 additions & 0 deletions
127
apps/console/src/__tests__/k8s-client/useK8sSubresource.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { describe, it, expect, vi } from "vitest" | ||
| import { renderHook, act } from "@testing-library/react" | ||
| import { QueryClient, QueryClientProvider } from "@tanstack/react-query" | ||
| import { K8sClient, K8sProvider, useK8sSubresource } from "@cozystack/k8s-client" | ||
| import type { ReactNode } from "react" | ||
|
|
||
| const actionRef = { | ||
| apiGroup: "subresources.kubevirt.io", | ||
| apiVersion: "v1", | ||
| plural: "virtualmachines", | ||
| name: "vm-instance-demo", | ||
| namespace: "tenant-root", | ||
| } | ||
|
|
||
| // The VirtualMachine (with its status) is served under kubevirt.io, not the | ||
| // subresources.kubevirt.io aggregated API the action endpoint lives under. | ||
| const statusRef = { | ||
| apiGroup: "kubevirt.io", | ||
| apiVersion: "v1", | ||
| plural: "virtualmachines", | ||
| namespace: "tenant-root", | ||
| } | ||
|
|
||
| function setup(gcTime = 0) { | ||
| const client = new K8sClient() | ||
| vi.spyOn(client, "subresource").mockResolvedValue(undefined) | ||
| const queryClient = new QueryClient({ | ||
| defaultOptions: { queries: { retry: false, gcTime } }, | ||
| }) | ||
| const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") | ||
| function wrapper({ children }: { children: ReactNode }) { | ||
| return ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <K8sProvider client={client} queryClient={queryClient}> | ||
| {children} | ||
| </K8sProvider> | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
| return { client, queryClient, invalidateSpy, wrapper } | ||
| } | ||
|
|
||
| describe("useK8sSubresource", () => { | ||
| it("calls the action subresource with a default empty body", async () => { | ||
| const { client, wrapper } = setup() | ||
| const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper }) | ||
|
|
||
| await act(async () => { | ||
| await result.current.mutateAsync({ subresource: "start" }) | ||
| }) | ||
|
|
||
| expect(client.subresource).toHaveBeenCalledWith( | ||
| "subresources.kubevirt.io", | ||
| "v1", | ||
| "virtualmachines", | ||
| "vm-instance-demo", | ||
| "start", | ||
| "tenant-root", | ||
| {}, | ||
| undefined, | ||
| ) | ||
| }) | ||
|
|
||
| it("invalidates the action ref's own resource key when no invalidate target is given", async () => { | ||
| const { invalidateSpy, wrapper } = setup() | ||
| const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper }) | ||
|
|
||
| await act(async () => { | ||
| await result.current.mutateAsync({ subresource: "start" }) | ||
| }) | ||
|
|
||
| expect(invalidateSpy).toHaveBeenCalledWith({ | ||
| queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"], | ||
| }) | ||
| }) | ||
|
|
||
| it("invalidates the target resource (kubevirt.io) when invalidate is supplied", async () => { | ||
| const { invalidateSpy, wrapper } = setup() | ||
| const { result } = renderHook( | ||
| () => useK8sSubresource(actionRef, { invalidate: statusRef }), | ||
| { wrapper }, | ||
| ) | ||
|
|
||
| await act(async () => { | ||
| await result.current.mutateAsync({ subresource: "start" }) | ||
| }) | ||
|
|
||
| // Regression guard: the key must be built from the kubevirt.io status ref, | ||
| // otherwise it never matches the query that holds printableStatus. | ||
| expect(invalidateSpy).toHaveBeenCalledWith({ | ||
| queryKey: ["k8s", "kubevirt.io", "v1", "virtualmachines", "tenant-root"], | ||
| }) | ||
| expect(invalidateSpy).not.toHaveBeenCalledWith({ | ||
| queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"], | ||
| }) | ||
| }) | ||
|
|
||
| it("invalidates a metadata.name field-selected list of the target resource", async () => { | ||
| // gcTime Infinity so the manually seeded (observer-less) query isn't | ||
| // garbage-collected before we read its post-invalidation state. | ||
| const { queryClient, wrapper } = setup(Infinity) | ||
| // The watch-based status read keys its list query with a field-selector. | ||
| // The resource-prefix invalidation must reach it (React Query prefix match), | ||
| // not just an unfiltered list — otherwise the post-action refresh is dead. | ||
| const fieldSelectedListKey = [ | ||
| "k8s", | ||
| "kubevirt.io", | ||
| "v1", | ||
| "virtualmachines", | ||
| "tenant-root", | ||
| "", | ||
| "metadata.name=vm-instance-demo", | ||
| ] | ||
| queryClient.setQueryData(fieldSelectedListKey, { items: [] }) | ||
| expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(false) | ||
|
|
||
| const { result } = renderHook( | ||
| () => useK8sSubresource(actionRef, { invalidate: statusRef }), | ||
| { wrapper }, | ||
| ) | ||
| await act(async () => { | ||
| await result.current.mutateAsync({ subresource: "start" }) | ||
| }) | ||
|
|
||
| expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(true) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { describe, it, expect } from "vitest" | ||
| import { releasePrefix } from "./app-definitions.ts" | ||
| import type { ApplicationDefinition } from "@cozystack/types" | ||
|
|
||
| function ad(overrides: Partial<ApplicationDefinition> = {}): ApplicationDefinition { | ||
| return { | ||
| apiVersion: "cozystack.io/v1alpha1", | ||
| kind: "ApplicationDefinition", | ||
| metadata: { name: "virtual-machine" }, | ||
| spec: { | ||
| application: { | ||
| kind: "VMInstance", | ||
| plural: "vminstances", | ||
| singular: "vm-instance", | ||
| openAPISchema: "{}", | ||
| }, | ||
| }, | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| describe("releasePrefix", () => { | ||
| it("returns the explicit release.prefix when set", () => { | ||
| expect( | ||
| releasePrefix( | ||
| ad({ | ||
| spec: { | ||
| application: { | ||
| kind: "VMInstance", | ||
| plural: "vminstances", | ||
| singular: "vm-instance", | ||
| openAPISchema: "{}", | ||
| }, | ||
| release: { prefix: "custom-" }, | ||
| }, | ||
| }), | ||
| ), | ||
| ).toBe("custom-") | ||
| }) | ||
|
|
||
| it("falls back to '<singular>-' when release.prefix is unset", () => { | ||
| expect(releasePrefix(ad())).toBe("vm-instance-") | ||
| }) | ||
|
|
||
| it("falls back to '<metadata.name>-' when neither prefix nor spec is present", () => { | ||
| expect(releasePrefix(ad({ spec: undefined }))).toBe("virtual-machine-") | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't skip tuple-array item schemas.
!Array.isArray(items)means this walker no longer visits tuple-style arrays (items: [...]), so anyadditionalPropertiesmap nested in those item schemas will miss the custom field binding and fall back to the broken native map rendering path. Please recurse through both objectitemsand arrayitemshere. As per coding guidelines: "For form widget binding, walk bothpropertiesanditemsfor arrays, but do not walkoneOf/anyOf/allOfunless a real chart needs it".Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents