Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: builds namespace #620

Merged
merged 27 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eefca3d
feat: builds namespace
vladfrangu Aug 26, 2024
ad5f932
feat: builds ls
vladfrangu Aug 26, 2024
8f03c93
chore: return full json not just items
vladfrangu Aug 26, 2024
aae3670
chore: only mark these cmds as supporting json for now
vladfrangu Aug 26, 2024
6921eaa
chore: include actor username too
vladfrangu Aug 26, 2024
b7bd906
feat: builds create
vladfrangu Aug 26, 2024
12aed77
chore: more enhancements to build ls
vladfrangu Aug 27, 2024
1767123
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 27, 2024
893fae2
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 28, 2024
3656c54
chore: push --log flag
vladfrangu Aug 30, 2024
7391d99
chore: nicer log
vladfrangu Aug 30, 2024
7b4776c
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 30, 2024
965d3f3
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 2, 2024
dae1f5a
chore: some requested changes
vladfrangu Sep 2, 2024
3b44f38
chore: basic tests
vladfrangu Sep 2, 2024
2df93f8
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 2, 2024
b4adcf1
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 3, 2024
f8425d4
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 4, 2024
d858078
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 5, 2024
7b40a3e
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 6, 2024
e050956
Apply suggestions from code review
vladfrangu Sep 6, 2024
f978bd9
chore: attempt to split up ls more
vladfrangu Sep 9, 2024
fe8b002
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
f8b67c0
chore: disable pr for now (will be handled in future PR)
vladfrangu Sep 9, 2024
0ec21c0
docs: correct Actor spelling
vladfrangu Sep 9, 2024
4134faf
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
8d724be
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@
"@oclif/core": "~4.0.17",
"@oclif/plugin-help": "~6.2.8",
"@root/walk": "~1.1.0",
"@sapphire/duration": "^1.1.2",
"@sapphire/timestamp": "^1.0.3",
"adm-zip": "~0.5.15",
"ajv": "~8.17.1",
"apify-client": "~2.9.4",
"archiver": "~7.0.1",
"axios": "~1.7.3",
"chalk": "~5.3.0",
"cli-table": "^0.3.11",
"computer-name": "~0.1.0",
"configparser": "~0.3.10",
"cors": "~2.8.5",
Expand Down Expand Up @@ -109,6 +112,7 @@
"@types/adm-zip": "^0.5.5",
"@types/archiver": "^6.0.2",
"@types/chai": "^4.3.17",
"@types/cli-table": "^0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/inquirer": "^9.0.7",
Expand Down
149 changes: 149 additions & 0 deletions src/commands/builds/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';

import { ApifyCommand } from '../../lib/apify_command.js';
import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js';
import { error, simpleLog } from '../../lib/outputs.js';
import { getLoggedClientOrThrow, objectGroupBy, outputJobLog, TimestampFormatter } from '../../lib/utils.js';

