Merge #997 Cloud adapter fixes with shared routing

Retain workspace-port ownership validation and repeatable route updates from #997. Keep the active-deployment routing, complete Docker route tables, and retryable failures from #998, with one shared Cloud dispatch path. Adapt the native Cloud removal regression to the consolidated flow.
This commit is contained in:
Hydra
2026-09-29 21:25:46 +03:00
4 changed files with 360 additions and 4 deletions
@@ -115,6 +115,54 @@ beforeEach(() => {
deregisterManagedEdge.mockReset().mockResolvedValue({ failures: [] });
});
describe("provider-managed native Cloud routes", () => {
it("updates and removes through the Cloud provider without a public-server edge registration", async () => {
const project = {
id: "project-1",
slug: "app",
port: 3000,
organizationId: "org-1",
cloudWorkspaceId: null,
activeDeploymentId: "deployment-1",
webhookDomain: null,
} as Parameters<typeof reapplyProjectLiveRoutes>[0];
findDeployment.mockReset().mockResolvedValue({
id: "deployment-1",
projectId: project.id,
organizationId: project.organizationId,
containerId: "workspace-one",
meta: { deployTarget: "cloud", workspaceId: "workspace-one" },
});
listByProject
.mockReset()
.mockResolvedValue([
domainRow({ id: "current", hostname: "app.opsh.io", targetPort: 3000, domainType: "free" }),
]);
resolveRuntime.mockReset();
reconcile.mockReset().mockResolvedValue(undefined);
await reapplyProjectLiveRoutes(project, ["previous.opsh.io"]);
expect(reconcile).toHaveBeenCalledExactlyOnceWith(
project,
expect.objectContaining({
deployment: expect.objectContaining({
id: "deployment-1",
projectId: project.id,
organizationId: project.organizationId,
containerId: "workspace-one",
}),
registers: [
{ hostname: "app.opsh.io", port: 3000, isCustomDomain: false },
],
removes: [{ hostname: "previous.opsh.io", isCustomDomain: false }],
}),
);
expect(syncManagedEdge).not.toHaveBeenCalled();
expect(deregisterManagedEdge).not.toHaveBeenCalled();
});
});
describe("shouldRefuseLoopbackRoute", () => {
it("refuses a tenant project's public route to the dashboard port on loopback", () => {
expect(shouldRefuseLoopbackRoute("127.0.0.1", 3001, { isSelfApp: false })).toBe(true);
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Oblien } from "oblien";
import { CloudInfraProvider } from "./cloud";
const hostname = "app.opsh.io";
const namespace = "tenant-one";
const workspaceId = "workspace-one";
let workspaceNamespace: string;
let routeType: string;
let storedTarget: string;
let writes: Array<{ hostname: string; input: Record<string, unknown> }>;
let reads: URL[];
let provider: CloudInfraProvider;
beforeEach(() => {
workspaceNamespace = namespace;
routeType = "host";
storedTarget = "http://10.103.0.9:3000";
writes = [];
reads = [];
// Use the published SDK's real URL, query and response handling. Only the
// HTTP peer is simulated; no production credential or resource is involved.
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const method = init?.method ?? "GET";
if (url.origin !== "https://oblien.test") throw new Error("Unexpected network origin");
if (method === "GET") reads.push(url);
if (method === "GET" && url.pathname === "/domain/routes") {
return Response.json({
success: true,
data: [
{
id: 1,
hostname,
slug: "app",
domain: "opsh.io",
namespace,
status: "active",
owner_type: "port",
owner_id: workspaceId,
is_custom: 0,
route_type: routeType,
target: storedTarget,
},
],
});
}
if (method === "GET" && url.pathname === `/workspace/${workspaceId}`) {
return Response.json({
success: true,
workspace: {
id: workspaceId,
namespace: workspaceNamespace,
status: "active",
ip: "10.103.0.9",
info: { status: "running", is_running: true },
},
});
}
if (method === "PUT" && url.pathname === `/domain/routes/${hostname}`) {
const body = JSON.parse(String(init?.body));
writes.push({ hostname, input: body });
routeType = "routes";
storedTarget = JSON.stringify({
v: 1,
rules: [{ action: { k: "proxy", backend: "http://10.103.0.9:3000", vm: workspaceId } }],
});
return Response.json({
success: true,
hostname,
version: writes.length,
config: JSON.parse(storedTarget),
});
}
throw new Error(`Unexpected provider request: ${method} ${url.pathname}`);
}),
);
provider = new CloudInfraProvider(
new Oblien({ token: "test-namespace-token", baseUrl: "https://oblien.test" }),
{ namespace },
);
});
afterEach(() => vi.unstubAllGlobals());
it("updates a native port route twice through the SDK after the provider switches to a compiled table", async () => {
for (let attempt = 0; attempt < 2; attempt++) {
await provider.registerRoute({
domain: hostname,
targetUrl: "http://10.103.0.9:3000",
tls: true,
});
}
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual({
hostname,
input: {
routes: [
{
match: { path: "/", type: "prefix" },
action: { kind: "proxy", workspace: workspaceId, port: 3000 },
},
],
},
});
const inventories = reads.filter((url) => url.pathname === "/domain/routes");
expect(inventories).toHaveLength(2);
expect(inventories.every((url) => url.searchParams.get("namespace") === namespace)).toBe(true);
});
it("sends no update when the provider's workspace response belongs to a different namespace", async () => {
workspaceNamespace = "tenant-two";
await expect(
provider.registerRoute({ domain: hostname, targetUrl: "http://10.103.0.9:3000", tls: true }),
).rejects.toMatchObject({ code: "CLOUD_ROUTE_OWNER_CHANGED", statusCode: 409 });
expect(writes).toEqual([]);
});
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DomainRoute, Oblien } from "oblien";
import { CloudInfraProvider } from "./cloud";
const hostname = "app.opsh.io";
const namespace = "tenant-one";
const workspaceId = "workspace-one";
const targetUrl = "http://10.103.0.9:3000";
let owner: DomainRoute;
let workspace: Record<string, unknown>;
let inventory: ReturnType<typeof vi.fn>;
let getWorkspace: ReturnType<typeof vi.fn>;
let setRoutes: ReturnType<typeof vi.fn>;
let listPorts: ReturnType<typeof vi.fn>;
let revokePort: ReturnType<typeof vi.fn>;
let provider: CloudInfraProvider;
beforeEach(() => {
owner = {
id: 1,
hostname,
slug: "app",
domain: "opsh.io",
namespace,
owner_type: "port",
owner_id: workspaceId,
route_type: "host",
target: targetUrl,
is_custom: false,
status: "active",
};
workspace = { id: workspaceId, namespace, ip: "10.103.0.9" };
inventory = vi.fn(async () => ({ data: [owner] }));
getWorkspace = vi.fn(async () => workspace);
setRoutes = vi.fn(async (_hostname: string, input: unknown) => {
// The real provider replaces the raw URL with a compiled JSON table.
owner = { ...owner, route_type: "routes", target: JSON.stringify(input) };
});
listPorts = vi.fn(async () => [
{ port: 3000, hash: "app", domain: "opsh.io", url: `https://${hostname}` },
]);
revokePort = vi.fn();
const client = {
domain: { routes: inventory },
routes: { set: setRoutes },
workspace: vi.fn(() => ({
get: getWorkspace,
publicAccess: { list: listPorts, revoke: revokePort },
})),
} as unknown as Oblien;
provider = new CloudInfraProvider(client, { namespace });
});
describe("Cloud workspace port routes", () => {
it.each(["port", "workspace"])(
"updates a %s owner using the workspace identity and remains retryable",
async (ownerType) => {
owner.owner_type = ownerType as DomainRoute["owner_type"];
for (let attempt = 0; attempt < 2; attempt++) {
await provider.registerRoute({ domain: hostname, targetUrl, tls: true });
}
expect(setRoutes).toHaveBeenCalledTimes(2);
expect(setRoutes).toHaveBeenLastCalledWith(hostname, {
routes: [
{
match: { path: "/", type: "prefix" },
action: { kind: "proxy", workspace: workspaceId, port: 3000 },
},
],
});
expect(inventory).toHaveBeenCalledWith({ namespace });
},
);
it("uses the owning workspace's current IP after a restart", async () => {
workspace.ip = "10.103.0.10";
await provider.registerRoute({
domain: hostname,
targetUrl: "http://10.103.0.10:3000",
tls: true,
});
expect(setRoutes).toHaveBeenCalledOnce();
});
it("does not publish a route listed under a different namespace", async () => {
owner.namespace = "tenant-two";
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow();
expect(getWorkspace).not.toHaveBeenCalled();
expect(setRoutes).not.toHaveBeenCalled();
});
it.each(["tenant-two", null, undefined])(
"rejects a workspace whose namespace changed to %s",
async (value) => {
workspace.namespace = value;
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow();
expect(setRoutes).not.toHaveBeenCalled();
},
);
it.each([
"http://10.103.0.10:3000",
"http://127.0.0.1:3000",
"http://attacker.example:3000",
"http://user:password@10.103.0.9:3000",
"ftp://10.103.0.9:3000",
])("rejects an unowned or invalid upstream %s", async (targetUrl) => {
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow();
expect(setRoutes).not.toHaveBeenCalled();
});
it("does not trust a stale route's IP if the workspace has no current IP", async () => {
workspace.ip = null;
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow();
expect(setRoutes).not.toHaveBeenCalled();
});
it("propagates an unavailable workspace instead of falling back to the stored target", async () => {
getWorkspace.mockRejectedValue(new Error("provider unavailable"));
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow("provider unavailable");
expect(setRoutes).not.toHaveBeenCalled();
});
it.each(["page", "edge_proxy"])(
"does not reinterpret a %s owner as a workspace",
async (ownerType) => {
owner.owner_type = ownerType as DomainRoute["owner_type"];
await expect(
provider.registerRoute({ domain: hostname, targetUrl, tls: true }),
).rejects.toThrow();
expect(setRoutes).not.toHaveBeenCalled();
},
);
it("revokes only the port bound to the requested managed hostname", async () => {
listPorts.mockResolvedValue([
{ port: 8080, url: "https://sibling.opsh.io" },
{ port: 3000, url: `https://${hostname}` },
]);
await provider.removeRoute(hostname);
expect(revokePort).toHaveBeenCalledExactlyOnceWith(3000);
});
it("does not revoke a port after its hostname binding changed", async () => {
listPorts.mockResolvedValue([{ port: 3000, url: "https://replacement.opsh.io" }]);
await expect(provider.removeRoute(hostname)).rejects.toThrow();
expect(revokePort).not.toHaveBeenCalled();
});
});
+34 -4
View File
@@ -1,4 +1,5 @@
import type { Oblien, DomainRoute } from "oblien";
import { isIP } from "node:net";
import { AppError } from "@repo/core";
import type { ManualCert, RouteConfig, SslResult } from "../types";
import type { RoutingProvider, SslProvider, ProvisionCertOptions } from "./types";
@@ -106,12 +107,40 @@ export class CloudInfraProvider implements RoutingProvider, SslProvider {
});
return;
}
if (owner.owner_type !== "workspace" || !route.targetUrl) {
// Public workspace ports are registered as `port`; custom workspace
// domains use `workspace`. In both cases owner_id is the workspace ID.
if (!["workspace", "port"].includes(owner.owner_type) || !route.targetUrl) {
throw new Error("Cloud route target does not match its owning resource");
}
const target = new URL(route.targetUrl);
const current = new URL(owner.target.includes("://") ? owner.target : `http://${owner.target}`);
if (target.hostname !== current.hostname || target.username || target.password) {
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) {
throw new Error("Cloud route target must belong to its owning workspace");
}
// `target` becomes a compiled JSON table after routes.set(), and a stored
// raw IP can become stale after a restart. Revalidate the live resource;
// neither representation of the previous target is ownership evidence.
const workspace = await this.client.workspace(owner.owner_id).get();
if (
workspace.id !== owner.owner_id ||
workspace.namespace !== this.options.namespace ||
(this.options.dockerWorkspaceId && owner.owner_id !== this.options.dockerWorkspaceId)
) {
throw new AppError(
"Cloud route workspace is no longer in this project or organization",
409,
"CLOUD_ROUTE_OWNER_CHANGED",
);
}
const ip = workspace.ip;
if (typeof ip !== "string" || !isIP(ip)) {
throw new AppError(
"The Cloud workspace has no current network address. Start it and retry routing.",
409,
"CLOUD_WORKSPACE_NOT_READY",
);
}
const current = new URL(`http://${isIP(ip) === 6 ? `[${ip}]` : ip}`);
if (target.hostname !== current.hostname) {
throw new Error("Cloud route target must belong to its owning workspace");
}
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
@@ -147,7 +176,8 @@ export class CloudInfraProvider implements RoutingProvider, SslProvider {
else await this.pages.disable(page.slug);
return;
}
if (owner.owner_type !== "workspace") throw new Error("Cloud route is owned by an unsupported resource type");
if (!["workspace", "port"].includes(owner.owner_type))
throw new Error("Cloud route is owned by an unsupported resource type");
if (owner.is_custom) {
const { domains } = await this.workspaceDomain(owner.owner_id, domain);
await domains.disconnect();