diff --git a/docs/reference.md b/docs/reference.md index 403db8035..d4e3072b4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -478,7 +478,8 @@ DESCRIPTION runtime image, 'apify runtime start' runs it, and 'apify runtime status' says whether it is up. - The runtime publishes two ports on localhost: + The runtime publishes two ports on localhost, by default (move them with + 'apify runtime start --api-port --console-port '): 3333 API http://localhost:3333 (Apify API compatible endpoint) 3000 Console http://localhost:3000 (web UI) @@ -489,7 +490,7 @@ DESCRIPTION These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over - the connection wherever they are set: + the connection wherever they are set (shown for the default ports): export APIFY_CLIENT_BASE_URL=http://localhost:3333 export APIFY_CONSOLE_URL=http://localhost:3000 @@ -557,18 +558,25 @@ DESCRIPTION Docker or Podman. Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on http://localhost:3333 and the console on - http://localhost:3000. Run 'apify runtime -h' for the environment variables - that point the CLI at it. + http://localhost:3000 unless moved with --api-port and --console-port, which + are remembered for later starts and for 'apify runtime connect'. Run 'apify + runtime -h' for the environment variables that point the CLI at it. USAGE - $ apify runtime start [--data-dir ] [-d] + $ apify runtime start [--api-port ] + [--console-port ] [--data-dir ] [-d] FLAGS - --data-dir= Host directory mounted as the runtime - data directory (storages, builds and run records). - Defaults to ~/.apify/actor-runtime/data. - -d, --detach Run the runtime container in the - background. Stop it with 'apify runtime stop'. + --api-port= Host port for the runtime API. + Defaults to the port of the previous start, else 3333. + --console-port= Host port for the runtime + console. Defaults to the port of the previous start, + else 3000. + --data-dir= Host directory mounted as the + runtime data directory (storages, builds and run + records). Defaults to ~/.apify/actor-runtime/data. + -d, --detach Run the runtime container + in the background. Stop it with 'apify runtime stop'. ``` ##### `apify runtime stop` diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index 04b51aa78..be0e76b40 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -37,14 +37,14 @@ export class RuntimeIndexCommand extends ApifyCommand --console-port '):`, '', ` ${String(ACTOR_RUNTIME_API_PORT).padEnd(5)} API ${ACTOR_RUNTIME_API_URL} (Apify API compatible endpoint)`, ` ${String(ACTOR_RUNTIME_CONSOLE_PORT).padEnd(5)} Console ${ACTOR_RUNTIME_CONSOLE_URL} (web UI)`, '', `Run 'apify runtime connect' to send every Apify CLI command to the runtime instead of the Apify cloud, and 'apify runtime disconnect' to go back. The connection is remembered across terminals and does not touch your login.`, '', - 'These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over the connection wherever they are set:', + 'These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over the connection wherever they are set (shown for the default ports):', '', ...runtimeEnvExportLines().map((line) => ` ${line}`), '', diff --git a/src/commands/runtime/connect.ts b/src/commands/runtime/connect.ts index 3fa3ccd23..a5ec8bed2 100644 --- a/src/commands/runtime/connect.ts +++ b/src/commands/runtime/connect.ts @@ -3,12 +3,16 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { simpleLog, success, warning } from '../../lib/outputs.js'; import { - ACTOR_RUNTIME_API_URL, - ACTOR_RUNTIME_CONSOLE_URL, findRunningRuntimeEngine, + runtimeApiUrl, + runtimeConsoleUrl, runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; -import { overridingRuntimeEnvVars, setConnectedToActorRuntime } from '../../lib/runtime/target.js'; +import { + configuredRuntimePorts, + overridingRuntimeEnvVars, + setConnectedToActorRuntime, +} from '../../lib/runtime/target.js'; export class RuntimeConnectCommand extends ApifyCommand { static override name = 'connect' as const; @@ -33,12 +37,13 @@ export class RuntimeConnectCommand extends ApifyCommand Number.isInteger(port) && port >= 1 && port <= 65535; const defaultDataDir = () => join(GLOBAL_CONFIGS_FOLDER(), 'actor-runtime', 'data'); @@ -29,8 +37,9 @@ export class RuntimeStartCommand extends ApifyCommand chalk.white.bold(` ${line}`)), + ...runtimeEnvExportLines(ports).map((line) => chalk.white.bold(` ${line}`)), '', ...runtimeSkillHintLines(), ].join('\n'), }); // Spawned without a shell so interrupt signals reach the engine's 'run' directly instead of dying in 'sh -c'. - const args = buildRuntimeRunArgs({ image, dataDir, detach: this.flags.detach, hostSocketPath }); + const args = buildRuntimeRunArgs({ image, dataDir, detach: this.flags.detach, hostSocketPath, ports }); run({ message: `${engine} ${args.join(' ')}` }); const child = execa(engine, args, { stdio: 'inherit' }); diff --git a/src/commands/runtime/status.ts b/src/commands/runtime/status.ts index 72c114129..683fd7a3d 100644 --- a/src/commands/runtime/status.ts +++ b/src/commands/runtime/status.ts @@ -5,26 +5,30 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { simpleLog } from '../../lib/outputs.js'; import { - ACTOR_RUNTIME_API_PORT, - ACTOR_RUNTIME_CONSOLE_PORT, ACTOR_RUNTIME_CONTAINER_NAME, findRunningRuntimeEngine, inspectRuntimeContainer, type PublishedPort, + type RuntimePorts, runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; import { installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; -import { isConnectedToActorRuntime, overridingRuntimeEnvVars, resolveApiBaseUrl } from '../../lib/runtime/target.js'; +import { + configuredRuntimePorts, + isConnectedToActorRuntime, + overridingRuntimeEnvVars, + resolveApiBaseUrl, +} from '../../lib/runtime/target.js'; import { printJsonToStdout } from '../../lib/utils.js'; -function portRole(containerPort: number): string { - if (containerPort === ACTOR_RUNTIME_API_PORT) return 'API'; - if (containerPort === ACTOR_RUNTIME_CONSOLE_PORT) return 'Console'; +function portRole(containerPort: number, ports: RuntimePorts): string { + if (containerPort === ports.api) return 'API'; + if (containerPort === ports.console) return 'Console'; return ''; } -function portLine({ containerPort, protocol, hostAddress }: PublishedPort): string { - const role = portRole(containerPort); +function portLine({ containerPort, protocol, hostAddress }: PublishedPort, ports: RuntimePorts): string { + const role = portRole(containerPort, ports); return ` ${`${containerPort}/${protocol}`.padEnd(10)} -> ${hostAddress}${role ? ` (${role})` : ''}`; } @@ -101,7 +105,8 @@ export class RuntimeStatusCommand extends ApifyCommand portLine(port, ports))); } lines.push('', 'Apify CLI target:'); diff --git a/src/lib/runtime/config.ts b/src/lib/runtime/config.ts index 3934d25b4..a2c6dbe71 100644 --- a/src/lib/runtime/config.ts +++ b/src/lib/runtime/config.ts @@ -9,6 +9,9 @@ export interface ActorRuntimeConfig { image?: string; /** Whether `apify runtime connect` pointed the CLI at the runtime. */ connected?: boolean; + /** The ports the last `apify runtime start` published. */ + apiPort?: number; + consolePort?: number; } export function readActorRuntimeConfig(): ActorRuntimeConfig { diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index 1469f8ac2..e8d1fcf10 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -16,13 +16,33 @@ export const DOCKER_ENGINE_INSTALL_URL = 'https://docs.docker.com/engine/install /** Official Podman documentation: installation on every platform. */ export const PODMAN_INSTALL_URL = 'https://podman.io/docs/installation'; +/** Default ports; 'apify runtime start --api-port/--console-port' moves them. */ export const ACTOR_RUNTIME_API_PORT = 3333; export const ACTOR_RUNTIME_CONSOLE_PORT = 3000; -export const ACTOR_RUNTIME_API_URL = `http://localhost:${ACTOR_RUNTIME_API_PORT}`; +/** The runtime's own environment variables that move its ports; the same number is published on the host. */ +export const RUNTIME_API_PORT_ENV_VAR = 'ACTOR_RUNTIME_API_PORT'; -export const ACTOR_RUNTIME_CONSOLE_URL = `http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}`; +export const RUNTIME_CONSOLE_PORT_ENV_VAR = 'ACTOR_RUNTIME_CONSOLE_PORT'; + +export interface RuntimePorts { + api: number; + console: number; +} + +export const DEFAULT_RUNTIME_PORTS: RuntimePorts = { + api: ACTOR_RUNTIME_API_PORT, + console: ACTOR_RUNTIME_CONSOLE_PORT, +}; + +export const runtimeApiUrl = (port: number) => `http://localhost:${port}`; + +export const runtimeConsoleUrl = (port: number) => `http://localhost:${port}`; + +export const ACTOR_RUNTIME_API_URL = runtimeApiUrl(ACTOR_RUNTIME_API_PORT); + +export const ACTOR_RUNTIME_CONSOLE_URL = runtimeConsoleUrl(ACTOR_RUNTIME_CONSOLE_PORT); /** * Environment variables that point the Apify CLI (and the Apify SDKs/clients that honour them) @@ -47,8 +67,11 @@ export const RUNTIME_SOCKET_PATH = '/var/run/docker.sock'; /** Where the runtime container expects its data directory (storages, builds and run records). */ export const RUNTIME_DATA_PATH = '/data'; -export function runtimeEnvExportLines(): string[] { - return Object.entries(ACTOR_RUNTIME_ENV_VARS).map(([name, value]) => `export ${name}=${value}`); +export function runtimeEnvExportLines(ports: RuntimePorts = DEFAULT_RUNTIME_PORTS): string[] { + return [ + `export APIFY_CLIENT_BASE_URL=${runtimeApiUrl(ports.api)}`, + `export APIFY_CONSOLE_URL=${runtimeConsoleUrl(ports.console)}`, + ]; } /** The engine the user asked for via `APIFY_CONTAINER_ENGINE`, or undefined for "whichever is installed". */ @@ -298,6 +321,7 @@ export interface RuntimeRunArgsOptions { dataDir: string; detach: boolean; hostSocketPath: string; + ports?: RuntimePorts; platform?: NodeJS.Platform; } @@ -306,6 +330,7 @@ export function buildRuntimeRunArgs({ dataDir, detach, hostSocketPath, + ports = DEFAULT_RUNTIME_PORTS, platform = process.platform, }: RuntimeRunArgsOptions): string[] { // --init makes signals (Ctrl+C) reach the runtime process even though it runs as the container's PID 1. @@ -315,11 +340,20 @@ export function buildRuntimeRunArgs({ args.push('--detach'); } + // Same number inside and out: the runtime builds its URLs, and Actors may reach its API through the + // host, from the port it listens on. Defaults are left implicit so older runtime images keep working. + if (ports.api !== ACTOR_RUNTIME_API_PORT) { + args.push('-e', `${RUNTIME_API_PORT_ENV_VAR}=${ports.api}`); + } + if (ports.console !== ACTOR_RUNTIME_CONSOLE_PORT) { + args.push('-e', `${RUNTIME_CONSOLE_PORT_ENV_VAR}=${ports.console}`); + } + args.push( '-p', - `${ACTOR_RUNTIME_API_PORT}:${ACTOR_RUNTIME_API_PORT}`, + `${ports.api}:${ports.api}`, '-p', - `${ACTOR_RUNTIME_CONSOLE_PORT}:${ACTOR_RUNTIME_CONSOLE_PORT}`, + `${ports.console}:${ports.console}`, '-v', socketMountArg(hostSocketPath, platform), '-v', diff --git a/src/lib/runtime/skill.ts b/src/lib/runtime/skill.ts index 3b9970260..593490f56 100644 --- a/src/lib/runtime/skill.ts +++ b/src/lib/runtime/skill.ts @@ -8,9 +8,9 @@ import { execa } from 'execa'; import { APIFY_CLIENT_DEFAULT_HEADERS } from '../consts.js'; import { userHomeDir } from '../utils.js'; -import { ACTOR_RUNTIME_API_URL, findRunningRuntimeEngine, imageExistsLocally, type ContainerEngine } from './docker.js'; +import { findRunningRuntimeEngine, imageExistsLocally, runtimeApiUrl, type ContainerEngine } from './docker.js'; import { installedActorRuntimeImage } from './ensure.js'; -import { resolveApiBaseUrl } from './target.js'; +import { configuredRuntimePorts, resolveApiBaseUrl } from './target.js'; /** Matches the `name` in the file's own frontmatter. */ export const RUNTIME_SKILL_NAME = 'apify-actor-runtime'; @@ -70,7 +70,7 @@ async function readSkillFromImage(engine: ContainerEngine, image: string): Promi /** Cheapest-first: HTTP, then the installed image. Deliberately no copy bundled with the CLI - one could * only ever describe the image the CLI was released against, not the one present. */ export async function resolveRuntimeSkill(): Promise { - const baseUrl = resolveApiBaseUrl() ?? ACTOR_RUNTIME_API_URL; + const baseUrl = resolveApiBaseUrl() ?? runtimeApiUrl(configuredRuntimePorts().api); const overHttp = await fetchSkillFromRuntime(baseUrl); if (overHttp) return { content: overHttp, source: { kind: 'runtime', baseUrl } }; diff --git a/src/lib/runtime/target.ts b/src/lib/runtime/target.ts index aac5017ca..ef4eac398 100644 --- a/src/lib/runtime/target.ts +++ b/src/lib/runtime/target.ts @@ -1,7 +1,13 @@ import process from 'node:process'; import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.js'; -import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL, ACTOR_RUNTIME_ENV_VARS } from './docker.js'; +import { + ACTOR_RUNTIME_ENV_VARS, + DEFAULT_RUNTIME_PORTS, + runtimeApiUrl, + runtimeConsoleUrl, + type RuntimePorts, +} from './docker.js'; /** Whether `apify runtime connect` pointed the CLI at the local Actor runtime. */ export function isConnectedToActorRuntime(): boolean { @@ -12,6 +18,12 @@ export function setConnectedToActorRuntime(connected: boolean) { updateActorRuntimeConfig({ connected }); } +/** The ports the runtime was last started with, which `connect` and the URLs below follow. */ +export function configuredRuntimePorts(): RuntimePorts { + const { apiPort, consolePort } = readActorRuntimeConfig(); + return { api: apiPort ?? DEFAULT_RUNTIME_PORTS.api, console: consolePort ?? DEFAULT_RUNTIME_PORTS.console }; +} + /** The environment variables from {@link ACTOR_RUNTIME_ENV_VARS} the user set themselves, with their values. */ export function overridingRuntimeEnvVars(env: NodeJS.ProcessEnv = process.env): [string, string][] { return Object.keys(ACTOR_RUNTIME_ENV_VARS) @@ -24,10 +36,15 @@ export function overridingRuntimeEnvVars(env: NodeJS.ProcessEnv = process.env): * `apify runtime connect` is in effect, else undefined for the Apify platform default. */ export function resolveApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { - return env.APIFY_CLIENT_BASE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_API_URL : undefined); + return ( + env.APIFY_CLIENT_BASE_URL || (isConnectedToActorRuntime() ? runtimeApiUrl(configuredRuntimePorts().api) : undefined) + ); } /** The Console the CLI links to, resolved the same way as {@link resolveApiBaseUrl}. */ export function resolveConsoleUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { - return env.APIFY_CONSOLE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_CONSOLE_URL : undefined); + return ( + env.APIFY_CONSOLE_URL || + (isConnectedToActorRuntime() ? runtimeConsoleUrl(configuredRuntimePorts().console) : undefined) + ); } diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index f9b90fec1..8a73352bb 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -184,5 +184,34 @@ describe('runtime/docker', () => { expect(args).toContain('--detach'); expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(DEFAULT_ACTOR_RUNTIME_IMAGE)); }); + + it('publishes moved ports under the same number and tells the runtime to listen on them', () => { + const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, + dataDir: '/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + ports: { api: 4333, console: 4000 }, + platform: 'linux', + }); + expect(args.join(' ')).toContain( + '-e ACTOR_RUNTIME_API_PORT=4333 -e ACTOR_RUNTIME_CONSOLE_PORT=4000 -p 4333:4333 -p 4000:4000', + ); + expect(args.indexOf('-e')).toBeLessThan(args.indexOf(DEFAULT_ACTOR_RUNTIME_IMAGE)); + }); + + it('passes only the moved port to the runtime', () => { + const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, + dataDir: '/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + ports: { api: 3333, console: 4000 }, + platform: 'linux', + }); + expect(args).toContain('ACTOR_RUNTIME_CONSOLE_PORT=4000'); + expect(args.join(' ')).not.toContain('ACTOR_RUNTIME_API_PORT'); + expect(args).toContain('3333:3333'); + }); }); }); diff --git a/test/local/lib/runtime-target.test.ts b/test/local/lib/runtime-target.test.ts index ea8140f80..4ac06d364 100644 --- a/test/local/lib/runtime-target.test.ts +++ b/test/local/lib/runtime-target.test.ts @@ -1,9 +1,13 @@ import { readFileSync } from 'node:fs'; +import { runUrl } from '../../../src/lib/commands/run-result.js'; +import { getConsoleUrl } from '../../../src/lib/console-url.js'; import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../../../src/lib/consts.js'; +import { updateActorRuntimeConfig } from '../../../src/lib/runtime/config.js'; import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL } from '../../../src/lib/runtime/docker.js'; import { rememberInstalledActorRuntimeImage } from '../../../src/lib/runtime/ensure.js'; import { + configuredRuntimePorts, overridingRuntimeEnvVars, resolveApiBaseUrl, resolveConsoleUrl, @@ -35,6 +39,19 @@ describe('runtime/target', () => { expect(resolveConsoleUrl({})).toBeUndefined(); }); + it('follows the ports the runtime was last started with', () => { + expect(configuredRuntimePorts()).toEqual({ api: 3333, console: 3000 }); + + updateActorRuntimeConfig({ apiPort: 4333, consolePort: 4000 }); + setConnectedToActorRuntime(true); + + expect(configuredRuntimePorts()).toEqual({ api: 4333, console: 4000 }); + expect(resolveApiBaseUrl({})).toBe('http://localhost:4333'); + expect(resolveConsoleUrl({})).toBe('http://localhost:4000'); + expect(runUrl('actor', 'run')).toBe('http://localhost:4000/actors/actor/runs/run'); + expect(getConsoleUrl()).toBe('http://localhost:4000'); + }); + it('lets the environment variables win over the connection', () => { setConnectedToActorRuntime(true);