export class BuildsCreateCommand extends ApifyCommand<typeof BuildsCreateCommand> {
static override description = 'Creates a new build of the Actor.';

static override flags = {
actor: Flags.string({
description:
'Optional Actor ID or Name to trigger a build for. By default, it will use the Actor from the current directory',
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
}),
tag: Flags.string({
description: 'Build tag to be applied to the successful Actor build. By default, this is "latest"',
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
}),
version: Flags.string({
description:
'Optional Actor Version to build. By default, this will be inferred from the tag, but this flag is required when multiple versions have the same tag.',
required: false,
}),
log: Flags.boolean({
description: 'Whether to print out the build log after the build is triggered.',
}),
};

static override enableJsonFlag = true;

async run() {
const { actor, tag, version, json, log } = this.flags;

const client = await getLoggedClientOrThrow();

const ctx = await resolveActorContext({ providedActorNameOrId: actor, client });

if (!ctx) {
error({
message:
'Unable to detect what Actor to list the builds for. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor=<id>"',
});

return;
}

const actorInfo = (await client.actor(ctx.id).get())!;

const versionsByBuildTag = objectGroupBy(
actorInfo.versions,
(actorVersion) => actorVersion.buildTag ?? 'latest',
);

const taggedVersions = versionsByBuildTag[tag ?? 'latest'];
const specificVersionExists = actorInfo.versions.find((v) => v.versionNumber === version);

let selectedVersion: string | undefined;
let actualTag = tag;

// --version takes precedence over tagged versions (but if --tag is also specified, it will be checked again)
if (specificVersionExists) {
// The API doesn't really care if the tag you use for a version is correct or not, just that the version exists. This means you CAN have two separate versions with the same tag
// but only the latest one that gets built will have the tag.
// The *console* lets you pick a version to build. Multiple versions can have the same default tag, ergo what was written above.
// The API *does* also let you tag any existing version with whatever you want. This is where we diverge, and follow semi-console-like behavior. Effectively, this one if check prevents you from doing
// "--version 0.1 --tag not_actually_the_tag", even if that is technically perfectly valid. Future reader of this code, if this is not wanted, nuke the if check.

// This ensures that a --tag and --version match the version and tag the platform knows about
// but only when --tag is provided
if (tag && (!taggedVersions || !taggedVersions.some((v) => v.versionNumber === version))) {
error({
message: `The Actor Version "${version}" does not have the tag "${tag}".`,
});

return;
}

selectedVersion = version!;
actualTag = specificVersionExists.buildTag ?? 'latest';
} else if (taggedVersions) {
selectedVersion = taggedVersions[0].versionNumber!;
actualTag = tag ?? 'latest';

if (taggedVersions.length > 1) {
if (!version) {
error({
message: `Multiple Actor versions with the tag "${tag}" found. Please specify the version number using the "--version" flag.\n Available versions for this tag: ${taggedVersions.map((v) => chalk.yellow(v.versionNumber)).join(', ')}`,
});

return;
}

// On second run, it will call the upper if check
}
}

if (!selectedVersion) {
error({
message: `No Actor versions with the tag "${tag}" found. You can push a new version with this tag by using "apify push --build-tag=${tag}"`,
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
});

return;
}

const build = await client.actor(ctx.id).build(selectedVersion, { tag });

if (json) {
return build;
}

const message: string[] = [
`${chalk.yellow('Actor')}: ${actorInfo?.username ? `${actorInfo.username}/` : ''}${actorInfo?.name ?? 'unknown-actor'} (${chalk.gray(build.actId)})`,
` ${chalk.yellow('Version')}: ${selectedVersion} (tagged with ${chalk.yellow(actualTag)})`,
'',
`${chalk.greenBright('Build Started')} (ID: ${chalk.gray(build.id)})`,
` ${chalk.yellow('Build Number')}: ${build.buildNumber} (will get tagged once finished)`,
` ${chalk.yellow('Started')}: ${TimestampFormatter.display(build.startedAt)}`,
'',
];

const url = `https://console.apify.com/actors/${build.actId}/builds/${build.buildNumber}`;
const viewMessage = `${chalk.blue('View in Apify Console')}: ${url}`;

simpleLog({
message: message.join('\n'),
});

if (log) {
try {
await outputJobLog(build);
} catch (err) {
// This should never happen...
error({ message: `Failed to print log for build with ID "${build.id}": ${(err as Error).message}` });
}

// Print out an empty line
simpleLog({
message: '',
});
}

simpleLog({
message: viewMessage,
});

return undefined;
}
}
9 changes: 9 additions & 0 deletions src/commands/builds/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApifyCommand } from '../../lib/apify_command.js';

export class ActorIndexCommand extends ApifyCommand<typeof ActorIndexCommand> {
static override description = 'Commands are designed to be used with Actor Builds.';

async run() {
await this.printHelp();
}
}
100 changes: 100 additions & 0 deletions src/commands/builds/info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';

