fix(gateway): anchor dotenv discovery to module (#1138)
This commit is contained in:
@@ -1,64 +1,210 @@
|
||||
import 'reflect-metadata';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface ComposedModuleGraph {
|
||||
imports: readonly unknown[];
|
||||
federationModule: unknown;
|
||||
bootLogLines: readonly string[];
|
||||
}
|
||||
|
||||
interface FileSnapshot {
|
||||
exists: boolean;
|
||||
contents: Buffer | null;
|
||||
}
|
||||
|
||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||
|
||||
interface ModuleGraphFixtureOptions {
|
||||
cwdPath: string;
|
||||
rootEnvMode?: 'present' | 'absent';
|
||||
rootTier?: StorageTier;
|
||||
rootEnvContents?: string;
|
||||
secret?: string;
|
||||
gatewayLocalTier?: StorageTier;
|
||||
gatewayLocalEnvContents?: string;
|
||||
daemonEnvContents?: string;
|
||||
inheritedTier?: StorageTier;
|
||||
expectedProcessTier?: StorageTier;
|
||||
setup?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 30_000;
|
||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||
const specDir = dirname(fileURLToPath(import.meta.url));
|
||||
const monorepoRootDir = resolve(specDir, '../../..');
|
||||
const gatewayDir = resolve(specDir, '..');
|
||||
const rootEnvPath = join(monorepoRootDir, '.env');
|
||||
const gatewayLocalEnvPath = join(gatewayDir, '.env');
|
||||
|
||||
function restoreEnvironmentVariable(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotFile(path: string): Promise<FileSnapshot> {
|
||||
if (!existsSync(path)) {
|
||||
return { exists: false, contents: null };
|
||||
}
|
||||
|
||||
return { exists: true, contents: await readFile(path) };
|
||||
}
|
||||
|
||||
async function restoreFile(path: string, snapshot: FileSnapshot): Promise<void> {
|
||||
if (!snapshot.exists) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[name] = value;
|
||||
await writeFile(path, snapshot.contents ?? Buffer.alloc(0));
|
||||
}
|
||||
|
||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||
expect(bootLogLines).toHaveLength(1);
|
||||
const [bootLogLine] = bootLogLines;
|
||||
if (bootLogLine === undefined) {
|
||||
throw new Error('Expected a single boot log line');
|
||||
}
|
||||
|
||||
return bootLogLine;
|
||||
}
|
||||
|
||||
function expectBootLogLine(
|
||||
bootLogLines: readonly string[],
|
||||
tier: StorageTier,
|
||||
source: string,
|
||||
): void {
|
||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||
|
||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||
expect(bootLogLine).toContain(`source=${source}`);
|
||||
}
|
||||
|
||||
async function loadModuleGraphFromDotenv(
|
||||
tier: 'local' | 'standalone' | 'federated',
|
||||
options: ModuleGraphFixtureOptions,
|
||||
): Promise<ComposedModuleGraph> {
|
||||
const originalHome = process.env['HOME'];
|
||||
const originalTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const originalDatabaseUrl = process.env['DATABASE_URL'];
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-module-'));
|
||||
const gatewayCwd = join(fixtureRoot, 'apps', 'gateway');
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const rootSnapshot = await snapshotFile(rootEnvPath);
|
||||
const gatewayLocalSnapshot = await snapshotFile(gatewayLocalEnvPath);
|
||||
const isolatedHome = await mkdtemp(join(tmpdir(), 'mosaic-gateway-home-'));
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
|
||||
await mkdir(gatewayCwd, { recursive: true });
|
||||
await writeFile(join(fixtureRoot, '.env'), `MOSAIC_STORAGE_TIER=${tier}\n`, 'utf8');
|
||||
|
||||
process.env['HOME'] = join(fixtureRoot, 'home');
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
vi.resetModules();
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(gatewayCwd);
|
||||
await mkdir(options.cwdPath, { recursive: true });
|
||||
|
||||
try {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||
await import('./env.js');
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier);
|
||||
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||
if (
|
||||
options.rootEnvContents !== undefined ||
|
||||
options.rootTier !== undefined ||
|
||||
options.secret !== undefined
|
||||
) {
|
||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||
}
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const { FederationModule } = await import('./federation/federation.module.js');
|
||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||
await rm(rootEnvPath, { force: true });
|
||||
} else {
|
||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||
throw new Error('Expected rootTier or rootEnvContents');
|
||||
}
|
||||
|
||||
if (!Array.isArray(imports)) {
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||
const rootFixtureWithSecret = options.secret
|
||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n`
|
||||
: rootFixture;
|
||||
|
||||
await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8');
|
||||
}
|
||||
|
||||
return { imports, federationModule: FederationModule };
|
||||
if (options.daemonEnvContents !== undefined) {
|
||||
const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env');
|
||||
await mkdir(dirname(daemonEnvPath), { recursive: true });
|
||||
await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8');
|
||||
}
|
||||
|
||||
if (options.gatewayLocalEnvContents !== undefined) {
|
||||
await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8');
|
||||
} else if (options.gatewayLocalTier !== undefined) {
|
||||
await writeFile(
|
||||
gatewayLocalEnvPath,
|
||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||
'utf8',
|
||||
);
|
||||
} else if (existsSync(gatewayLocalEnvPath)) {
|
||||
await rm(gatewayLocalEnvPath, { force: true });
|
||||
}
|
||||
|
||||
process.env['HOME'] = isolatedHome;
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
|
||||
await options.setup?.();
|
||||
|
||||
if (options.inheritedTier !== undefined) {
|
||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.cwdPath);
|
||||
|
||||
try {
|
||||
if (options.inheritedTier === undefined) {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||
} else {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||
}
|
||||
|
||||
await import('./env.js');
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||
options.expectedProcessTier ?? options.rootTier,
|
||||
);
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const { FederationModule } = await import('./federation/federation.module.js');
|
||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||
|
||||
if (!Array.isArray(imports)) {
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
}
|
||||
|
||||
return {
|
||||
imports,
|
||||
federationModule: FederationModule,
|
||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||
args.map((value: unknown): string => String(value)).join(' '),
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
restoreEnvironmentVariable('HOME', originalHome);
|
||||
restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier);
|
||||
restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl);
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
consoleInfoSpy.mockRestore();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await restoreFile(rootEnvPath, rootSnapshot);
|
||||
await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot);
|
||||
await rm(isolatedHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,22 +220,179 @@ describe('AppModule federation gating', (): void => {
|
||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||
});
|
||||
|
||||
it.each(['local', 'standalone'] as const)(
|
||||
'does not register FederationModule for the %s tier',
|
||||
async (tier): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv(tier);
|
||||
it(
|
||||
'ignores attacker-writable ambient cwd dotenv files',
|
||||
async (): Promise<void> => {
|
||||
const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-'));
|
||||
const ambientCwd = join(ambientRoot, 'sandbox', 'cwd');
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath: ambientCwd,
|
||||
rootTier: 'local',
|
||||
setup: async (): Promise<void> => {
|
||||
await writeFile(join(ambientCwd, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
|
||||
await writeFile(join(ambientRoot, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
} finally {
|
||||
await rm(ambientRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'registers FederationModule when federated tier is supplied only by a dotenv file',
|
||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv('federated');
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||
async (): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||
inheritedTier: 'standalone',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||
async (): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootEnvMode: 'absent',
|
||||
gatewayLocalTier: 'federated',
|
||||
expectedProcessTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'monorepo-root .env wins over gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootTier: 'standalone',
|
||||
gatewayLocalTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each(['local', 'standalone'] as const)(
|
||||
'does not register FederationModule for the %s tier',
|
||||
async (tier): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootTier: tier,
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||
async (): Promise<void> => {
|
||||
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
|
||||
|
||||
try {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
cwdPath,
|
||||
rootTier: 'federated',
|
||||
secret: 'super-secret-fixture-value',
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain('super-secret-fixture-value');
|
||||
} finally {
|
||||
await rm(cwdPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
+74
-8
@@ -1,14 +1,80 @@
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode).
|
||||
// It takes precedence over file-based local-dev configuration.
|
||||
type TierSource =
|
||||
| 'process environment'
|
||||
| 'daemon .env'
|
||||
| 'monorepo-root .env'
|
||||
| 'gateway-local .env'
|
||||
| 'default';
|
||||
|
||||
type BootSource = TierSource | 'mosaic.config.json';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||
const monorepoRootEnv = resolve(here, '../../..', '.env');
|
||||
const gatewayLocalEnv = resolve(here, '..', '.env');
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter),
|
||||
// then fill any remaining values from apps/gateway/.env when present.
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config();
|
||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||
let databaseUrlSource: TierSource =
|
||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||
|
||||
function loadAnchoredDotenv(
|
||||
path: string,
|
||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||
): void {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||
config({ path, quiet: true });
|
||||
|
||||
if (
|
||||
beforeTier === undefined &&
|
||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||
tierSource === 'default'
|
||||
) {
|
||||
tierSource = sourceLabel;
|
||||
}
|
||||
|
||||
if (
|
||||
beforeDatabaseUrl === undefined &&
|
||||
process.env['DATABASE_URL'] !== undefined &&
|
||||
databaseUrlSource === 'default'
|
||||
) {
|
||||
databaseUrlSource = sourceLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||
// It takes precedence over file-based local-dev configuration.
|
||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||
|
||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||
// from apps/gateway/.env when present.
|
||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||
|
||||
const envOnlyTier = detectFromEnv().tier;
|
||||
const resolvedTier = loadConfig().tier;
|
||||
|
||||
let source: BootSource;
|
||||
if (resolvedTier !== envOnlyTier) {
|
||||
source = 'mosaic.config.json';
|
||||
} else if (tierSource !== 'default') {
|
||||
source = tierSource;
|
||||
} else if (envOnlyTier === 'standalone' && databaseUrlSource !== 'default') {
|
||||
source = databaseUrlSource;
|
||||
} else {
|
||||
source = 'default';
|
||||
}
|
||||
|
||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||
|
||||
Reference in New Issue
Block a user