Skip to content

Commit

Permalink
[eas-cli] add --with-eas-environment-variables-set flag to `eas upd…
Browse files Browse the repository at this point in the history
…ate` command (#2628)

<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C013ZK4SA12/p1725644220583309

We are planning to introduce enhancements to our env vars system soon. Currently, we only allow people to keep secrets on EAS servers. Secrets are not readable outside of EAS servers so of course EAS Update doesn't work with them. In a new system, people can also store readable env vars on our servers, which opens new possibilities about how EAS Update can interact with env vars!

In this PR I'm adding support for the `--with-eas-environment-variables-set production|preview|development` flag. If used, we use the flag's value to fetch readable env vars from our servers and use it in the `expo export` command to produce an update bundle based on these env vars. If the flag is used I'm using the `EXPO_NO_DOTENV` flag to disable Expo CLI from loading env vars from `.env` files and potentially unwillingly overwriting env vars fetched from our servers for a given environment.

Users seem to face big issues with using env vars with EAS Build & Update at the moment, originating from the fact that these 2 handle different, non-compatible ways of using env vars. Env vars in the build profile and server-side secrets vs local env vars + `.env`. With this flag it will be easy to avoid envs mismatch by doing `eas update ----with-eas-environment-variables-set environment` and `eas build` with `{ ..., environment: environment, ...} in the build profile!

# How

Add a new hidden (for now) flag `--with-eas-environment-variables-set` that indicates which environment to use. If used fetch env vars from server. Merge and overwrite `process.env` with newly fetched env vars. Use them to resolve the expo config. Don't use `.env` env var loading from Expo CLI if server-side env vars are used.

# Test Plan

Test manually. Run `eas update` with and without a new flag. Check that the expo config is resolved correctly.
  • Loading branch information
szdziedzic authored Oct 25, 2024
1 parent a212662 commit d0f01cb
Show file tree
Hide file tree
Showing 24 changed files with 337 additions and 38 deletions.
40 changes: 38 additions & 2 deletions packages/eas-cli/src/commandUtils/EasCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
import { OptionalPrivateProjectConfigContextField } from './context/OptionalPrivateProjectConfigContextField';
import { PrivateProjectConfigContextField } from './context/PrivateProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import { ServerSideEnvironmentVariablesContextField } from './context/ServerSideEnvironmentVariablesContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
import VcsClientContextField from './context/VcsClientContextField';
import { EasCommandError } from './errors';
Expand All @@ -24,6 +25,7 @@ import {
CommandEvent,
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log from '../log';
import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs';
Expand All @@ -44,8 +46,26 @@ export type ContextOutput<
[P in keyof T]: T[P];
};

type GetContextType<Type> = {
[Property in keyof Type]: any;
};

const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';

interface BaseGetContextAsyncArgs {
nonInteractive: boolean;
vcsClientOverride?: Client;
}

interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
extends BaseGetContextAsyncArgs {
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
}

interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
withServerSideEnvironment?: never;
}

export default abstract class EasCommand extends Command {
protected static readonly ContextOptions = {
/**
Expand Down Expand Up @@ -81,7 +101,7 @@ export default abstract class EasCommand extends Command {
* run within a project directory, null otherwise.
*/
OptionalProjectConfig: {
privateProjectConfig: new OptionalPrivateProjectConfigContextField(),
optionalPrivateProjectConfig: new OptionalPrivateProjectConfigContextField(),
},
/**
* Require this command to be run in a project directory. Return the project directory in the context.
Expand Down Expand Up @@ -116,6 +136,10 @@ export default abstract class EasCommand extends Command {
Vcs: {
vcsClient: new VcsClientContextField(),
},
ServerSideEnvironmentVariables: {
// eslint-disable-next-line async-protect/async-suffix
getServerSideEnvironmentVariablesAsync: new ServerSideEnvironmentVariablesContextField(),
},
};

/**
Expand Down Expand Up @@ -150,7 +174,18 @@ export default abstract class EasCommand extends Command {
} = object,
>(
commandClass: { contextDefinition: ContextInput<C> },
{ nonInteractive, vcsClientOverride }: { nonInteractive: boolean; vcsClientOverride?: Client }
{
nonInteractive,
vcsClientOverride,
// if specified and not null, the env vars from the selected environment will be fetched from the server
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
withServerSideEnvironment,
}: C extends
| GetContextType<typeof EasCommand.ContextOptions.DynamicProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.OptionalProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.ServerSideEnvironmentVariables>
? GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
: GetContextAsyncArgsWithoutServerSideEnvironmentArgument
): Promise<ContextOutput<C>> {
const contextDefinition = commandClass.contextDefinition;

Expand All @@ -164,6 +199,7 @@ export default abstract class EasCommand extends Command {
sessionManager: this.sessionManager,
analytics: this.analytics,
vcsClientOverride,
withServerSideEnvironment,
}),
]);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/eas-cli/src/commandUtils/context/ContextField.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import SessionManager from '../../user/SessionManager';
import { Client } from '../../vcs/vcs';

Expand All @@ -7,6 +8,10 @@ export interface ContextOptions {
analytics: Analytics;
nonInteractive: boolean;
vcsClientOverride?: Client;
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
}

export default abstract class ContextField<T> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ExpoConfig } from '@expo/config';

import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import {
ExpoConfigOptions,
getPrivateExpoConfig,
Expand All @@ -19,6 +21,7 @@ export class DynamicPublicProjectConfigContextField extends ContextField<Dynamic
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
Expand All @@ -27,6 +30,24 @@ export class DynamicPublicProjectConfigContextField extends ContextField<Dynamic
nonInteractive,
env: options?.env,
});
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = getPublicExpoConfig(projectDir, options);
return {
exp,
Expand All @@ -41,6 +62,7 @@ export class DynamicPrivateProjectConfigContextField extends ContextField<Dynami
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
Expand All @@ -49,6 +71,24 @@ export class DynamicPrivateProjectConfigContextField extends ContextField<Dynami
nonInteractive,
env: options?.env,
});
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = getPrivateExpoConfig(projectDir, options);
return {
exp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { ExpoConfig } from '@expo/config';
import { InvalidEasJsonError } from '@expo/eas-json/build/errors';

import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPrivateExpoConfig } from '../../project/expoConfig';

export class OptionalPrivateProjectConfigContextField extends ContextField<
Expand All @@ -14,7 +16,11 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
}
| undefined
> {
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<
| {
projectId: string;
exp: ExpoConfig;
Expand All @@ -39,7 +45,20 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
});
const exp = getPrivateExpoConfig(projectDir);
let serverSideEnvVars: Record<string, string> | undefined;
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
serverSideEnvVars = serverSideEnvironmentVariables;
}
const exp = getPrivateExpoConfig(projectDir, { env: serverSideEnvVars });
return {
exp,
projectDir,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPublicExpoConfig } from '../../project/expoConfig';

type GetServerSideEnvironmentVariablesFn = (
maybeEnv?: Record<string, string>
) => Promise<Record<string, string>>;

export class ServerSideEnvironmentVariablesContextField extends ContextField<GetServerSideEnvironmentVariablesFn> {
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<GetServerSideEnvironmentVariablesFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (maybeEnv?: Record<string, string>) => {
if (!withServerSideEnvironment) {
throw new Error(
'withServerSideEnvironment parameter is required to evaluate ServerSideEnvironmentVariablesContextField'
);
}
const exp = getPublicExpoConfig(projectDir, { env: maybeEnv });
const projectId = await getProjectIdAsync(sessionManager, exp, {
nonInteractive,
env: maybeEnv,
});
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
return serverSideEnvironmentVariables;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ExpoGraphqlClient } from './createGraphqlClient';
import { EnvironmentVariableEnvironment } from '../../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';

const cachedServerSideEnvironmentVariables: Record<
EnvironmentVariableEnvironment,
Record<string, string> | null
> = {
[EnvironmentVariableEnvironment.Development]: null,
[EnvironmentVariableEnvironment.Preview]: null,
[EnvironmentVariableEnvironment.Production]: null,
};

export async function loadServerSideEnvironmentVariablesAsync({
environment,
projectId,
graphqlClient,
}: {
environment: EnvironmentVariableEnvironment;
projectId: string;
graphqlClient: ExpoGraphqlClient;
}): Promise<Record<string, string>> {
// don't load environment variables if they were already loaded while executing a command
const cachedEnvVarsForEnvironment = cachedServerSideEnvironmentVariables[environment];
if (cachedEnvVarsForEnvironment) {
return cachedEnvVarsForEnvironment;
}

const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
graphqlClient,
{
appId: projectId,
environment,
}
);
const serverEnvVars = Object.fromEntries(
environmentVariables
.filter(({ name, value }) => name && value)
.map(({ name, value }) => [name, value])
) as Record<string, string>;

if (Object.keys(serverEnvVars).length > 0) {
Log.log(
`Environment variables loaded from the "${environment.toLowerCase()}" environment on EAS servers: ${Object.keys(
serverEnvVars
).join(', ')}.`
);
} else {
Log.log(
`No environment variables found for the "${environment.toLowerCase()}" environment on EAS servers.`
);
}

const encryptedEnvVars = environmentVariables.filter(({ name, value }) => name && !value);
if (encryptedEnvVars.length > 0) {
Log.warn(
`Some environment variables defined in the "${environment.toLowerCase()}" environment on EAS servers are of "encrypted" type and cannot be read outside of the EAS servers (including EAS CLI): ${encryptedEnvVars
.map(({ name }) => name)
.join(', ')}. `
);
}
Log.newLine();

cachedServerSideEnvironmentVariables[environment] = serverEnvVars;

return serverEnvVars;
}
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export default class Build extends EasCommand {
vcsClient,
} = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});

await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default class BuildInspect extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildInspect, {
nonInteractive: false,
withServerSideEnvironment: null,
});

const outputDirectory = path.resolve(process.cwd(), flags.output);
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default class BuildInternal extends EasCommand {
} = await this.getContextAsync(BuildInternal, {
nonInteractive: true,
vcsClientOverride: new GitNoCommitClient(),
withServerSideEnvironment: null,
});

await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/resign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export default class BuildResign extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildResign, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});

const maybeBuild = flags.maybeBuildId
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/version/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default class BuildVersionGetView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionGetView, {
nonInteractive: true,
withServerSideEnvironment: null,
});

if (!flags.platform && flags['non-interactive']) {
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/version/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default class BuildVersionSetView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionSetView, {
nonInteractive: false,
withServerSideEnvironment: null,
});

const platform = await selectPlatformAsync(flags.platform);
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/commands/build/version/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default class BuildVersionSyncView extends EasCommand {
vcsClient,
} = await this.getContextAsync(BuildVersionSyncView, {
nonInteractive: true,
withServerSideEnvironment: null,
});

const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
Expand Down
Loading

0 comments on commit d0f01cb

Please sign in to comment.