Skip to content

Commit

Permalink
[eas-cli] use existing google service account key (#650)
Browse files Browse the repository at this point in the history
* [eas-cli] use existing google service account key

* changelog

* pr feedback

* update snap
  • Loading branch information
quinlanj authored Sep 28, 2021
1 parent 69add6f commit 4674cbe
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- More upload support for Google Service Account Keys. ([#649](https://github.com/expo/eas-cli/pull/649) by [@quinlanj](https://github.com/quinlanj))
- Allow the user to assign an existing Google Service Account Key to their project. ([#650](https://github.com/expo/eas-cli/pull/650) by [@quinlanj](https://github.com/quinlanj))

### 🐛 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export function getNewAndroidApiMock(): { [key in keyof typeof AndroidGraphqlCli
createOrUpdateAndroidAppBuildCredentialsByNameAsync: jest.fn(),
createKeystoreAsync: jest.fn(),
createGoogleServiceAccountKeyAsync: jest.fn(),
getGoogleServiceAccountKeysForAccountAsync: jest.fn(),
createFcmAsync: jest.fn(),
deleteKeystoreAsync: jest.fn(),
deleteFcmAsync: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import chalk from 'chalk';

import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { fromNow } from '../../../utils/date';
import { Context } from '../../context';

export class UseExistingGoogleServiceAccountKey {
constructor(private account: Account) {}

public async runAsync(ctx: Context): Promise<GoogleServiceAccountKeyFragment | null> {
if (ctx.nonInteractive) {
throw new Error(
`Existing Google Service Account Key cannot be chosen in non-interactive mode.`
);
}
const gsaKeyFragments = await ctx.android.getGoogleServiceAccountKeysForAccountAsync(
this.account
);
if (gsaKeyFragments.length === 0) {
Log.error("There aren't any Google Service Account Keys associated with your account.");
return null;
}
return await this.selectGoogleServiceAccountKeyAsync(gsaKeyFragments);
}

private async selectGoogleServiceAccountKeyAsync(
keys: GoogleServiceAccountKeyFragment[]
): Promise<GoogleServiceAccountKeyFragment | null> {
const sortedKeys = this.sortGoogleServiceAccountKeysByUpdatedAtDesc(keys);
const { chosenKey } = await promptAsync({
type: 'select',
name: 'chosenKey',
message: 'Select a Google Service Account Key:',
choices: sortedKeys.map(key => ({
title: this.formatGoogleServiceAccountKey(key),
value: key,
})),
});
return chosenKey;
}

private sortGoogleServiceAccountKeysByUpdatedAtDesc(
keys: GoogleServiceAccountKeyFragment[]
): GoogleServiceAccountKeyFragment[] {
return keys.sort(
(keyA, keyB) =>
new Date(keyB.updatedAt).getMilliseconds() - new Date(keyA.updatedAt).getMilliseconds()
);
}

private formatGoogleServiceAccountKey({
projectIdentifier,
privateKeyIdentifier,
clientEmail,
clientIdentifier,
updatedAt,
}: GoogleServiceAccountKeyFragment): string {
let line: string = '';
line += `Client Email: ${clientEmail}, Project Id: ${projectIdentifier}`;
line += chalk.gray(
`\n Client Id: ${clientIdentifier}, Private Key Id: ${privateKeyIdentifier}`
);
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago,`);
return line;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { asMock } from '../../../../__tests__/utils';
import { promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testGoogleServiceAccountKeyFragment,
} from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { UseExistingGoogleServiceAccountKey } from '../UseExistingGoogleServiceAccountKey';

jest.mock('../../../../prompts');
asMock(promptAsync).mockImplementation(() => ({ chosenKey: testGoogleServiceAccountKeyFragment }));

describe(UseExistingGoogleServiceAccountKey, () => {
it('uses an existing Google Service Account Key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
getGoogleServiceAccountKeysForAccountAsync: jest.fn(() => [
testGoogleServiceAccountKeyFragment,
]),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const useExistingGoogleServiceAccountKeyAction = new UseExistingGoogleServiceAccountKey(
appLookupParams.account
);
const selectedKey = await useExistingGoogleServiceAccountKeyAction.runAsync(ctx);
expect(ctx.android.getGoogleServiceAccountKeysForAccountAsync).toHaveBeenCalledTimes(1);
expect(selectedKey).toMatchObject(testGoogleServiceAccountKeyFragment);
});
it("returns null if the account doesn't have any Google Service Account Keys", async () => {
const ctx = createCtxMock({
nonInteractive: false,
android: {
...getNewAndroidApiMock(),
getGoogleServiceAccountKeysForAccountAsync: jest.fn(() => []),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const useExistingGoogleServiceAccountKeyAction = new UseExistingGoogleServiceAccountKey(
appLookupParams.account
);
const selectedKey = await useExistingGoogleServiceAccountKeyAction.runAsync(ctx);
expect(ctx.android.getGoogleServiceAccountKeysForAccountAsync).toHaveBeenCalledTimes(1);
expect(selectedKey).toBe(null);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const useExistingGoogleServiceAccountKeyAction = new UseExistingGoogleServiceAccountKey(
appLookupParams.account
);

// fail if users are running in non-interactive mode
await expect(useExistingGoogleServiceAccountKeyAction.runAsync(ctx)).rejects.toThrowError();
});
});
7 changes: 7 additions & 0 deletions packages/eas-cli/src/credentials/android/api/GraphqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AndroidFcmMutation } from './graphql/mutations/AndroidFcmMutation';
import { AndroidKeystoreMutation } from './graphql/mutations/AndroidKeystoreMutation';
import { GoogleServiceAccountKeyMutation } from './graphql/mutations/GoogleServiceAccountKeyMutation';
import { AndroidAppCredentialsQuery } from './graphql/queries/AndroidAppCredentialsQuery';
import { GoogleServiceAccountKeyQuery } from './graphql/queries/GoogleServiceAccountKeyQuery';

export interface AppLookupParams {
account: Account;
Expand Down Expand Up @@ -251,6 +252,12 @@ export async function createGoogleServiceAccountKeyAsync(
);
}

export async function getGoogleServiceAccountKeysForAccountAsync(
account: Account
): Promise<GoogleServiceAccountKeyFragment[]> {
return await GoogleServiceAccountKeyQuery.getAllForAccountAsync(account.name);
}

async function getAppAsync(appLookupParams: AppLookupParams): Promise<AppFragment> {
const projectFullName = formatProjectFullName(appLookupParams);
return await AppQuery.byFullNameAsync(projectFullName);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { print } from 'graphql';
import gql from 'graphql-tag';

import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
GoogleServiceAccountKeyByAccountQuery,
GoogleServiceAccountKeyFragment,
} from '../../../../../graphql/generated';
import { GoogleServiceAccountKeyFragmentNode } from '../../../../../graphql/types/credentials/GoogleServiceAccountKey';

export const GoogleServiceAccountKeyQuery = {
async getAllForAccountAsync(accountName: string): Promise<GoogleServiceAccountKeyFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GoogleServiceAccountKeyByAccountQuery>(
gql`
query GoogleServiceAccountKeyByAccountQuery($accountName: String!) {
account {
byName(accountName: $accountName) {
id
googleServiceAccountKeys {
id
...GoogleServiceAccountKeyFragment
}
}
}
}
${print(GoogleServiceAccountKeyFragmentNode)}
`,
{
accountName,
}
)
.toPromise()
);
return data.account.byName.googleServiceAccountKeys;
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

exports[`print credentials prints the AndroidAppCredentials fragment 1`] = `
"Android Credentials Project: testApp Application Identifier: test.com.app Push Notifications (FCM): Key: abcd...efgh Updated 0 second agoGoogle Service Account Key For Submissions Project ID sdf.sdf.sdf
Private Key ID test-private-key-identifier
Client Email quin@expo.io
Client ID test-client-identifier
Private Key ID test-private-key-identifier
Updated 0 second ago Configuration: legacy (Default) Keystore: Type: JKS Key Alias: QHdrb3p5cmEvY3JlZGVudGlhbHMtdGVzdA== MD5 Fingerprint: TE:ST:-M:D5 SHA1 Fingerprint: TE:ST:-S:HA:1 SHA256 Fingerprint: TE:ST:-S:HA:25:6 Updated 0 second ago"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ function displayGoogleServiceAccountKeyForSubmissions(

const fields = [
{ label: 'Project ID', value: projectIdentifier },
{ label: 'Private Key ID', value: privateKeyIdentifier },
{ label: 'Client Email', value: clientEmail },
{ label: 'Client ID', value: clientIdentifier },
{ label: 'Private Key ID', value: privateKeyIdentifier },
{ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` },
];
Log.log(formatFields(fields, { labelFormat: chalk.cyan.bold }));
Expand Down
14 changes: 14 additions & 0 deletions packages/eas-cli/src/credentials/manager/ManageAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { RemoveFcm } from '../android/actions/RemoveFcm';
import { RemoveKeystore } from '../android/actions/RemoveKeystore';
import { SetupBuildCredentialsFromCredentialsJson } from '../android/actions/SetupBuildCredentialsFromCredentialsJson';
import { UpdateCredentialsJson } from '../android/actions/UpdateCredentialsJson';
import { UseExistingGoogleServiceAccountKey } from '../android/actions/UseExistingGoogleServiceAccountKey';
import {
displayAndroidAppCredentials,
displayEmptyAndroidCredentials,
Expand All @@ -52,6 +53,7 @@ enum ActionType {
CreateFcm,
RemoveFcm,
CreateGsaKey,
UseExistingGsaKey,
UpdateCredentialsJson,
SetupBuildCredentialsFromCredentialsJson,
}
Expand Down Expand Up @@ -156,6 +158,11 @@ const gsaKeyActions: ActionInfo[] = [
title: 'Upload a Google Service Account Key',
scope: Scope.Project,
},
{
value: ActionType.UseExistingGsaKey,
title: 'Use an existing Google Service Account Key',
scope: Scope.Project,
},
{
value: ActionType.GoBackToHighLevelActions,
title: 'Go back',
Expand Down Expand Up @@ -329,6 +336,13 @@ export class ManageAndroid {
} else if (action === ActionType.CreateGsaKey) {
const gsaKey = await new CreateGoogleServiceAccountKey(appLookupParams.account).runAsync(ctx);
await new AssignGoogleServiceAccountKey(appLookupParams).runAsync(ctx, gsaKey);
} else if (action === ActionType.UseExistingGsaKey) {
const gsaKey = await new UseExistingGoogleServiceAccountKey(appLookupParams.account).runAsync(
ctx
);
if (gsaKey) {
await new AssignGoogleServiceAccountKey(appLookupParams).runAsync(ctx, gsaKey);
}
} else if (action === ActionType.UpdateCredentialsJson) {
const buildCredentials = await new SelectExistingAndroidBuildCredentials(
appLookupParams
Expand Down
21 changes: 21 additions & 0 deletions packages/eas-cli/src/graphql/generated.ts

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

0 comments on commit 4674cbe

Please sign in to comment.