mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user