import { ApifyCommand } from '../../lib/apify_command.js';
import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js';
import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js';
import { error, simpleLog } from '../../lib/outputs.js';
import { DurationFormatter, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js';

export class BuildInfoCommand extends ApifyCommand<typeof BuildInfoCommand> {
static override description = 'Prints information about a specific build';
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved

static override args = {
buildId: Args.string({
required: true,
description: 'The build id to get information about',
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
}),
};

static override enableJsonFlag = true;

async run() {
const { buildId } = this.args;

const apifyClient = await getLoggedClientOrThrow();

const build = await apifyClient.build(buildId).get();

if (!build) {
error({ message: `Build with ID "${buildId}" was not found on your account` });
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
return;
}

// JSON output -> return the object (which is handled by oclif)
if (this.flags.json) {
return build;
}

const actor = await apifyClient.actor(build.actId).get();

let buildTag: string | undefined;

if (actor?.taggedBuilds) {
for (const [tag, buildData] of Object.entries(actor.taggedBuilds)) {
if (buildData.buildId === build.id) {
buildTag = tag;
break;
}
}
}

const exitCode = Reflect.get(build, 'exitCode') as number | undefined;

const message: string[] = [
//
`${chalk.yellow('Actor')}: ${actor?.username ? `${actor.username}/` : ''}${actor?.name ?? 'unknown-actor'} (${chalk.gray(build.actId)})`,
'',
`${chalk.yellow('Build Information')} (ID: ${chalk.gray(build.id)})`,
` ${chalk.yellow('Build Number')}: ${build.buildNumber}${buildTag ? ` (tagged as ${chalk.yellow(buildTag)})` : ''}`,
// exitCode is also not typed...
` ${chalk.yellow('Status')}: ${prettyPrintStatus(build.status)}${typeof exitCode !== 'undefined' ? ` (exit code: ${chalk.gray(exitCode)})` : ''}`,
` ${chalk.yellow('Started')}: ${TimestampFormatter.display(build.startedAt)}`,
];

if (build.finishedAt) {
message.push(
` ${chalk.yellow('Finished')}: ${TimestampFormatter.display(build.finishedAt)} (took ${chalk.gray(DurationFormatter.format(build.stats?.durationMillis ?? 0))})`,
);
} else {
const diff = Date.now() - build.startedAt.getTime();
message.push(
` ${chalk.yellow('Finished')}: ${chalk.gray(`Running for ${DurationFormatter.format(diff)}`)}`,
);
}
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved

if (build.stats?.computeUnits) {
// Platform shows 3 decimal places, so shall we
message.push(` ${chalk.yellow('Compute Units')}: ${build.stats.computeUnits.toFixed(3)}`);
}

// Untyped field again 😢
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
const dockerImageSize = Reflect.get(build.stats ?? {}, 'imageSizeBytes') as number | undefined;

if (dockerImageSize) {
message.push(` ${chalk.yellow('Docker Image Size')}: ${prettyPrintBytes(dockerImageSize)}`);
}

message.push(` ${chalk.yellow('Origin')}: ${build.meta.origin ?? 'UNKNOWN'}`);

message.push('');

const url = `https://console.apify.com/actors/${build.actId}/builds/${build.buildNumber}`;

message.push(`${chalk.blue('View in Apify Console')}: ${url}`);

simpleLog({ message: message.join('\n') });

return undefined;
}
}
38 changes: 38 additions & 0 deletions src/commands/builds/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Args } from '@oclif/core';

import { ApifyCommand } from '../../lib/apify_command.js';
import { error, info } from '../../lib/outputs.js';
import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js';

export class BuildLogCommand extends ApifyCommand<typeof BuildLogCommand> {
static override description = 'Prints the log of a specific build';
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved

static override args = {
buildId: Args.string({
required: true,
description: 'The build id to get the log from',
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
}),
};

async run() {
const { buildId } = this.args;

const apifyClient = await getLoggedClientOrThrow();

const build = await apifyClient.build(buildId).get();

if (!build) {
error({ message: `Build with ID "${buildId}" was not found on your account` });
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
return;
}

info({ message: `Log for build with ID "${buildId}":\n` });

try {
await outputJobLog(build);
} catch (err) {
// This should never happen...
error({ message: `Failed to get log for build with ID "${buildId}": ${(err as Error).message}` });
}
}
}
Loading
Loading