Skip to content
Draft
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
165 changes: 120 additions & 45 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { readFileSync, statSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import process from 'node:process';

import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client';
import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyApiError } from 'apify-client';
import open from 'open';

import { fetchManifest } from '@apify/actor-templates';
Expand All @@ -20,7 +22,14 @@ import { Args } from '../../lib/command-framework/args.js';
import { Flags } from '../../lib/command-framework/flags.js';
import { getConsoleUrl } from '../../lib/console-url.js';
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
import { monorepoUnsupportedMessage, resolveDockerContext } from '../../lib/docker-context.js';
import {
getContextFilePaths,
isOutside,
monorepoUnsupportedMessage,
readGitProvenance,
resolveDockerContext,
toPosix,
} from '../../lib/docker-context.js';
import { sumFilesSizeInBytes } from '../../lib/files.js';
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
Expand All @@ -38,7 +47,6 @@ import {
printJsonToStdout,
} from '../../lib/utils.js';

const TEMP_ZIP_FILE_NAME = 'temp_file.zip';
const DEFAULT_RUN_OPTIONS = {
build: 'latest',
memoryMbytes: 4096,
Expand Down Expand Up @@ -155,6 +163,31 @@ const confirmGitSourceSwitch = async ({
});
};

/** A platform without the `actorPath` version field rejects the key with a schema error. */
const rejectsActorPath = (err: unknown) => {
const { statusCode, message } = err as ApifyApiError;
return statusCode === 400 && typeof message === 'string' && message.includes('actorPath');
};

/**
* The platform's multi-file and zip sources carry files only: a link to a file goes as that file, a link to a
* directory inside the upload as copies of the uploaded files under its target, and a link to nothing is left out.
*/
const resolveLinksForUpload = (root: string, files: string[]) => {
const stats = new Map(files.map((file) => [file, statSync(join(root, file), { throwIfNoEntry: false })]));
return files.flatMap((file) => {
const fileStats = stats.get(file);
if (fileStats?.isFile()) return [file];
if (!fileStats?.isDirectory()) return [];
const target = relative(realpathSync(root), realpathSync(join(root, file)));
if (isOutside(target)) return [];
const prefix = `${toPosix(target)}/`;
return files
.filter((other) => other.startsWith(prefix) && stats.get(other)?.isFile())
.map((other) => `${file}/${other.slice(prefix.length)}`);
});
};

export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
static override name = 'push' as const;

Expand Down Expand Up @@ -290,32 +323,48 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {

const { config: actorConfig } = actorConfigResult.unwrap();

// A monorepo Actor builds from a Docker context above its folder, which a push of that folder cannot carry.
// A monorepo Actor builds from a Docker context above its folder, so the push uploads its repository
// (or, outside Git, the context) and tells the platform where the Actor lies in it.
const dockerContext = resolveDockerContext(cwd, actorConfig?.dockerContextDir);
if (dockerContext.kind === 'invalid') {
error({ message: dockerContext.message });
process.exitCode = CommandExitCodes.InvalidActorJson;
return;
}
let uploadRoot = cwd;
let uploadedFiles = filePathsToPush;
let actorPath: string | undefined;
if (dockerContext.kind === 'context') {
error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) });
process.exitCode = CommandExitCodes.NotImplemented;
return;
uploadRoot = dockerContext.sourceRoot;
uploadedFiles = resolveLinksForUpload(uploadRoot, (await getContextFilePaths(uploadRoot)).map(toPosix));
actorPath = dockerContext.actorPath;
}

// The platform resolves these paths after the upload; a wrong one builds the wrong image or fails the build.
const pathProblems = checkActorPaths({
actorDir: cwd,
uploadRoot: cwd,
uploadedFiles: filePathsToPush,
uploadRoot,
uploadedFiles,
config: actorConfig,
dockerContextRoot: dockerContext.kind === 'context' ? dockerContext.contextRoot : undefined,
});
if (pathProblems.length > 0) {
error({ message: formatActorPathProblems(pathProblems) });
process.exitCode = CommandExitCodes.InvalidActorJson;
return;
}

if (actorPath && readGitProvenance(uploadRoot).dirty) {
warning({
message: `The repository ${uploadRoot} has uncommitted changes; the push uploads the files as they are.`,
});
}

const refuseMonorepoPush = () => {
error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) });
process.exitCode = CommandExitCodes.NotImplemented;
};

const userInfo = await getCurrentUserInfo();
const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId;
const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : '';
Expand Down Expand Up @@ -368,6 +417,7 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
// TODO: export enum from apify-client
sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES as never,
sourceFiles: [],
...(actorPath ? { actorPath } : {}),
},
],
};
Expand All @@ -377,7 +427,13 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
newActor.actorStandby = { isEnabled: true };
}

