feat(FN-2586): merge fusion/fn-2586
This commit is contained in:
@@ -155,6 +155,7 @@ const baseRemoteAccess = {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
@@ -649,6 +650,121 @@ describe("ProjectEngine remote lifecycle restore policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine remote lifecycle quick tunnel mode", () => {
|
||||
it("starts cloudflare quick tunnel without manual tunnel fields", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(quickTunnelSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await engine.startRemoteTunnel();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledWith(
|
||||
"cloudflare",
|
||||
expect.objectContaining({
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
}),
|
||||
);
|
||||
|
||||
await engine.stop();
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces runtime prerequisite missing when cloudflared is unavailable in quick tunnel mode", async () => {
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
const err = new Error("cloudflared not found");
|
||||
if (typeof _options === "function") {
|
||||
(_options as (error: Error, result: { stdout: string; stderr: string }) => void)(err, {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
return {} as never;
|
||||
}
|
||||
|
||||
callback?.(err, { stdout: "", stderr: "" });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const quickTunnelSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(quickTunnelSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
"runtime_prerequisite_missing:cloudflared is not available on PATH",
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("keeps manual cloudflare validation unchanged when quick tunnel is disabled", async () => {
|
||||
const manualSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: false,
|
||||
tunnelToken: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(manualSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
"provider_not_configured:Cloudflare tunnel token is required",
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine shutdown merge handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -44,6 +44,16 @@ function cloudflareConfig(overrides: Partial<TunnelProviderConfig> = {}): Tunnel
|
||||
} as TunnelProviderConfig;
|
||||
}
|
||||
|
||||
function cloudflareQuickTunnelConfig(overrides: Partial<TunnelProviderConfig> = {}): TunnelProviderConfig {
|
||||
return {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
...overrides,
|
||||
} as TunnelProviderConfig;
|
||||
}
|
||||
|
||||
describe("TunnelProcessManager", () => {
|
||||
let pid = 1000;
|
||||
let children = new Map<number, FakeChildProcess>();
|
||||
@@ -74,6 +84,19 @@ describe("TunnelProcessManager", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts quick tunnel cloudflare config without token env requirements", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(manager.start("cloudflare", cloudflareQuickTunnelConfig())).resolves.toBeUndefined();
|
||||
expect(manager.getStatus().state).toBe("starting");
|
||||
});
|
||||
|
||||
it("starts, emits readiness transitions, and redacts token-bearing logs", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
@@ -107,6 +130,25 @@ describe("TunnelProcessManager", () => {
|
||||
expect(allLogs).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("detects trycloudflare readiness output for quick tunnel config", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.start("cloudflare", cloudflareQuickTunnelConfig());
|
||||
const child = [...children.values()][0];
|
||||
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getStatus().state).toBe("running");
|
||||
});
|
||||
expect(manager.getStatus().url).toBe("https://demo.trycloudflare.com");
|
||||
});
|
||||
|
||||
it("transitions start→running and stop→stopped, with idempotent repeated stop", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
|
||||
@@ -686,6 +686,23 @@ export class ProjectEngine {
|
||||
if (!cloudflare.enabled) {
|
||||
return { provider, reason: "provider_not_enabled", message: "Cloudflare provider is disabled" };
|
||||
}
|
||||
if (cloudflare.quickTunnel === true) {
|
||||
const executable = await this.checkExecutableAvailable("cloudflared");
|
||||
if (!executable.available) {
|
||||
return { provider, reason: "runtime_prerequisite_missing", message: executable.message };
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
config: {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!cloudflare.tunnelName?.trim() || !cloudflare.ingressUrl?.trim()) {
|
||||
return { provider, reason: "provider_not_configured", message: "Cloudflare tunnel name and ingress URL must be configured" };
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ const cloudflareAdapter: TunnelProviderAdapter = {
|
||||
provider: "cloudflare",
|
||||
validateConfig(config) {
|
||||
validateBaseConfig(config, "cloudflare");
|
||||
|
||||
if (config.provider === "cloudflare" && config.quickTunnel === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("credentialsPath" in config && config.credentialsPath !== undefined) {
|
||||
assertNonEmpty(config.credentialsPath, "credentialsPath");
|
||||
if (isAbsoluteOrPathLike(config.credentialsPath)) {
|
||||
|
||||
@@ -108,6 +108,11 @@ export interface TailscaleProviderConfig extends TunnelProviderConfigBase {
|
||||
|
||||
export interface CloudflareProviderConfig extends TunnelProviderConfigBase {
|
||||
provider: "cloudflare";
|
||||
/**
|
||||
* Enables account-less Cloudflare quick tunnels (`cloudflared tunnel --url ...`).
|
||||
* In this mode, no token env var or credentials file is required.
|
||||
*/
|
||||
quickTunnel?: boolean;
|
||||
/**
|
||||
* Optional environment variable name holding a Cloudflare token reference.
|
||||
* The manager validates that it exists when provided, but never logs its value.
|
||||
|
||||
Reference in New Issue
Block a user