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
10 changes: 9 additions & 1 deletion src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
description: 'Directory where the Actor is located',
required: false,
}),
'allow-missing-secrets': Flags.boolean({
description:
'Allow the command to continue even when secret values are not found in the local secrets storage.',
required: false,
default: false,
}),
};

static override args = {
Expand Down Expand Up @@ -293,7 +299,9 @@ Skipping push. Use --force to override.`,
// Update Actor version
const actorCurrentVersion = await actorClient.version(version).get();
const envVars = actorConfig!.environmentVariables
? transformEnvToEnvVars(actorConfig!.environmentVariables as Record<string, string>)
? transformEnvToEnvVars(actorConfig!.environmentVariables as Record<string, string>, undefined, {
allowMissing: this.flags.allowMissingSecrets,
})
: undefined;

if (actorCurrentVersion) {
Expand Down
12 changes: 11 additions & 1 deletion src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
stdin: StdinMode.Stringified,
exclusive: ['input'],
}),
'allow-missing-secrets': Flags.boolean({
description:
'Allow the command to continue even when secret values are not found in the local secrets storage.',
required: false,
default: false,
}),
};

async run() {
Expand Down Expand Up @@ -265,7 +271,11 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
if (userId) localEnvVars[APIFY_ENV_VARS.USER_ID] = userId;
if (token) localEnvVars[APIFY_ENV_VARS.TOKEN] = token;
if (localConfig!.environmentVariables) {
const updatedEnv = replaceSecretsValue(localConfig!.environmentVariables as Record<string, string>);
const updatedEnv = replaceSecretsValue(
localConfig!.environmentVariables as Record<string, string>,
undefined,
{ allowMissing: this.flags.allowMissingSecrets },
);
Object.assign(localEnvVars, updatedEnv);
}

Expand Down
60 changes: 52 additions & 8 deletions src/lib/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,47 @@ const isSecretKey = (envValue: string) => {
* @param env
* @param secrets - Object with secrets, if not set, will be load from secrets file.
*/
export const replaceSecretsValue = (env: Record<string, string>, secrets?: Record<string, string>) => {
export const replaceSecretsValue = (
env: Record<string, string>,
secrets?: Record<string, string>,
{ allowMissing = false }: { allowMissing?: boolean } = {},
) => {
secrets = secrets || getSecretsFile();
const updatedEnv = {};
const missingSecrets: string[] = [];

Object.keys(env).forEach((key) => {
if (isSecretKey(env[key])) {
const secretKey = env[key].replace(new RegExp(`^${SECRET_KEY_PREFIX}`), '');
if (secrets![secretKey]) {
// @ts-expect-error - we are replacing the value
updatedEnv[key] = secrets[secretKey];
} else {
warning({
message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`,
});
missingSecrets.push(secretKey);
}
} else {
// @ts-expect-error - we are replacing the value
updatedEnv[key] = env[key];
}
});

if (missingSecrets.length > 0) {
const secretsList = missingSecrets.map((s) => ` - ${s}`).join('\n');
if (allowMissing) {
for (const secretKey of missingSecrets) {
warning({
message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`,
});
}
} else {
throw new Error(
`The following secrets are missing:\n${secretsList}\n\n` +
`Set them by calling "apify secrets add <SECRET_NAME> <SECRET_VALUE>" for each missing secret.\n` +
`If you want to skip missing secrets, run the command with the --allow-missing-secrets flag.`,
);
}
}

return updatedEnv;
};

Expand All @@ -88,9 +110,15 @@ interface EnvVar {
* It replaces secrets to values from secrets file.
* @param secrets - Object with secrets, if not set, will be load from secrets file.
*/
export const transformEnvToEnvVars = (env: Record<string, string>, secrets?: Record<string, string>) => {
export const transformEnvToEnvVars = (
env: Record<string, string>,
secrets?: Record<string, string>,
{ allowMissing = false }: { allowMissing?: boolean } = {},
) => {
secrets = secrets || getSecretsFile();
const envVars: EnvVar[] = [];
const missingSecrets: string[] = [];

Object.keys(env).forEach((key) => {
if (isSecretKey(env[key])) {
const secretKey = env[key].replace(new RegExp(`^${SECRET_KEY_PREFIX}`), '');
Expand All @@ -101,9 +129,7 @@ export const transformEnvToEnvVars = (env: Record<string, string>, secrets?: Rec
isSecret: true,
});
} else {
warning({
message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`,
});
missingSecrets.push(secretKey);
}
} else {
envVars.push({
Expand All @@ -112,5 +138,23 @@ export const transformEnvToEnvVars = (env: Record<string, string>, secrets?: Rec
});
}
});

if (missingSecrets.length > 0) {
const secretsList = missingSecrets.map((s) => ` - ${s}`).join('\n');
if (allowMissing) {
for (const secretKey of missingSecrets) {
warning({
message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`,
});
}
} else {
throw new Error(
`The following secrets are missing:\n${secretsList}\n\n` +
`Set them by calling "apify secrets add <SECRET_NAME> <SECRET_VALUE>" for each missing secret.\n` +
`If you want to skip missing secrets, run the command with the --allow-missing-secrets flag.`,
);
}
}

return envVars;
};
110 changes: 104 additions & 6 deletions test/local/lib/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { replaceSecretsValue } from '../../../src/lib/secrets.js';
import { replaceSecretsValue, transformEnvToEnvVars } from '../../../src/lib/secrets.js';

describe('Secrets', () => {
describe('replaceSecretsValue()', () => {
it('should work', () => {
const spy = vitest.spyOn(console, 'error');

it('should replace secret references with their values', () => {
const secrets = {
myProdToken: 'mySecretToken',
mongoUrl: 'mongo://bla@bla:supermongo.com:27017',
Expand All @@ -13,7 +11,6 @@ describe('Secrets', () => {
TOKEN: '@myProdToken',
USER: 'jakub.drobnik@apify.com',
MONGO_URL: '@mongoUrl',
WARNING: '@doesNotExist',
};
const updatedEnv = replaceSecretsValue(env, secrets);

Expand All @@ -22,9 +19,110 @@ describe('Secrets', () => {
USER: 'jakub.drobnik@apify.com',
MONGO_URL: secrets.mongoUrl,
});
});

it('should throw an error when secrets are missing', () => {
const secrets = {
myProdToken: 'mySecretToken',
};
const env = {
TOKEN: '@myProdToken',
MISSING_ONE: '@doesNotExist',
MISSING_TWO: '@alsoMissing',
};

expect(() => replaceSecretsValue(env, secrets)).toThrow(
/The following secrets are missing:\n\s+- doesNotExist\n\s+- alsoMissing/,
);
});

it('should mention --allow-missing-secrets in the error message', () => {
const env = { TOKEN: '@doesNotExist' };

expect(() => replaceSecretsValue(env, {})).toThrow(/--allow-missing-secrets/);
});

it('should warn instead of throwing when allowMissing is true', () => {
const spy = vitest.spyOn(console, 'error');

const secrets = {
myProdToken: 'mySecretToken',
};
const env = {
TOKEN: '@myProdToken',
MISSING_ONE: '@doesNotExist',
MISSING_TWO: '@alsoMissing',
};

const updatedEnv = replaceSecretsValue(env, secrets, {
allowMissing: true,
});

expect(updatedEnv).toStrictEqual({
TOKEN: secrets.myProdToken,
});

expect(spy).toHaveBeenCalled();
expect(spy.mock.calls.flat().join(' ')).to.include('doesNotExist');

spy.mockRestore();
});
});

describe('transformEnvToEnvVars()', () => {
it('should transform env to envVars format with secret resolution', () => {
const secrets = {
myProdToken: 'mySecretToken',
};
const env = {
TOKEN: '@myProdToken',
USER: 'jakub.drobnik@apify.com',
};
const envVars = transformEnvToEnvVars(env, secrets);

expect(envVars).toStrictEqual([
{ name: 'TOKEN', value: 'mySecretToken', isSecret: true },
{ name: 'USER', value: 'jakub.drobnik@apify.com' },
]);
});

it('should throw an error when secrets are missing', () => {
const secrets = {};
const env = {
TOKEN: '@doesNotExist',
USER: 'plain-value',
};

expect(() => transformEnvToEnvVars(env, secrets)).toThrow(
/The following secrets are missing:\n\s+- doesNotExist/,
);
});

it('should mention --allow-missing-secrets in the error message', () => {
const env = { TOKEN: '@doesNotExist' };

expect(() => transformEnvToEnvVars(env, {})).toThrow(/--allow-missing-secrets/);
});

it('should warn instead of throwing when allowMissing is true', () => {
const spy = vitest.spyOn(console, 'error');

const secrets = {};
const env = {
TOKEN: '@doesNotExist',
USER: 'plain-value',
};

const envVars = transformEnvToEnvVars(env, secrets, {
allowMissing: true,
});

expect(envVars).toStrictEqual([{ name: 'USER', value: 'plain-value' }]);

expect(spy).toHaveBeenCalled();
expect(spy.mock.calls[0][0]).to.include('Warning:');
expect(spy.mock.calls.flat().join(' ')).to.include('doesNotExist');

spy.mockRestore();
});
});
});