Skip to content
Merged
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
25 changes: 20 additions & 5 deletions web/services/vms/drivers/freestyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "freestyle";

import { createHash, randomBytes } from "node:crypto";
import { isIP } from "node:net";
import { Effect } from "effect";
import { announceFreestyleNetwork } from "./freestyleNetworkAnnouncement";
import { guestResourceReporterInstallCommand } from "../guestResourceReporter";
Expand Down Expand Up @@ -407,8 +408,8 @@ export function freestyleNetworkAddressMetadata(
const ipv4 = network?.ipv4?.trim();
const ipv6 = network?.ipv6?.trim();
return {
...(ipv4 ? { networkIpv4: ipv4 } : {}),
...(ipv6 ? { networkIpv6: ipv6 } : {}),
...(ipv4 && isIP(ipv4) === 4 ? { networkIpv4: ipv4 } : {}),
...(ipv6 && isIP(ipv6) === 6 ? { networkIpv6: ipv6 } : {}),
};
}

Expand Down Expand Up @@ -963,6 +964,11 @@ export class FreestyleProvider implements VMProvider {
"cmux.vm.network.private": !!networkId,
});
try {
// Validate the provider-assigned VPC address without issuing the
// guest-side announcement exec. The baked supervisor announces on
// clone boot; attach performs the strict announcement before
// handing out the private daemon route.
if (networkId) await this.announcePrivateAddresses(vm, data, { validateOnly: true });
if (options.imageSize) {
// One snapshot per size: the machine already boots at the shape
// that was sold, so nothing is read back and nothing is grown.
Expand All @@ -985,7 +991,12 @@ export class FreestyleProvider implements VMProvider {
// The in-VM shim is a separate convenience layer over the baked
// daemon and is installed idempotently for agents and peer links.
await this.installGuestCli(vm, vmId, options.promptIdentity);
await this.announcePrivateAddresses(vm, data);
// The baked supervisor announces the VPC interface on clone boot
// and every 30 seconds. Waiting for a second guest-side `ip` probe
// here made create pay a redundant network round trip and turned
// a transient netlink timeout into a destructive rollback. The
// attach path performs the strict announcement/readiness check
// before handing out the private daemon route.
} catch (err) {
// A VM that failed to size or configure must not survive as an
// orphan, and an undersized machine must not ship as if it were
Expand Down Expand Up @@ -1488,12 +1499,16 @@ export class FreestyleProvider implements VMProvider {
);
}

private async announcePrivateAddresses(vm: Vm, data: FreestyleRouteAddresses): Promise<void> {
private async announcePrivateAddresses(
vm: Vm,
data: FreestyleRouteAddresses,
options: { readonly validateOnly?: boolean } = {},
): Promise<void> {
const addresses = (data.vpcs ?? data.networks ?? [])
.flatMap((network) => [network.ipv4, network.ipv6])
.filter((address): address is string => typeof address === "string" && address.trim() !== "")
.map((address) => address.trim());
await Effect.runPromise(announceFreestyleNetwork(vm, addresses));
await Effect.runPromise(announceFreestyleNetwork(vm, addresses, options));
}


Expand Down
7 changes: 6 additions & 1 deletion web/services/vms/drivers/freestyleNetworkAnnouncement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ if not announced:
* Create, restore, and attach surface that failure; a wake reports it without
* failing, having nothing to roll back (see FreestyleProvider.resume).
*/
export function announceFreestyleNetwork(vm: Pick<Vm, "exec">, addresses: readonly string[]) {
export function announceFreestyleNetwork(
vm: Pick<Vm, "exec">,
addresses: readonly string[],
options: { readonly validateOnly?: boolean } = {},
) {
const valid = [...new Set(addresses.filter((address) => isIP(address) !== 0))];
if (valid.length === 0) {
return Effect.fail(new ProviderError("freestyle", "Private network has no valid assigned address"));
}
if (options.validateOnly) return Effect.void;
return Effect.tryPromise({
try: () => vm.exec({ command: freestyleNetworkAnnouncementCommand(valid), linuxUser: "root", timeoutMs: 5_000 }),
catch: (cause) => new ProviderError("freestyle", "announce private network", cause),
Expand Down
36 changes: 35 additions & 1 deletion web/tests/freestyle-network-announcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,47 @@ describe("Freestyle private network readiness", () => {
if (hasAddresses) {
await allocation;
events.push("published");
expect(events).toEqual(["allocated", "guest-network", "published"]);
expect(events).toEqual(operation === "create"
? ["allocated", "published"]
: ["allocated", "guest-network", "published"]);
} else {
await expect(allocation).rejects.toThrow();
expect(events).toEqual(["allocated", "delete"]);
}
});

test("create does not wait for a guest network announcement after allocation", async () => {
const events: string[] = [];
const data = {
id: "vm-network-create-fast", state: "running", snapshotId: "sh-fixture",
resources: { cpu: 64, memory: 131072, storage: 1048576 },
vpcs: [{ ipv4: "10.16.0.2", ipv6: "fd00::2" }],
};
const vm = {
exec: async ({ command }: { command: string }) => {
if (command.startsWith("python3 -c ")) {
events.push("guest-network");
return { statusCode: 124, stdout: "", stderr: "network state probe timed out" };
}
return { statusCode: 0, stdout: "", stderr: "" };
},
fs: { writeTextFile: async () => {}, remove: async () => {} },
delete: async () => { events.push("delete"); },
};
const client = { vms: {
create: async () => { events.push("allocated"); return { vm, vmId: data.id, data }; },
} } as unknown as Freestyle;
const provider = new FreestyleProvider({
client: () => client,
resolveDaemonSource: async () => { throw new Error("No daemon install is needed"); },
});

const handle = await provider.create({ image: "sh-fixture", network: { id: "vpc-fixture" } });
events.push("published");
expect(handle.providerVmId).toBe(data.id);
expect(events).toEqual(["allocated", "published"]);
});

test("the guest announces assigned IPv4 and IPv6 without touching other addresses", () => {
const { status, packets } = captureAnnouncements(["10.16.0.2", "fd00::2"]);
expect(status).toBe(0);
Expand Down
13 changes: 13 additions & 0 deletions web/tests/vm-freestyle-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ describe("Freestyle platform contract", () => {
expect(freestyleNetworkAddressMetadata({ publicIpv6: "2602::1" })).toEqual({});
});

test("network metadata drops malformed provider addresses before publication", () => {
expect(
freestyleNetworkAddressMetadata({
vpcs: [{ ipv4: "not-an-ip", ipv6: "fd60:1e5e:6720::3" }],
}),
).toEqual({ networkIpv6: "fd60:1e5e:6720::3" });
expect(
freestyleNetworkAddressMetadata({
vpcs: [{ ipv4: "not-an-ip", ipv6: "also-not-an-ip" }],
}),
).toEqual({});
});

test("cmux-remote route prefers the private VPC address and never falls back from it", () => {
// On a VPC: the private address wins even when a public address exists,
// because a VPC machine has no public inbound rule. v4 is preferred within
Expand Down
Loading