Skip to content

VM port and custom-domain updates silently fail with an existing custom domain #370

Description

@LogFlames

Bug issue:

I wasn't able to create more port forwards after the first one, nor change its custom domain settings. It kept saying "saving..." but nothing happened.

I debugged with AI which gave the following writeup (I have confirmed this is the case by using the API directly which worked):

Summary

The VM port and proxy editors submit read-model port objects as update-model objects. This breaks updates when any existing port has an HTTP proxy with a custom domain.

The relevant API types are structurally different:

// Read model
interface HttpProxyRead {
  name: string;
  url?: string;
  customDomain?: {
    domain: string;
    url: string;
    status: string;
    secret: string;
  };
}

// Update model
interface HttpProxyUpdate {
  name?: string;
  customDomain?: string;
}

A TypeScript assertion is being used where an actual read-to-update conversion is required. Assertions do not transform the runtime object, so an untouched custom domain is sent as an object even though the API expects a string.

There is a second issue: updateVM() does not check response.ok. API validation errors are JSON objects, so they are treated as successful responses, passed to queueJob(), and presented as "saving..." instead of showing an error.

Steps to reproduce

  1. Create a VM port-forwarding rule.
  2. Add an HTTP proxy with an active custom domain to that rule.
  3. Try either of the following:
    • add another port-forwarding rule in Port forwarding; or
    • add/change custom-domain settings for another proxy.
  4. Observe that the UI says it is saving, but the change is absent after refreshing.

Actual behavior

  • The frontend submits an invalid update payload containing a read-side customDomain object.
  • The API rejects the request.
  • The frontend treats the JSON error response as a successful job response.
  • The UI displays "saving...", but nothing changes.

Expected behavior

  • Every port should be explicitly converted to PortUpdate before submission.
  • Existing proxies and custom domains should remain unchanged unless explicitly edited.
  • Non-2xx responses should display the API error and should not be passed to the job queue.

PortManager cause

PortManager initializes its state directly from vm.ports, then adds the new port to that read-model array:

https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/PortManager.tsx#L60-L108

The incompatible array is hidden with:

ports: newPorts as PortUpdate[]

If an existing port has a custom domain, its CustomDomainRead object is serialized unchanged.

ProxyManager cause

ProxyManager also starts with the full read-model array:

https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/ProxyManager.tsx#L122-L143

const portsList = vm.ports as PortCreate[];

It reconstructs only the selected proxy. Other proxies remain read objects, after which the complete array is asserted as PortUpdate[].

For the selected proxy, customDomain is correctly sent as a string. However, an untouched proxy can still be sent like this:

{
  "httpProxy": {
    "name": "existing-proxy",
    "url": "https://existing-proxy.vm-app.cloud.cbh.kth.se",
    "customDomain": {
      "domain": "existing.example.com",
      "url": "https://existing.example.com",
      "status": "active",
      "secret": "..."
    }
  }
}

The entire update is then rejected because HttpProxyUpdate.customDomain expects a string.

ProxyManager also mutates objects from vm.ports directly before the request completes.

Error-handling cause

updateVM() parses the response but never checks its HTTP status:

https://github.com/kthcloud/console/blob/main/src/api/deploy/vms.ts#L68-L83

Conceptually it does:

const result = await response.json();
if (typeof result !== "object") throw new Error(...);
return result;

A validation error is an object, so it is returned as though the update succeeded.

Suggested fix

  1. Add an explicit PortRead to PortUpdate mapper and remove the unsafe casts.
  2. Build new objects rather than mutating vm.ports.
  3. Include only writable fields: name, port, protocol, and writable httpProxy fields.
  4. For an unchanged existing proxy, send its name but omit customDomain. The backend treats omission as unchanged. Re-sending an unchanged domain string may regenerate its verification secret and return it to pending state.
  5. Send a domain string only when it is explicitly added or changed; send the API's deletion representation only when explicitly removed.
  6. Make updateVM() throw the parsed API error whenever !response.ok.

For example, preserving an existing proxy while updating the VM should produce an entry shaped like:

{
  name: port.name,
  port: port.port,
  protocol: port.protocol,
  httpProxy: port.httpProxy
    ? { name: port.httpProxy.name }
    : undefined,
}

Acceptance criteria

  • A second port can be added when another port has an active custom domain.
  • Proxy/custom-domain settings can be changed when another proxy has an active custom domain.
  • Existing custom domains retain their domain, verification status, and secret when unrelated ports are edited.
  • Requests contain no read-only externalPort, proxy url, custom-domain status, or custom-domain secret fields.
  • Non-2xx responses show a useful error and are never passed to queueJob().
  • Tests cover both PortManager and ProxyManager with an existing active custom-domain proxy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions