Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@crawlee/fs-storage": "4.0.0-beta.145",
"dockerode": "^4.0.5",
"express": "^5.1.0",
"json5": "^2.2.3",
"tar-stream": "^3.1.7"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions requirements/actor-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
- Actor build details are saved in `__BUILDS__` internal storage
- Actor build log is saved in `__LOGS__` internal storage
- Actor details are saved in `__ACTORS__` internal storage
- **The Dockerfile to build is resolved from the Actor's pushed source**, not Docker's implicit default. `.actor/actor.json` is parsed as JSON5; an unparseable file fails the build with a "Could not parse .actor/actor.json" message. Resolution order, stopping at the first hit:
1. the `dockerfile` field of `.actor/actor.json`, relative to `.actor/` - a path escaping the Actor root fails with "points outside the Actor root directory"; a non-string value fails with `"dockerfile" must be a string`; a value naming no pushed file (including empty) falls through instead of failing.
2. `.actor/Dockerfile`
3. `Dockerfile` at the Actor root
4. the platform's bundled default Dockerfile, for that build only - the pushed source itself is unchanged.
- Matching is case-insensitive, exact-case wins ties, and every outcome is stated in the build log.

# Bind mount volumes with Actor source code

Expand Down
3 changes: 2 additions & 1 deletion requirements/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
which covers push/call/log-stream/storage-access against the runtime itself, not what a given Actor's
own code does over the network.
- Building the sample Actors' images requires pulling their base images
(`apify/actor-node:24`, `apify/actor-python:3.13`) at least once; CI must pre-pull both before
(`apify/actor-node:24`, `apify/actor-python:3.13`, and `python:3.11-slim` for the
`sample_actor_crawler` build case) at least once; CI must pre-pull all three before
running the e2e suite so the timing of the actual push/call assertions is not dominated by image
pulls.

Expand Down
17 changes: 17 additions & 0 deletions sample_actor_crawler/.actor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Specify the base Docker image. See https://docs.apify.com/sdk/python/docs/overview/getting-started
FROM apify/actor-python:3.13

COPY --chown=myuser:myuser requirements.txt ./

RUN echo "Python version:" \
&& python --version \
&& echo "Pip version:" \
&& pip --version \
&& echo "Installing dependencies:" \
&& pip install -r requirements.txt \
&& echo "All installed Python packages:" \
&& pip freeze

COPY --chown=myuser:myuser . ./

CMD ["python3", "-m", "src"]
8 changes: 8 additions & 0 deletions sample_actor_crawler/.actor/actor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"actorSpecification": 1,
"name": "sample-actor-crawler",
"version": "0.0",
"buildTag": "latest",
"dockerfile": "./Dockerfile",
"input": "./input_schema.json"
}
22 changes: 22 additions & 0 deletions sample_actor_crawler/.actor/input_schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"title": "Sample Actor Crawler input",
"type": "object",
"schemaVersion": 1,
"properties": {
"startUrl": {
"title": "Start URL",
"type": "string",
"description": "The page the crawler starts from. Same-domain links found on it (and on the pages it discovers) are enqueued too, up to the crawl limit.",
"editor": "textfield",
"default": "https://crawlee.dev"
},
"proxyConfiguration": {
"title": "Proxy configuration",
"type": "object",
"description": "Apify Proxy configuration used for every request. The only way to crawl direct with no proxy is an explicit `{\"useApifyProxy\": false}` -- omitting this field entirely is NOT equivalent; it falls through to the SDK's default proxy configuration (same as `useApifyProxy: true`) and requires a valid APIFY_PROXY_PASSWORD.",
"editor": "proxy",
"default": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
},
"required": ["startUrl"]
}
2 changes: 2 additions & 0 deletions sample_actor_crawler/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
apify
crawlee[parsel]
Empty file.
6 changes: 6 additions & 0 deletions sample_actor_crawler/src/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import asyncio

from .main import main

