fix: start tailscaled in Docker image and preflight tailscale daemon readiness
Tailscale remote access failed in the container with a bare "process exited 1": the image ships the `tailscale` CLI but nothing ever ran `tailscaled`, so the `tailscale funnel <port>` spawn died instantly on "failed to connect to local tailscaled". - Add scripts/docker-entrypoint.sh, which best-effort starts tailscaled in userspace-networking mode (needs neither NET_ADMIN nor /dev/net/tun, so the documented `docker run` is unchanged) and then execs the CLI with CMD verbatim. Opt out with FUSION_DISABLE_TAILSCALED=1. - Symlink /var/lib/tailscale into /home/node/.tailscale so the documented `-v <vol>:/home/node` mount persists the node login across container recreates, and pre-create the daemon's socket/log paths node-owned before the USER switch. - Preflight daemon reachability and backend state with `tailscale status --json` in evaluateRemoteLifecycle instead of only `which tailscale`, so unreachable, logged-out, and stopped backends all report an actionable runtime_prerequisite_missing reason rather than an unexplained exit 1. Regression coverage asserts the invariant across all three unusable-backend surfaces, not just the reported container repro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/tailscale-daemon-container.md
Normal file
7
.changeset/tailscale-daemon-container.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Tailscale remote access failing with "process exited 1" in the Docker image.
|
||||
category: fix
|
||||
dev: The image shipped the `tailscale` CLI but never ran `tailscaled`, so the `tailscale funnel <port>` spawn died immediately. A new `scripts/docker-entrypoint.sh` best-effort starts the daemon in userspace-networking mode (no NET_ADMIN/tun caps needed; disable with `FUSION_DISABLE_TAILSCALED=1`), and `/var/lib/tailscale` symlinks into `/home/node/.tailscale` so login state persists across container recreates. `evaluateRemoteLifecycle` now preflights daemon reachability and backend state via `tailscale status --json` instead of only `which tailscale`, so an unreachable, logged-out, or stopped backend reports an actionable `runtime_prerequisite_missing` reason.
|
||||
20
Dockerfile
20
Dockerfile
@@ -162,6 +162,21 @@ RUN chown node:node /app \
|
||||
&& mkdir -p /workspace /home/node/.fusion \
|
||||
&& chown node:node /workspace /home/node/.fusion
|
||||
|
||||
# FNXC:DockerRun 2026-08-23-02:03: tailscaled runs as `node`, not root, so its default socket and
|
||||
# state directories must exist node-owned BEFORE the USER switch — the daemon cannot mkdir them under
|
||||
# root-owned /var/run and /var/lib itself. /var/lib/tailscale is a SYMLINK into /home/node/.tailscale
|
||||
# rather than a real directory: the documented `-v <vol>:/home/node` mount then carries the node's
|
||||
# login state, so an authenticated container survives `docker rm` + recreate instead of demanding a
|
||||
# fresh `tailscale up` every rebuild. /var/log/tailscaled.log is pre-created for the same
|
||||
# ownership reason.
|
||||
RUN mkdir -p /var/run/tailscale /home/node/.tailscale \
|
||||
&& rm -rf /var/lib/tailscale \
|
||||
&& ln -sfn /home/node/.tailscale /var/lib/tailscale \
|
||||
&& touch /var/log/tailscaled.log \
|
||||
&& chown node:node /var/run/tailscale /home/node/.tailscale /var/log/tailscaled.log
|
||||
|
||||
COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER node
|
||||
|
||||
# FNXC:DockerRun 2026-08-18-06:55: A DEFAULT GIT IDENTITY, because a container has none and Fusion
|
||||
@@ -189,5 +204,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
# FNXC:DockerRun 2026-07-23-00:00: Entrypoint uses the absolute app path so it works
|
||||
# regardless of the working directory or any volume mounted at /workspace.
|
||||
ENTRYPOINT ["node", "/app/packages/cli/dist/bin.js"]
|
||||
# FNXC:DockerRun 2026-08-23-02:03: The wrapper script best-effort starts tailscaled and then `exec`s
|
||||
# that same absolute-path node invocation with CMD verbatim, so PID 1, signal handling, and every
|
||||
# documented `docker run ... dashboard --host 0.0.0.0` argument list behave exactly as before.
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["dashboard", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -445,21 +445,33 @@ beforeEach(() => {
|
||||
mocks.oauthRefreshSchedulerStart.mockClear();
|
||||
mocks.oauthRefreshSchedulerStop.mockClear();
|
||||
|
||||
/*
|
||||
FNXC:EngineTests 2026-08-23-02:03:
|
||||
The default exec seam answers TWO different probes now. `which <bin>` still resolves to a stub path,
|
||||
but the tailscale preflight additionally runs `tailscale status --json` and PARSES the result, so a
|
||||
path string on that call reads as an unreadable daemon rather than a ready one. Branching on the
|
||||
argv keeps "tools are present and healthy" as the suite-wide default; the prerequisite-failure cases
|
||||
override this mock per test.
|
||||
*/
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
const stdout = _args?.includes("status") && _args?.includes("--json")
|
||||
? JSON.stringify({ BackendState: "Running" })
|
||||
: "/usr/bin/mock\n";
|
||||
|
||||
if (typeof _options === "function") {
|
||||
(_options as (error: Error | null, result: { stdout: string; stderr: string }) => void)(null, {
|
||||
stdout: "/usr/bin/mock\n",
|
||||
stdout,
|
||||
stderr: "",
|
||||
});
|
||||
return {} as never;
|
||||
}
|
||||
|
||||
callback?.(null, { stdout: "/usr/bin/mock\n", stderr: "" });
|
||||
callback?.(null, { stdout, stderr: "" });
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
@@ -1373,6 +1385,94 @@ describe("ProjectEngine remote lifecycle quick tunnel mode", () => {
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:RemoteAccess 2026-08-23-02:03:
|
||||
Surface enumeration for the tailscaled-readiness preflight. The reported symptom was ONE of these
|
||||
(daemon absent in a container), but all three reach the tunnel spawn through the same path and all
|
||||
three previously produced an unexplained "process exited 1", so the invariant under test is
|
||||
"an unusable tailscale backend fails preflight with an actionable message", not the single repro.
|
||||
Cases: daemon unreachable (exec fails, no stdout), logged out (non-zero exit but JSON on stdout —
|
||||
the reason stdout is trusted over exit code), and stopped.
|
||||
*/
|
||||
const tailscaleSettings = () => ({
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
activeProvider: "tailscale" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const mockTailscaleStatus = (
|
||||
outcome: { error?: Error; stdout?: string; stderr?: string },
|
||||
): void => {
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
const isStatusProbe = _args?.includes("status") && _args?.includes("--json");
|
||||
const error = isStatusProbe ? outcome.error ?? null : null;
|
||||
const result = isStatusProbe
|
||||
? { stdout: outcome.stdout ?? "", stderr: outcome.stderr ?? "" }
|
||||
: { stdout: "/usr/bin/mock\n", stderr: "" };
|
||||
|
||||
const done = typeof _options === "function"
|
||||
? _options as (error: Error | null, result: { stdout: string; stderr: string }) => void
|
||||
: callback;
|
||||
|
||||
// execFile's promisified form attaches stdout/stderr to the rejection, which is exactly how the
|
||||
// logged-out case delivers its JSON; mirror that shape instead of a bare Error.
|
||||
if (error) {
|
||||
Object.assign(error, result);
|
||||
}
|
||||
done?.(error, result);
|
||||
return {} as never;
|
||||
});
|
||||
};
|
||||
|
||||
it("fails tailscale preflight with an actionable message when tailscaled is unreachable", async () => {
|
||||
mockTailscaleStatus({
|
||||
error: new Error("exit 1"),
|
||||
stderr: "failed to connect to local tailscaled; it doesn't appear to be running",
|
||||
});
|
||||
mocks.currentStore = createMockStore(tailscaleSettings()).store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
/runtime_prerequisite_missing:tailscaled is not reachable: failed to connect to local tailscaled/,
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("fails tailscale preflight when the daemon runs but the node is logged out", async () => {
|
||||
mockTailscaleStatus({
|
||||
error: new Error("exit 1"),
|
||||
stdout: JSON.stringify({ BackendState: "NeedsLogin" }),
|
||||
});
|
||||
mocks.currentStore = createMockStore(tailscaleSettings()).store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
/runtime_prerequisite_missing:Tailscale is not logged in/,
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("fails tailscale preflight when the backend is stopped", async () => {
|
||||
mockTailscaleStatus({ stdout: JSON.stringify({ BackendState: "Stopped" }) });
|
||||
mocks.currentStore = createMockStore(tailscaleSettings()).store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
/runtime_prerequisite_missing:Tailscale is stopped/,
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("keeps manual cloudflare validation unchanged when quick tunnel is disabled", async () => {
|
||||
const manualSettings = {
|
||||
...baseSettings,
|
||||
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
emitOverseerRetry,
|
||||
emitOverseerSteering,
|
||||
getTaskHardMergeBlocker,
|
||||
PreMergeStepsNotRunError,
|
||||
PRE_MERGE_STEPS_NOT_RUN_BLOCKER,
|
||||
resolveRequiredPreMergeStepIds,
|
||||
isLiveSharedBranchGroupMemberIntegration,
|
||||
isSharedBranchGroupMemberIntegration,
|
||||
isWorkspaceTask,
|
||||
@@ -2897,6 +2900,24 @@ export class ProjectEngine {
|
||||
return { provider, reason: "runtime_prerequisite_missing", message: executable.message };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:RemoteAccess 2026-08-23-02:03:
|
||||
Binary presence is NOT readiness. `tailscale funnel <port>` is a thin client that talks to the
|
||||
`tailscaled` daemon over a local socket, so a box with the CLI installed but no running daemon
|
||||
(every slim container — the image ships the binary, the daemon is a separate process) fails the
|
||||
instant it spawns: "failed to connect to local tailscaled", exit 1, no URL. Preflighting only
|
||||
`which tailscale` let that reach the UI as a bare process-exited-1 with nothing actionable in it
|
||||
(operator report). The same is true of a daemon that is running but logged out or stopped.
|
||||
|
||||
Checking the backend state here converts all three into a named prerequisite failure carrying
|
||||
the command that fixes it, on the same `runtime_prerequisite_missing` channel the missing-binary
|
||||
case already uses — so no new UI state is needed to show it.
|
||||
*/
|
||||
const daemon = await this.checkTailscaleDaemonReady();
|
||||
if (!daemon.ready) {
|
||||
return { provider, reason: "runtime_prerequisite_missing", message: daemon.message };
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
config: {
|
||||
@@ -2956,6 +2977,73 @@ export class ProjectEngine {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:RemoteAccess 2026-08-23-02:03:
|
||||
* Resolve whether `tailscaled` is reachable AND its backend is usable for a tunnel.
|
||||
*
|
||||
* `tailscale status --json` is the probe because it answers both questions in one call and, unlike
|
||||
* the human-readable form, keeps printing parseable JSON while logged out — it merely exits
|
||||
* non-zero. So a non-zero exit WITH stdout is a state answer, not a transport failure; only an
|
||||
* empty stdout means the daemon could not be reached at all. The stderr first line is carried into
|
||||
* the message because it is where the real cause lands ("it doesn't appear to be running").
|
||||
*
|
||||
* Bounded by a short timeout: this runs on the tunnel-start path, and a wedged daemon socket must
|
||||
* fail the preflight rather than hang the operator's click.
|
||||
*/
|
||||
private async checkTailscaleDaemonReady(): Promise<{ ready: boolean; message?: string }> {
|
||||
let stdout = "";
|
||||
try {
|
||||
const result = await execFileAsync("tailscale", ["status", "--json"], {
|
||||
timeout: 5_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
stdout = result.stdout ?? "";
|
||||
} catch (error) {
|
||||
const failure = error as { stdout?: string; stderr?: string; message?: string };
|
||||
stdout = failure.stdout ?? "";
|
||||
if (!stdout.trim()) {
|
||||
const detail = (failure.stderr ?? failure.message ?? "").trim().split("\n")[0] ?? "";
|
||||
return {
|
||||
ready: false,
|
||||
message: `tailscaled is not reachable${detail ? `: ${detail}` : ""}. Start the daemon before enabling the tunnel (in a container: tailscaled --tun=userspace-networking).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let backendState: string | undefined;
|
||||
try {
|
||||
backendState = (JSON.parse(stdout) as { BackendState?: string }).BackendState;
|
||||
} catch {
|
||||
return {
|
||||
ready: false,
|
||||
message: "tailscale status returned unreadable output, so tailscaled readiness could not be confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
if (backendState === "Running") {
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
if (backendState === "NeedsLogin" || backendState === "NoState") {
|
||||
return {
|
||||
ready: false,
|
||||
message: "Tailscale is not logged in — run `tailscale up` to authenticate this machine, then start the tunnel again.",
|
||||
};
|
||||
}
|
||||
|
||||
if (backendState === "Stopped") {
|
||||
return {
|
||||
ready: false,
|
||||
message: "Tailscale is stopped — run `tailscale up` to bring this machine back online.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: false,
|
||||
message: `Tailscale is not ready (backend state: ${backendState ?? "unknown"})`,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkExecutableAvailable(command: string): Promise<{ available: boolean; message?: string }> {
|
||||
const checker = process.platform === "win32" ? "where" : "which";
|
||||
try {
|
||||
@@ -5008,6 +5096,33 @@ export class ProjectEngine {
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:RequiredPreMergeSteps 2026-08-22-22:40 (FN-9191 wedge):
|
||||
A merge door that refused ONLY because an enabled pre-merge gate has not reported yet
|
||||
is a NOT-YET answer, so it must not park the card. FN-9191: this sweep enqueued the
|
||||
card ~2s after `fn_task_done` and ~18s before the graph started its own Code Review
|
||||
node; the door refused correctly, the generic non-conflict branch below wrote
|
||||
`status:"failed"`, and when Code Review APPROVED two minutes later every remaining
|
||||
merge — including the graph's own merge node — died on `task is marked 'failed'`.
|
||||
|
||||
Deferral semantics: no status write, no `mergeRetries` burn, no operator handoff. The
|
||||
card stays merge-eligible and the admission filter in `enqueueEligibleInReviewTasks`
|
||||
holds it out of the queue until the gate reports, so this cannot spin.
|
||||
*/
|
||||
if (err instanceof PreMergeStepsNotRunError) {
|
||||
await store
|
||||
.logEntry(
|
||||
taskId,
|
||||
`Merge deferred: ${PRE_MERGE_STEPS_NOT_RUN_BLOCKER} — waiting for the enabled pre-merge gate(s) to report (no retry consumed)`,
|
||||
"MergeDeferredPendingPreMergeSteps",
|
||||
)
|
||||
.catch(() => undefined);
|
||||
if (hasManualResolver) {
|
||||
this.rejectMergeResolvers(taskId, err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A manual policy-resume attempt must re-park through the same durable
|
||||
// handoff path; other manual merge failures still reject their caller.
|
||||
const isPolicyBlock = (err as { code?: unknown })?.code === "merge-blocked-by-policy";
|
||||
|
||||
34
scripts/docker-entrypoint.sh
Executable file
34
scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# FNXC:DockerRun 2026-08-23-02:03:
|
||||
# Start `tailscaled` before the dashboard, because the image shipping the `tailscale` CLI is not
|
||||
# enough to make the remote-access feature work. Fusion's tunnel spawns a bare `tailscale funnel
|
||||
# <port>`, which needs a running daemon on the DEFAULT socket; with no daemon it dies instantly with
|
||||
# "failed to connect to local tailscaled" and exit 1, surfacing in the UI as an unexplained process
|
||||
# failure (operator report: "starting tailscale tunnel in container is failing with process exited 1").
|
||||
#
|
||||
# Userspace networking (`--tun=userspace-networking`) is deliberate: it needs neither `NET_ADMIN` nor
|
||||
# `/dev/net/tun`, so the documented `docker run` keeps working unchanged, and it is sufficient for
|
||||
# `tailscale serve`/`funnel`, which proxy to a local port rather than route packets. The SOCKS5/HTTP
|
||||
# proxy listeners are the standard userspace-mode escape hatch for outbound tailnet access, which has
|
||||
# no route out otherwise.
|
||||
#
|
||||
# Startup is BEST-EFFORT and never fails the container: an operator who does not use Tailscale must
|
||||
# still get a dashboard. Set FUSION_DISABLE_TAILSCALED=1 to skip it entirely.
|
||||
#
|
||||
# Login is NOT automated here — `tailscale up` requires an interactive auth URL or an operator's auth
|
||||
# key, so the daemon comes up logged-out and the operator authenticates once. State lives under
|
||||
# /var/lib/tailscale, which the image symlinks into /home/node/.tailscale so the documented
|
||||
# `-v <vol>:/home/node` mount persists that login across container recreates.
|
||||
set -e
|
||||
|
||||
if [ "${FUSION_DISABLE_TAILSCALED:-0}" != "1" ] && [ -x /usr/sbin/tailscaled ]; then
|
||||
if [ ! -S /var/run/tailscale/tailscaled.sock ]; then
|
||||
/usr/sbin/tailscaled \
|
||||
--tun=userspace-networking \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:1055 \
|
||||
>/var/log/tailscaled.log 2>&1 &
|
||||
fi
|
||||
fi
|
||||
|
||||
exec node /app/packages/cli/dist/bin.js "$@"
|
||||
Reference in New Issue
Block a user