actor = await apifyClient.actors().create(newActor);
try {
actor = await apifyClient.actors().create(newActor);
} catch (err) {
if (!rejectsActorPath(err)) throw err;
refuseMonorepoPush();
return;
}
actorId = actor.id;
isActorCreatedNow = true;
info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` });
Expand All @@ -393,14 +449,14 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {

info({ message: `Deploying Actor '${actorConfig!.name}' to Apify.` });

const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd);
const filesSize = await sumFilesSizeInBytes(uploadedFiles, uploadRoot);

if (filesSize < MAX_MULTIFILE_BYTES && !isActorCreatedNow) {
if ((filesSize < MAX_MULTIFILE_BYTES || actorPath) && !isActorCreatedNow) {
const client = await actorClient.get();

// Check when was files modified last
const mostRecentModifiedFileMs = filePathsToPush.reduce((modifiedMs, filePath) => {
const { mtimeMs, ctimeMs } = statSync(join(cwd, filePath));
const mostRecentModifiedFileMs = uploadedFiles.reduce((modifiedMs, filePath) => {
const { mtimeMs, ctimeMs } = statSync(join(uploadRoot, filePath));

// Sometimes it's possible mtimeMs is some messed up value (like 2000/01/01 midnight), then we want to check created if it's newer
const fileModifiedMs = mtimeMs > ctimeMs ? mtimeMs : ctimeMs;
Expand Down Expand Up @@ -443,24 +499,28 @@ Skipping push. Use --force to override.`,
let sourceFiles;
let tarballUrl;
if (filesSize < MAX_MULTIFILE_BYTES) {
sourceFiles = await createSourceFiles(filePathsToPush, cwd);
sourceFiles = await createSourceFiles(uploadedFiles, uploadRoot);
sourceType = ACTOR_SOURCE_TYPES.SOURCE_FILES;
} else {
// Create zip
run({ message: 'Zipping Actor files' });
await createActZip(TEMP_ZIP_FILE_NAME, filePathsToPush, cwd);
const zipPath = join(tmpdir(), `apify-push-${randomUUID()}.zip`);
await createActZip(zipPath, uploadedFiles, uploadRoot);

// Upload it to Apify.keyValueStores
const store = await apifyClient.keyValueStores().getOrCreate(`actor-${actorId}-source`);
const key = `version-${version}.zip`;
const buffer = readFileSync(TEMP_ZIP_FILE_NAME);
await apifyClient.keyValueStore(store.id).setRecord({
key,
// TODO: fix this type too
value: buffer as never,
contentType: 'application/zip',
});
unlinkSync(TEMP_ZIP_FILE_NAME);
try {
const buffer = readFileSync(zipPath);
await apifyClient.keyValueStore(store.id).setRecord({
key,
// TODO: fix this type too
value: buffer as never,
contentType: 'application/zip',
});
} finally {
unlinkSync(zipPath);
}
const tempTarballUrl = new URL(
`${apifyClient.baseUrl}/key-value-stores/${store.id}/records/${key}?disableRedirect=true`,
);
Expand Down Expand Up @@ -493,27 +553,42 @@ Skipping push. Use --force to override.`,
const applyEnvVarsToBuild =
this.flags.applyEnvVarsToBuild ?? (actorConfig!.applyEnvVarsToBuild as boolean | undefined);

if (actorCurrentVersion) {
const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars, applyEnvVarsToBuild };
// TODO: fix this type too -.-
await actorClient.version(version).update(actorVersionModifier as never);
run({ message: `Updated version ${version} for Actor ${actor.name}.` });
} else {
const actorNewVersion = {
versionNumber: version,
tarballUrl,
sourceFiles,
buildTag,
sourceType,
envVars,
applyEnvVarsToBuild,
};
const actorVersionSource = { tarballUrl, sourceFiles, sourceType, ...(actorPath ? { actorPath } : {}) };
try {
if (actorCurrentVersion) {
const actorVersionModifier = { ...actorVersionSource, buildTag, envVars, applyEnvVarsToBuild };
// TODO: fix this type too -.-
await actorClient.version(version).update(actorVersionModifier as never);
run({ message: `Updated version ${version} for Actor ${actor.name}.` });
} else {
const actorNewVersion = {
versionNumber: version,
...actorVersionSource,
buildTag,
envVars,
applyEnvVarsToBuild,
};

await actorClient.versions().create({
...actorNewVersion,
} as never);
await actorClient.versions().create({
...actorNewVersion,
} as never);

run({ message: `Created version ${version} for Actor ${actor.name}.` });
run({ message: `Created version ${version} for Actor ${actor.name}.` });
}
} catch (err) {
if (!rejectsActorPath(err)) throw err;
if (isActorCreatedNow) {
try {
await actorClient.delete();
info({ message: `Deleted the just created Actor ${actor.name} again.` });
} catch (deleteErr) {
warning({
message: `Could not delete the just created Actor ${actor.name}: ${(deleteErr as Error).message}`,
});
}
}
refuseMonorepoPush();
return;
}

// Sync standby mode on existing actors with actor.json
Expand Down
Loading
Loading