if __name__ == '__main__':
asyncio.run(main())
41 changes: 41 additions & 0 deletions sample_actor_crawler/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Sample Actor demonstrating a Parsel-based crawl through Apify Proxy."""

from crawlee.crawlers import ParselCrawler, ParselCrawlingContext
from crawlee.router import Router

from apify import Actor

router = Router[ParselCrawlingContext]()


@router.default_handler
async def request_handler(context: ParselCrawlingContext) -> None:
Actor.log.info(f"Scraping {context.request.url} ...")

data = {
"url": context.request.url,
"title": context.selector.xpath("//title/text()").get(),
"headings": context.selector.xpath("//h1/text() | //h2/text() | //h3/text()").getall(),
}
await context.push_data(data)

await context.enqueue_links(strategy="same-domain")


async def main() -> None:
async with Actor:
actor_input = await Actor.get_input() or {}
start_url = actor_input.get("startUrl", "https://crawlee.dev")

proxy_configuration = await Actor.create_proxy_configuration(
actor_proxy_input=actor_input.get("proxyConfiguration")
)

crawler = ParselCrawler(
proxy_configuration=proxy_configuration,
request_handler=router,
# Crawl limit: 10 pages total, seed URL counted.
max_requests_per_crawl=10,
)

await crawler.run([start_url])
4 changes: 3 additions & 1 deletion src/driver/docker-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import Docker from 'dockerode';
import * as tar from 'tar-stream';

import { CONTAINER_API_ALIAS } from '../config.js';
import { normalizeEntryName } from './tar-entry-name.js';
import type { SourceFile } from '../storage/entities.js';
import {
DriverTimedOutError,
Expand Down Expand Up @@ -111,7 +112,7 @@ function buildTarball(sourceFiles: SourceFile[]): NodeJS.ReadableStream {
const pack = tar.pack();
for (const file of sourceFiles) {
const buffer = sourceFileToBuffer(file);
pack.entry({ name: file.name }, buffer);
pack.entry({ name: normalizeEntryName(file.name) }, buffer);
}
pack.finalize();
return pack;
Expand Down Expand Up @@ -231,6 +232,7 @@ export class DockerDriver implements Driver {
stream = await this.docker.buildImage(tarball, {
t: imageTag,
nocache: !ctx.useCache,
dockerfile: ctx.dockerfilePath,
abortSignal: controller.signal,
});
} catch (error) {
Expand Down
9 changes: 9 additions & 0 deletions src/driver/tar-entry-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as path from 'node:path';

/** Canonicalizes a source-file name to a tar entry name: POSIX separators, no leading `./`, then
* `path.posix.normalize` (a leading `../` is left alone). */
export function normalizeEntryName(name: string): string {
const posixName = name.replace(/\\/g, '/');
const withoutLeadingDotSlash = posixName.replace(/^(?:\.\/)+/, '');
return path.posix.normalize(withoutLeadingDotSlash);
}
2 changes: 2 additions & 0 deletions src/driver/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface BuildContext {
sourceFiles: SourceFile[];
useCache: boolean;
timeoutSecs: number;
/** Tar-relative path to the Dockerfile to build, passed as dockerode's `dockerfile` build option. */
dockerfilePath: string;
}

/** Host folder + image working directory, carried together so "both or neither" is enforced by the
Expand Down
23 changes: 21 additions & 2 deletions src/services/builds.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { generateId } from '../storage/ids.js';
import type { BuildRecord, JobStatus } from '../storage/entities.js';
import type { BuildRecord, JobStatus, SourceFile } from '../storage/entities.js';
import { getRegistries } from '../storage/registries.js';
import type { ActorRecord, ActorVersionRecord } from '../storage/entities.js';
import { recordTaggedBuild, updateActor } from './actors.js';
import type { Driver } from '../driver/types.js';
import { DriverTimedOutError } from '../driver/types.js';
import { resolveDockerfileLocation } from './dockerfile-location.js';
import { appendLog, flushLog, markLogTerminal } from './logs.js';
import { isTerminalJobStatus, transitionJobStatus } from './job-status.js';

Expand Down Expand Up @@ -180,14 +181,32 @@ export async function runBuildInBackground(
return;
}

const dockerfileResolution = resolveDockerfileLocation(version.sourceFiles);
if (dockerfileResolution.outcome === 'failure') {
appendLog(record.id, `${dockerfileResolution.message}\n`);
await flushLog(record.id);
markLogTerminal(record.id);
await transitionJobStatus(builds, record.id, 'FAILED', {
finishedAt: new Date().toISOString(),
statusMessage: dockerfileResolution.message,
});
return;
}
for (const line of dockerfileResolution.logLines) appendLog(record.id, line);
const sourceFiles: SourceFile[] =
dockerfileResolution.outcome === 'default'
? [...version.sourceFiles, dockerfileResolution.extraSourceFile]
: version.sourceFiles;

try {
const outcome = await driver.startBuild(
{
buildId: record.id,
actorName: actor.name,
sourceFiles: version.sourceFiles,
sourceFiles,
useCache: options.useCache,
timeoutSecs: DEFAULT_BUILD_TIMEOUT_SECS,
dockerfilePath: dockerfileResolution.dockerfilePath,
},
(chunk) => appendLog(record.id, chunk),
);
Expand Down
15 changes: 15 additions & 0 deletions src/services/default-dockerfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Basename for the resolver's Dockerfile candidates and the bundled default's own tar-entry name. */
export const DEFAULT_DOCKERFILE_NAME = 'Dockerfile';

/** Injected when an Actor's pushed source names no Dockerfile. Matches the Apify platform's default Dockerfile. */
export const DEFAULT_DOCKERFILE_CONTENT = `# This is a default Dockerfile is used for Actors that don't have a Dockerfile.
FROM apify/actor-node:20

# Copy all files and directories from the directory to the Docker image
COPY . ./

# Install NPM packages, skip optional and development dependencies to keep the image small,
# avoid logging to much and show log the dependency tree
RUN npm install --quiet --only=prod --no-optional \\
&& (npm list || true)
`;
Loading
Loading