diff --git a/cli/BUILD b/cli/BUILD index aa20288d4..17a47bb9c 100644 --- a/cli/BUILD +++ b/cli/BUILD @@ -41,7 +41,6 @@ ts_library( "@npm//@types/yargs", "@npm//chokidar", "@npm//glob", - "@npm//parse-duration", "@npm//readline-sync", "@npm//untildify", "@npm//yargs", diff --git a/cli/api/BUILD b/cli/api/BUILD index 9b817c962..aed10e738 100644 --- a/cli/api/BUILD +++ b/cli/api/BUILD @@ -40,6 +40,7 @@ ts_library( "@npm//deepmerge", "@npm//fs-extra", "@npm//glob", + "@npm//google-auth-library", "@npm//google-sql-syntax-ts", "@npm//js-beautify", "@npm//js-yaml", diff --git a/cli/api/dbadapters/bigquery.ts b/cli/api/dbadapters/bigquery.ts index 303e08024..b94594289 100644 --- a/cli/api/dbadapters/bigquery.ts +++ b/cli/api/dbadapters/bigquery.ts @@ -1,4 +1,5 @@ -import { BigQuery, GetTablesResponse, TableField, TableMetadata } from "@google-cloud/bigquery"; +import { BigQuery, BigQueryOptions, GetTablesResponse, TableField, TableMetadata } from "@google-cloud/bigquery"; +import { GoogleAuth, Impersonated } from "google-auth-library"; import Long from "long"; import { PromisePoolExecutor } from "promise-pool-executor"; @@ -17,7 +18,9 @@ import { coerceAsError } from "df/common/errors/errors"; import { retry } from "df/common/promises"; import { dataform } from "df/protos/ts"; +const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; const EXTRA_GOOGLE_SCOPES = ["https://www.googleapis.com/auth/drive"]; +const IMPERSONATION_GOOGLE_SCOPES = [GOOGLE_CLOUD_PLATFORM_SCOPE, ...EXTRA_GOOGLE_SCOPES]; const BIGQUERY_DATE_RELATED_FIELDS = [ "BigQueryDate", @@ -37,24 +40,42 @@ export interface IBigQueryExecutionOptions { reservation?: string; } -export type BigQueryClientProvider = (projectId?: string) => BigQuery; +export type BigQueryClientProvider = (projectId?: string) => BigQuery | Promise; export function createBigQueryClientProvider( credentials: dataform.IBigQuery ): BigQueryClientProvider { const clients = new Map(); - return (projectId?: string) => { + return async (projectId?: string) => { projectId = projectId || credentials.projectId; if (!clients.has(projectId)) { - clients.set( + const clientConfig: BigQueryOptions = { projectId, - new BigQuery({ + location: credentials.location + }; + + if (credentials.impersonateServiceAccount) { + // Impersonation requires cloud-platform for the source and target credentials, while the + // Drive scope remains necessary for BigQuery external tables backed by Google Drive. + const sourceAuth = new GoogleAuth({ projectId, - scopes: EXTRA_GOOGLE_SCOPES, - location: credentials.location, + scopes: IMPERSONATION_GOOGLE_SCOPES, credentials: credentials.credentials && JSON.parse(credentials.credentials) - }) - ); + }); + + const authClient = await sourceAuth.getClient(); + + clientConfig.authClient = new Impersonated({ + sourceClient: authClient, + targetPrincipal: credentials.impersonateServiceAccount, + targetScopes: IMPERSONATION_GOOGLE_SCOPES + }); + } else { + clientConfig.scopes = EXTRA_GOOGLE_SCOPES; + clientConfig.credentials = credentials.credentials && JSON.parse(credentials.credentials); + } + + clients.set(projectId, new BigQuery(clientConfig)); } return clients.get(projectId); }; @@ -143,7 +164,7 @@ export class BigQueryDbAdapter implements IDbAdapter { return this.pool .addSingleTask({ generator: async () => { - const [rows, , apiResponse] = await this.getClient().query({ + const [rows, , apiResponse] = await (await this.getClient()).query({ ...this.prepareQueryOptions(statement, options.rowLimit, options.bigquery, options.params), skipParsing: true } as any); @@ -162,8 +183,8 @@ export class BigQueryDbAdapter implements IDbAdapter { try { await this.pool .addSingleTask({ - generator: () => - this.getClient().query({ + generator: async () => + (await this.getClient()).query({ useLegacySql: false, query, dryRun: true @@ -189,11 +210,12 @@ export class BigQueryDbAdapter implements IDbAdapter { public async tables(database: string, schema?: string): Promise { const datasetIds = schema ? [schema] : await this.schemas(database); + const client = await this.getClient(database); const tablesMetadata: dataform.ITableMetadata[] = []; await Promise.all( datasetIds.map(async datasetId => { - const [tables] = await this.getClient(database) + const [tables] = await client .dataset(datasetId) .getTables({ autoPaginate: true, maxResults: 1000 }); await Promise.all( @@ -286,14 +308,17 @@ export class BigQueryDbAdapter implements IDbAdapter { } public async deleteTable(target: dataform.ITarget): Promise { - await this.getClient(target.database) + await (await this.getClient(target.database)) .dataset(target.schema) .table(target.name) .delete({ ignoreNotFound: true }); } public async schemas(database: string): Promise { - const data = await this.getClient(database).getDatasets({ autoPaginate: true, maxResults: 1000 }); + const data = await (await this.getClient(database)).getDatasets({ + autoPaginate: true, + maxResults: 1000 + }); return data[0].map(dataset => dataset.id); } @@ -313,7 +338,7 @@ export class BigQueryDbAdapter implements IDbAdapter { metadata.schema.fields ); - await this.getClient(target.database) + await (await this.getClient(target.database)) .dataset(target.schema) .table(target.name) .setMetadata({ @@ -325,7 +350,7 @@ export class BigQueryDbAdapter implements IDbAdapter { private async getMetadata(target: dataform.ITarget): Promise { try { - const table = await this.getClient(target.database) + const table = await (await this.getClient(target.database)) .dataset(target.schema) .table(target.name) .getMetadata(); @@ -340,8 +365,8 @@ export class BigQueryDbAdapter implements IDbAdapter { } } - private getClient(projectId?: string) { - return this.clientProvider(projectId); + private async getClient(projectId?: string) { + return await this.clientProvider(projectId); } private async runQuery( @@ -351,12 +376,13 @@ export class BigQueryDbAdapter implements IDbAdapter { byteLimit?: number, location?: string ): Promise { + const client = await this.getClient(); const results = await new Promise((resolve, reject) => { const allRows = new LimitedResultSet({ rowLimit, byteLimit }); - const stream = this.getClient().createQueryStream({ + const stream = client.createQueryStream({ query, params, location @@ -412,7 +438,8 @@ export class BigQueryDbAdapter implements IDbAdapter { return retry( async () => { try { - const job = await this.getClient().createQueryJob( + const client = await this.getClient(); + const job = await client.createQueryJob( this.prepareQueryOptions( query, rowLimit, diff --git a/cli/api/dbadapters/bigquery_test.ts b/cli/api/dbadapters/bigquery_test.ts index 6659941e8..92462d096 100644 --- a/cli/api/dbadapters/bigquery_test.ts +++ b/cli/api/dbadapters/bigquery_test.ts @@ -1,5 +1,6 @@ import { Dataset, Table } from "@google-cloud/bigquery"; import { expect } from "chai"; +import { Readable } from "stream"; import { anything, instance, mock, verify, when } from "ts-mockito"; import { BigQueryDbAdapter } from "df/cli/api/dbadapters/bigquery"; @@ -9,6 +10,8 @@ import { suite, test } from "df/testing"; suite("BigQueryDbAdapter", () => { test("tables() with schema filters correctly", async () => { const mockBigQuery = mock(); + // ts-mockito invents a then() method, which prevents await from resolving this client. + when(mockBigQuery.then).thenReturn(undefined); const mockDataset = mock(); const mockTable = mock(); @@ -49,6 +52,7 @@ suite("BigQueryDbAdapter", () => { test("tables() without schema lists all datasets and tables", async () => { const mockBigQuery = mock(); + when(mockBigQuery.then).thenReturn(undefined); const mockDataset = mock(); const mockTable = mock
(); const schemaName = "schema1"; @@ -84,6 +88,41 @@ suite("BigQueryDbAdapter", () => { expect(result[0].target.name).to.equal(tableName); }); + for (const asynchronous of [false, true]) { + test(`interactive queries accept ${asynchronous ? "asynchronous" : "synchronous"} clients`, async () => { + const client = { + createQueryStream: () => Readable.from([{ id: 1 }]) + } as any; + const adapter = new BigQueryDbAdapter({ projectId: "project" }, { + clientProvider: () => asynchronous ? Promise.resolve(client) : client + }); + + const result = await adapter.execute("SELECT 1 AS id", { interactive: true }); + expect(result.rows).deep.equals([{ id: 1 }]); + }); + } + + for (const failure of ["client initialization", "stream creation"]) { + test(`interactive queries reject on ${failure} failure`, async () => { + const expectedError = new Error(failure); + const adapter = new BigQueryDbAdapter({ projectId: "project" }, { + clientProvider: async () => { + if (failure === "client initialization") { + throw expectedError; + } + return { + createQueryStream: () => { throw expectedError; } + } as any; + } + }); + + await adapter.execute("SELECT 1", { interactive: true }).then( + () => { throw new Error("Expected the query to reject"); }, + error => expect(error).equals(expectedError) + ); + }); + } + test("setMetadata handles action without columns", async () => { // Partial mock for BigQuery client to avoid real network calls const mockBigQuery: any = { diff --git a/cli/commands/run_command.ts b/cli/commands/run_command.ts index 643be8dc0..c596f5ab0 100644 --- a/cli/commands/run_command.ts +++ b/cli/commands/run_command.ts @@ -93,6 +93,9 @@ export const runCommand: ICommand = { const readCredentials = credentials.read( actuallyResolve(argv.projectDir, argv.credentials) ); + if (argv.impersonateServiceAccount) { + readCredentials.impersonateServiceAccount = argv.impersonateServiceAccount; + } const dbadapter = new BigQueryDbAdapter(readCredentials); const executionGraph = await build( diff --git a/cli/commands/run_options.ts b/cli/commands/run_options.ts index d042623fb..6ac6cb4be 100644 --- a/cli/commands/run_options.ts +++ b/cli/commands/run_options.ts @@ -6,6 +6,8 @@ import { credentialsOption, IActionsArgs, ICredentialsArgs, + IImpersonateServiceAccountArgs, + impersonateServiceAccountOption, IJsonOutputArgs, IProjectDirArgs, ITimeoutArgs, @@ -21,6 +23,7 @@ export interface IRunArgs extends IProjectDirArgs, IProjectConfigArgs, ICredentialsArgs, + IImpersonateServiceAccountArgs, IActionsArgs, IJsonOutputArgs, ITimeoutArgs { @@ -173,6 +176,7 @@ export const runOptions: Array> = [ actionRetryLimitOption, actionsOption, credentialsOption, + impersonateServiceAccountOption, emitLineageOption, fullRefreshOption, includeDepsOption, diff --git a/cli/commands/test_command.ts b/cli/commands/test_command.ts index be9fcb6ce..59486e829 100644 --- a/cli/commands/test_command.ts +++ b/cli/commands/test_command.ts @@ -5,6 +5,8 @@ import { assertProjectDirExists, credentialsOption, ICredentialsArgs, + IImpersonateServiceAccountArgs, + impersonateServiceAccountOption, IJsonOutputArgs, IProjectDirArgs, ITimeoutArgs, @@ -26,6 +28,7 @@ import { ICommand } from "df/cli/yargswrapper"; export interface ITestArgs extends IProjectDirArgs, ICredentialsArgs, + IImpersonateServiceAccountArgs, ITimeoutArgs, IJsonOutputArgs, IProjectConfigArgs {} @@ -37,6 +40,7 @@ export const testCommand: ICommand = { check: [assertProjectDirExists], options: [ credentialsOption, + impersonateServiceAccountOption, timeoutOption, jsonOutputOption, ...ProjectConfigOptions.allYargsOptions @@ -60,6 +64,9 @@ export const testCommand: ICommand = { const readCredentials = credentials.read( actuallyResolve(argv.projectDir, argv.credentials) ); + if (argv.impersonateServiceAccount) { + readCredentials.impersonateServiceAccount = argv.impersonateServiceAccount; + } if (!compiledGraph.tests.length) { printError("No unit tests found."); diff --git a/cli/common_options.ts b/cli/common_options.ts index 9ebf58875..76e96e47b 100644 --- a/cli/common_options.ts +++ b/cli/common_options.ts @@ -1,10 +1,9 @@ import * as fs from "fs"; -import parseDuration from "parse-duration"; import * as path from "path"; import yargs from "yargs"; import { CREDENTIALS_FILENAME } from "df/cli/api/commands/credentials"; -import { actuallyResolve, assertPathExists } from "df/cli/util"; +import { actuallyResolve, assertPathExists, parseCliDuration } from "df/cli/util"; import { INamedOption } from "df/cli/yargswrapper"; export interface IProjectDirArgs { @@ -63,6 +62,18 @@ export const credentialsOption: INamedOption = actuallyResolve(argv.projectDir, argv.credentials) }; +export interface IImpersonateServiceAccountArgs { + impersonateServiceAccount?: string; +} + +export const impersonateServiceAccountOption: INamedOption = { + name: "impersonate-service-account", + option: { + describe: "Service account email to impersonate during authentication.", + type: "string" + } +}; + export interface IJsonOutputArgs { json: boolean; } @@ -77,7 +88,7 @@ export const jsonOutputOption: INamedOption = { }; export const coerceTimeout = (rawTimeoutString: string | null) => - rawTimeoutString ? parseDuration(rawTimeoutString) : null; + rawTimeoutString ? parseCliDuration(rawTimeoutString) : null; export interface ITimeoutArgs { timeout: number | null; diff --git a/cli/index_project_test.ts b/cli/index_project_test.ts index c53814da6..3141a4243 100644 --- a/cli/index_project_test.ts +++ b/cli/index_project_test.ts @@ -1,5 +1,7 @@ import { expect } from "chai"; import * as fs from "fs-extra"; +import { createServer } from "http"; +import { AddressInfo } from "net"; import * as path from "path"; import { @@ -12,6 +14,39 @@ import { TmpDirFixture } from "df/testing/fixtures"; suite("project ops", ({ afterEach }) => { const tmpDirFixture = new TmpDirFixture(afterEach); + test("project setup installs the local core package without registry requests", async () => { + const requests: string[] = []; + const registry = createServer((request, response) => { + requests.push(request.url); + response.writeHead(503); + response.end(); + }); + await new Promise(resolve => registry.listen(0, "127.0.0.1", resolve)); + const previousRegistry = process.env.npm_config_registry; + const previousRetries = process.env.npm_config_fetch_retries; + try { + process.env.npm_config_registry = `http://127.0.0.1:${(registry.address() as AddressInfo).port}`; + process.env.npm_config_fetch_retries = "0"; + const projectDir = tmpDirFixture.createNewTmpDir(); + await setupProject(tmpDirFixture, projectDir); + + expect(fs.existsSync(path.join(projectDir, "node_modules/@dataform/core/bundle.js"))).equals(true); + expect(requests).deep.equals([]); + } finally { + if (previousRegistry === undefined) { + delete process.env.npm_config_registry; + } else { + process.env.npm_config_registry = previousRegistry; + } + if (previousRetries === undefined) { + delete process.env.npm_config_fetch_retries; + } else { + process.env.npm_config_fetch_retries = previousRetries; + } + await new Promise(resolve => registry.close(() => resolve())); + } + }); + suite("install command", () => { test("install throws an error when dataformCoreVersion in workflow_settings.yaml", async () => { const projectDir = tmpDirFixture.createNewTmpDir(); diff --git a/cli/index_test_base.ts b/cli/index_test_base.ts index 5845c5723..750acfa60 100644 --- a/cli/index_test_base.ts +++ b/cli/index_test_base.ts @@ -88,9 +88,16 @@ export async function setupProject( } }` ); - await getProcessResult( + const installResult = await getProcessResult( execFile(npmPath, [ "install", + // Core is bundled with no runtime dependencies. Keep fixture setup independent of npm + // registry availability, including the audit and update checks performed by npm install. + "--offline", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--update-notifier=false", "--prefix", projectDir, "--cache", @@ -98,6 +105,9 @@ export async function setupProject( corePackageTarPath ]) ); + if (installResult.exitCode !== 0) { + throw new Error(`Failed to install the local core package: ${installResult.stderr}`); + } return projectDir; } diff --git a/cli/util.ts b/cli/util.ts index dcd77b37b..07601dc8b 100644 --- a/cli/util.ts +++ b/cli/util.ts @@ -52,6 +52,146 @@ export function formatBytesInHumanReadableFormat(bytes: number): string { return `${value} ${units[i]}`; } +const DURATION_UNITS_IN_MILLIS: { [unit: string]: number } = { + ms: 1, + msec: 1, + msecs: 1, + millisecond: 1, + milliseconds: 1, + s: 1000, + sec: 1000, + secs: 1000, + second: 1000, + seconds: 1000, + m: 60 * 1000, + min: 60 * 1000, + mins: 60 * 1000, + minute: 60 * 1000, + minutes: 60 * 1000, + h: 60 * 60 * 1000, + hr: 60 * 60 * 1000, + hrs: 60 * 60 * 1000, + hour: 60 * 60 * 1000, + hours: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + days: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + weeks: 7 * 24 * 60 * 60 * 1000 +}; + +// Security-fixed parse-duration releases (2.1.3 and later) are ESM-only, but this Bazel +// TypeScript target emits CommonJS for unbundled CLI and test execution. Keep this parser as a +// linear scanner so those entrypoints remain compatible without reintroducing CVE-2025-25283's +// unsafe regular-expression behavior. +export function parseCliDuration(rawDuration: string): number { + const normalizedDuration = rawDuration?.trim().toLowerCase(); + if (!normalizedDuration) { + throw new Error("Duration cannot be empty."); + } + + if (isCliDurationNumber(normalizedDuration)) { + return Number(normalizedDuration); + } + + let totalDurationMillis = 0; + let matchFound = false; + let cursor = 0; + + while (cursor < normalizedDuration.length) { + while (normalizedDuration[cursor] === " ") { + cursor++; + } + if (cursor >= normalizedDuration.length) { + break; + } + + const numberStart = cursor; + if (normalizedDuration[cursor] === "+" || normalizedDuration[cursor] === "-") { + cursor++; + } + + const integerStart = cursor; + while (isAsciiDigit(normalizedDuration[cursor])) { + cursor++; + } + let digitCount = cursor - integerStart; + + if (normalizedDuration[cursor] === ".") { + cursor++; + const fractionStart = cursor; + while (isAsciiDigit(normalizedDuration[cursor])) { + cursor++; + } + digitCount += cursor - fractionStart; + } + if (digitCount === 0) { + throw new Error(`Invalid duration: ${rawDuration}`); + } + + while (normalizedDuration[cursor] === " ") { + cursor++; + } + + const unitStart = cursor; + while (isAsciiLetter(normalizedDuration[cursor])) { + cursor++; + } + if (cursor === unitStart) { + throw new Error(`Invalid duration: ${rawDuration}`); + } + + const durationValue = Number(normalizedDuration.slice(numberStart, unitStart).trim()); + const durationUnit = normalizedDuration.slice(unitStart, cursor); + const unitMillis = DURATION_UNITS_IN_MILLIS[durationUnit]; + if (unitMillis === undefined) { + throw new Error(`Unsupported duration unit: ${durationUnit}`); + } + + totalDurationMillis += durationValue * unitMillis; + matchFound = true; + } + + if (!matchFound) { + throw new Error(`Invalid duration: ${rawDuration}`); + } + + return totalDurationMillis; +} + +function isCliDurationNumber(value: string): boolean { + let cursor = 0; + if (value[cursor] === "+" || value[cursor] === "-") { + cursor++; + } + + const integerStart = cursor; + while (isAsciiDigit(value[cursor])) { + cursor++; + } + let digitCount = cursor - integerStart; + + if (value[cursor] === ".") { + cursor++; + const fractionStart = cursor; + while (isAsciiDigit(value[cursor])) { + cursor++; + } + digitCount += cursor - fractionStart; + } + + return digitCount > 0 && cursor === value.length; +} + +function isAsciiDigit(value: string): boolean { + return value >= "0" && value <= "9"; +} + +function isAsciiLetter(value: string): boolean { + return value >= "a" && value <= "z"; +} + /** * Handles prompting and validation for defaultBucketName, defaultTableFolderRoot * and defaultTableFolderSubpath if the user provides the --iceberg flag when diff --git a/cli/util_test.ts b/cli/util_test.ts index b86f89370..f4c9e9cb9 100644 --- a/cli/util_test.ts +++ b/cli/util_test.ts @@ -3,6 +3,7 @@ import { expect } from "chai"; import { formatBytesInHumanReadableFormat, formatExecutionSuffix, + parseCliDuration, validateIcebergConfigBucketName, validateIcebergConfigTableFolderRoot, validateIcebergConfigTableFolderSubpath, @@ -35,6 +36,50 @@ suite('format bytes in human readable format', () => { }); }); +suite("parse cli duration", () => { + test("parses numeric durations as milliseconds", () => { + expect(parseCliDuration("1500")).equals(1500); + }); + + test("parses single-unit durations", () => { + expect(parseCliDuration("1s")).equals(1000); + expect(parseCliDuration("10m")).equals(600000); + expect(parseCliDuration("2 hours")).equals(7200000); + }); + + test("parses compound and fractional durations", () => { + expect(parseCliDuration("1h30m")).equals(5400000); + expect(parseCliDuration("1.5m")).equals(90000); + expect(parseCliDuration("1 week 2 days")).equals(777600000); + }); + + for (const [input, expected] of [ + [".5s", 500], + ["5.s", 5000], + ["-.5s", -500], + ["+5.s", 5000], + ["1m .5s", 60500], + ["1m5.s", 65000], + [".5", 0.5], + ["5.", 5], + ["-.5", -0.5], + ["+5.", 5] + ] as Array<[string, number]>) { + test(`parses decimal duration ${input}`, () => { + expect(parseCliDuration(input)).equals(expected); + }); + } + + test("rejects invalid durations", () => { + expect(() => parseCliDuration("")).to.throw("Duration cannot be empty."); + expect(() => parseCliDuration("tomorrow")).to.throw("Invalid duration: tomorrow"); + expect(() => parseCliDuration("1fortnight")).to.throw("Unsupported duration unit: fortnight"); + for (const input of [".", ".s", "+.s", "-.s", "5..s"]) { + expect(() => parseCliDuration(input)).to.throw(`Invalid duration: ${input}`); + } + }); +}); + suite('Iceberg Config Validation', () => { suite('validateIcebergConfigBucketName', () => { test('valid bucket names do not throw errors', () => { diff --git a/package.json b/package.json index 5582ee7a1..502d8e0c5 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "estraverse": "^5.1.0", "fs-extra": "^9.0.0", "glob": "13.0.6", + "google-auth-library": "^10.0.0-rc.1", "google-sql-syntax-ts": "^1.0.3", "js-beautify": "^1.10.2", "js-yaml": "^4.2.0", @@ -48,7 +49,6 @@ "minimist": "^1.2.6", "moo": "^0.5.0", "object-sizeof": "^1.6.1", - "parse-duration": "^1.0.0", "prettier": "^1.14.2", "promise-pool-executor": "^1.1.1", "protobufjs": "^7.6.5", diff --git a/packages/@dataform/cli/BUILD b/packages/@dataform/cli/BUILD index 31309c581..367add25e 100644 --- a/packages/@dataform/cli/BUILD +++ b/packages/@dataform/cli/BUILD @@ -37,12 +37,12 @@ externals = [ "deepmerge", "fs-extra", "glob", + "google-auth-library", "google-sql-syntax-ts", "js-beautify", "js-yaml", "moo", "object-sizeof", - "parse-duration", "promise-pool-executor", "protobufjs", "readline-sync", diff --git a/protos/profiles.proto b/protos/profiles.proto index 09eabcdb8..37b982ac9 100644 --- a/protos/profiles.proto +++ b/protos/profiles.proto @@ -11,6 +11,8 @@ message BigQuery { string credentials = 3; // Options are listed here: https://cloud.google.com/bigquery/docs/locations string location = 4; + // Service account email to impersonate during authentication + string impersonate_service_account = 5; reserved 2; } diff --git a/yarn.lock b/yarn.lock index ee7a9c3cb..d6c7ea5aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3772,11 +3772,6 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-duration@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-1.0.0.tgz#8605651745f61088f6fb14045c887526c291858c" - integrity "sha1-hgVlF0X2EIj2+xQEXIh1JsKRhYw= sha512-X4kUkCTHU1N/kEbwK9FpUJ0UZQa90VzeczfS704frR30gljxDG0pSziws06XlK+CGRSo/1wtG1mFIdBFQTMQNw==" - parse-semver@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/parse-semver/-/parse-semver-1.1.1.tgz#9a4afd6df063dc4826f93fba4a99cf223f666cb8"