Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App Hosting Emulator: Init #7937

Merged
merged 25 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3aedc0e
add rootDirectory param
mathu97 Nov 5, 2024
e345476
progress
mathu97 Nov 5, 2024
f55c85d
working monorepo stuff
mathu97 Nov 7, 2024
702eb25
fixup tests
mathu97 Nov 7, 2024
20a12d5
update schema
mathu97 Nov 7, 2024
9316e66
add more tests
mathu97 Nov 7, 2024
55b11d8
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 7, 2024
085dfb5
fix bugs
mathu97 Nov 12, 2024
9a97d7d
cleanup
mathu97 Nov 12, 2024
fc98aeb
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 12, 2024
0668d91
address comments
mathu97 Nov 13, 2024
7db1c00
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 13, 2024
098ac41
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 14, 2024
9a415a6
init
mathu97 Nov 12, 2024
5beeb43
initial scaffolding poc for additional initialization prompts for emu…
mathu97 Nov 13, 2024
d0e2cba
progress
mathu97 Nov 14, 2024
88dd428
add config export to init
mathu97 Nov 15, 2024
db30a48
fix tests
mathu97 Nov 17, 2024
51debb0
progress
mathu97 Nov 18, 2024
8b148c7
fix merge conflicts
mathu97 Nov 19, 2024
bf02073
address comments
mathu97 Nov 19, 2024
39b59ba
prompt user whether they want to export secrets
mathu97 Nov 19, 2024
5f397d6
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 19, 2024
790efc7
fix
mathu97 Nov 20, 2024
af61c4b
Merge remote-tracking branch 'origin/master' into apphosting-emulator…
mathu97 Nov 20, 2024
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
144 changes: 138 additions & 6 deletions src/apphosting/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolve, join, dirname } from "path";
import { resolve, join, dirname, basename } from "path";
import { writeFileSync } from "fs";
import * as yaml from "yaml";

Expand All @@ -7,6 +7,11 @@
import * as prompt from "../prompt";
import * as dialogs from "./secrets/dialogs";
import { AppHostingYamlConfig } from "./yaml";
import { FirebaseError } from "../error";
import { promptForAppHostingYaml } from "./utils";
import { fetchSecrets } from "./secrets";
import { logger } from "../logger";
import { getOrPromptProject } from "../management/projects";

export const APPHOSTING_BASE_YAML_FILE = "apphosting.yaml";
export const APPHOSTING_LOCAL_YAML_FILE = "apphosting.local.yaml";
Expand Down Expand Up @@ -37,6 +42,9 @@
env?: Env[];
}

const SECRET_CONFIG = "Secret";
const EXPORTABLE_CONFIG = [SECRET_CONFIG];

/**
* Returns the absolute path for an app hosting backend root.
*
Expand Down Expand Up @@ -65,8 +73,8 @@

/**
* Returns paths of apphosting config files in the given path
* */
export function listAppHostingFilesInPath(path: string) {
*/
export function listAppHostingFilesInPath(path: string): string[] {
return fs
.listFiles(path)
.filter((file) => APPHOSTING_YAML_FILE_REGEX.test(file))
Expand Down Expand Up @@ -172,15 +180,79 @@
dynamicDispatch.store(path, projectYaml);
}

/**
* Reads userGivenConfigFile and exports the secrets defined in that file by
* hitting Google Secret Manager. The secrets are written in plain text to an
* apphosting.local.yaml file as environment variables.
*
* If userGivenConfigFile is not given, user is prompted to select one of the
* discovered app hosting yaml files.
*/
export async function exportConfig(
cwd: string,
backendRoot: string,
projectId?: string,
userGivenConfigFile?: string,
): Promise<void> {
const choices = await prompt.prompt({}, [

Check warning on line 197 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
{
type: "checkbox",
name: "configurations",
message: "What configs would you like to export?",
choices: EXPORTABLE_CONFIG,
},
]);

/**
* TODO: Update when supporting additional configurations. Currently only
* Secrets are exportable.
*/
if (!choices.configurations.includes(SECRET_CONFIG)) {

Check warning on line 210 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .configurations on an `any` value

Check warning on line 210 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value
logger.info("No configs selected to export");
return;
}

if (!projectId) {
const project = await getOrPromptProject({});
projectId = project.projectId;
}

let localAppHostingConfig: AppHostingYamlConfig = AppHostingYamlConfig.empty();

const localAppHostingConfigPath = resolve(backendRoot, APPHOSTING_LOCAL_YAML_FILE);
if (fs.fileExistsSync(localAppHostingConfigPath)) {
localAppHostingConfig = await AppHostingYamlConfig.loadFromFile(localAppHostingConfigPath);
}

const configToExport = await loadConfigToExportSecrets(cwd, userGivenConfigFile);
const secretsToExport = configToExport.secrets;
if (!secretsToExport) {
logger.info("No secrets found to export in the chosen App Hosting config files");
return;
}

const secretMaterial = await fetchSecrets(projectId, secretsToExport);
mathu97 marked this conversation as resolved.
Show resolved Hide resolved
for (const [key, value] of secretMaterial) {
localAppHostingConfig.addEnvironmentVariable({
variable: key,
value: value,
availability: ["RUNTIME"],
});
}

// update apphosting.local.yaml
await localAppHostingConfig.upsertFile(localAppHostingConfigPath);
logger.info(`Wrote secrets as environment variables to ${APPHOSTING_LOCAL_YAML_FILE}.`);
mathu97 marked this conversation as resolved.
Show resolved Hide resolved
}

mathu97 marked this conversation as resolved.
Show resolved Hide resolved
/**
* Given apphosting yaml config paths this function returns the
* appropriate combined configuration.
*
* Environment specific config (i.e apphosting.<environment>.yaml) will
* take precedence over the base config (apphosting.yaml).
*
* @param envYamlPath: Example: "/home/my-project/apphosting.staging.yaml"
* @param baseYamlPath: Example: "/home/my-project/apphosting.yaml"
* @param envYamlPath Example: "/home/my-project/apphosting.staging.yaml"
* @param baseYamlPath Example: "/home/my-project/apphosting.yaml"
*/
export async function loadConfigForEnvironment(
envYamlPath: string,
Expand All @@ -199,3 +271,63 @@

return envYamlConfig;
}

/**
* Returns the appropriate App Hosting YAML configuration for exporting secrets.
* @return The final merged config
*/
export async function loadConfigToExportSecrets(
cwd: string,
userGivenConfigFile?: string,
): Promise<AppHostingYamlConfig> {
if (userGivenConfigFile && !APPHOSTING_YAML_FILE_REGEX.test(userGivenConfigFile)) {
throw new FirebaseError(
"Invalid apphosting yaml config file provided. File must be in format: 'apphosting.yaml' or 'apphosting.<environment>.yaml'",
);
}

const allConfigs = getValidConfigs(cwd);
let userGivenConfigFilePath: string;
if (userGivenConfigFile) {
if (!allConfigs.has(userGivenConfigFile)) {
throw new FirebaseError(
`The provided app hosting config file "${userGivenConfigFile}" does not exist`,
);
}

userGivenConfigFilePath = allConfigs.get(userGivenConfigFile)!;

Check warning on line 298 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
} else {
userGivenConfigFilePath = await promptForAppHostingYaml(
allConfigs,
"Which environment would you like to export secrets from Secret Manager for?",
);
}
mathu97 marked this conversation as resolved.
Show resolved Hide resolved

if (userGivenConfigFile === APPHOSTING_BASE_YAML_FILE) {
return AppHostingYamlConfig.loadFromFile(allConfigs.get(APPHOSTING_BASE_YAML_FILE)!);

Check warning on line 307 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

const baseFilePath = allConfigs.get(APPHOSTING_BASE_YAML_FILE)!;

Check warning on line 310 in src/apphosting/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
return await loadConfigForEnvironment(userGivenConfigFilePath, baseFilePath);
}

/**
* Gets all apphosting yaml configs excluding apphosting.local.yaml and returns
* a map in the format {"apphosting.staging.yaml" => "/cwd/apphosting.staging.yaml"}.
*/
function getValidConfigs(cwd: string): Map<string, string> {
const appHostingConfigPaths = listAppHostingFilesInPath(cwd).filter(
(path) => !path.endsWith(APPHOSTING_LOCAL_YAML_FILE),
);
if (appHostingConfigPaths.length === 0) {
throw new FirebaseError("No apphosting.*.yaml configs found");
}

const fileNameToPathMap: Map<string, string> = new Map();
for (const path of appHostingConfigPaths) {
const fileName = basename(path);
fileNameToPathMap.set(fileName, path);
}

return fileNameToPathMap;
}
45 changes: 0 additions & 45 deletions src/apphosting/secrets/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@
import * as gcsmImport from "../../gcp/secretManager";
import * as utilsImport from "../../utils";
import * as promptImport from "../../prompt";
import * as configImport from "../config";

import { Secret } from "../yaml";
import { FirebaseError } from "../../error";

describe("secrets", () => {
let gcsm: sinon.SinonStubbedInstance<typeof gcsmImport>;
Expand All @@ -37,7 +35,7 @@
it("uses explicit account", () => {
const backend = {
serviceAccount: "sa",
} as any as apphosting.Backend;

Check warning on line 38 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
expect(secrets.serviceAccountsForBackend("number", backend)).to.deep.equal({
buildServiceAccount: "sa",
runServiceAccount: "sa",
Expand All @@ -45,7 +43,7 @@
});

it("has a fallback for legacy SAs", () => {
const backend = {} as any as apphosting.Backend;

Check warning on line 46 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
expect(secrets.serviceAccountsForBackend("number", backend)).to.deep.equal({
buildServiceAccount: gcb.getDefaultServiceAccount("number"),
runServiceAccount: gce.getDefaultServiceAccount("number"),
Expand Down Expand Up @@ -297,47 +295,4 @@
);
});
});

describe("loadConfigToExport", () => {
let apphostingConfigs: sinon.SinonStubbedInstance<typeof configImport>;

const baseYamlPath = "/parent/cwd/apphosting.yaml";

beforeEach(() => {
apphostingConfigs = sinon.stub(configImport);
apphostingConfigs.listAppHostingFilesInPath.returns([
baseYamlPath,
"/parent/apphosting.staging.yaml",
]);
});

afterEach(() => {
sinon.verifyAndRestore();
});

it("returns throws an error if an invalid apphosting yaml if provided", async () => {
await expect(secrets.loadConfigToExport("/parent/cwd", "blah.txt")).to.be.rejectedWith(
FirebaseError,
"Invalid apphosting yaml config file provided. File must be in format: 'apphosting.yaml' or 'apphosting.<environment>.yaml'",
);
});

it("does not prompt user if an userGivenConfigFile is provided", async () => {
await secrets.loadConfigToExport("/parent/cwd", "apphosting.staging.yaml");
expect(prompt.promptOnce).to.not.be.called;
});

it("prompts user if userGivenConfigFile not provided", async () => {
await secrets.loadConfigToExport("/parent/cwd");
expect(prompt.promptOnce).to.be.called;
});

it("should throw an error if userGivenConfigFile could not be found", async () => {
await expect(
secrets.loadConfigToExport("/parent/cwd", "apphosting.preview.yaml"),
).to.be.rejectedWith(
'The provided app hosting config file "apphosting.preview.yaml" does not exist',
);
});
});
});
71 changes: 1 addition & 70 deletions src/apphosting/secrets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,7 @@
import { isFunctionsManaged } from "../../gcp/secretManager";
import * as utils from "../../utils";
import * as prompt from "../../prompt";
import { basename } from "path";
import {
APPHOSTING_BASE_YAML_FILE,
APPHOSTING_LOCAL_YAML_FILE,
APPHOSTING_YAML_FILE_REGEX,
listAppHostingFilesInPath,
loadConfigForEnvironment,
} from "../config";
import { promptForAppHostingYaml } from "../utils";
import { AppHostingYamlConfig, Secret } from "../yaml";
import { Secret } from "../yaml";

/** Interface for holding the service account pair for a given Backend. */
export interface ServiceAccounts {
Expand Down Expand Up @@ -126,7 +117,7 @@
* If a secret exists, we verify the user is not trying to change the region and verifies a secret
* is not being used for both functions and app hosting as their garbage collection is incompatible
* (client vs server-side).
* @returns true if a secret was created, false if a secret already existed, and null if a user aborts.

Check warning on line 120 in src/apphosting/secrets/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
export async function upsertSecret(
project: string,
Expand Down Expand Up @@ -186,7 +177,7 @@

try {
const secretPromises: Promise<[string, string]>[] = secrets.map(async (secretConfig) => {
const [name, version] = getSecretNameParts(secretConfig.secret!);

Check warning on line 180 in src/apphosting/secrets/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion

const value = await gcsm.accessSecretVersion(projectId, name, version);
return [secretConfig.variable, value] as [string, string];
Expand All @@ -203,66 +194,6 @@
return secretsKeyValuePairs;
}

/**
* Returns the appropriate App Hosting YAML configuration for exporting secrets.
*
* @returns The final merged config
*/
export async function loadConfigToExport(
cwd: string,
userGivenConfigFile?: string,
): Promise<AppHostingYamlConfig> {
if (userGivenConfigFile && !APPHOSTING_YAML_FILE_REGEX.test(userGivenConfigFile)) {
throw new FirebaseError(
"Invalid apphosting yaml config file provided. File must be in format: 'apphosting.yaml' or 'apphosting.<environment>.yaml'",
);
}

const allConfigs = getValidConfigs(cwd);
let userGivenConfigFilePath: string;
if (userGivenConfigFile) {
if (!allConfigs.has(userGivenConfigFile)) {
throw new FirebaseError(
`The provided app hosting config file "${userGivenConfigFile}" does not exist`,
);
}

userGivenConfigFilePath = allConfigs.get(userGivenConfigFile)!;
} else {
userGivenConfigFilePath = await promptForAppHostingYaml(
allConfigs,
"Which environment would you like to export secrets from Secret Manager for?",
);
}

if (userGivenConfigFile === APPHOSTING_BASE_YAML_FILE) {
return AppHostingYamlConfig.loadFromFile(allConfigs.get(APPHOSTING_BASE_YAML_FILE)!);
}

const baseFilePath = allConfigs.get(APPHOSTING_BASE_YAML_FILE)!;
return await loadConfigForEnvironment(userGivenConfigFilePath, baseFilePath);
}

/**
* Gets all apphosting yaml configs excluding apphosting.local.yaml and returns
* a map in the format {"apphosting.staging.yaml" => "/cwd/apphosting.staging.yaml"}.
*/
function getValidConfigs(cwd: string): Map<string, string> {
const appHostingConfigPaths = listAppHostingFilesInPath(cwd).filter(
(path) => !path.endsWith(APPHOSTING_LOCAL_YAML_FILE),
);
if (appHostingConfigPaths.length === 0) {
throw new FirebaseError("No apphosting.*.yaml configs found");
}

const fileNameToPathMap: Map<string, string> = new Map();
for (const path of appHostingConfigPaths) {
const fileName = basename(path);
fileNameToPathMap.set(fileName, path);
}

return fileNameToPathMap;
}
/**
* secret expected to be in format "myApiKeySecret@5",
* "projects/test-project/secrets/secretID", or
Expand Down
34 changes: 2 additions & 32 deletions src/commands/apphosting-config-export.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { Command } from "../command";
import { logger } from "../logger";
import { Options } from "../options";
import { needProjectId } from "../projectUtils";
import { requireAuth } from "../requireAuth";
import * as secretManager from "../gcp/secretManager";
import { requirePermissions } from "../requirePermissions";
import { APPHOSTING_LOCAL_YAML_FILE, discoverBackendRoot } from "../apphosting/config";
import { fetchSecrets, loadConfigToExport } from "../apphosting/secrets";
import { resolve } from "path";
import * as fs from "../fsutils";
import { AppHostingYamlConfig } from "../apphosting/yaml";
import { discoverBackendRoot, exportConfig } from "../apphosting/config";
import { FirebaseError } from "../error";

export const command = new Command("apphosting:config:export")
Expand All @@ -28,37 +23,12 @@ export const command = new Command("apphosting:config:export")
const environmentConfigFile = options.secrets as string | undefined;
const cwd = process.cwd();

// Load apphosting.local.yaml file if it exists. Secrets should be added to the env list in this object and written back to the apphosting.local.yaml
let localAppHostingConfig: AppHostingYamlConfig = AppHostingYamlConfig.empty();
const backendRoot = discoverBackendRoot(cwd);
if (!backendRoot) {
throw new FirebaseError(
"Missing apphosting.yaml: This command requires an apphosting.yaml configuration file. Please run 'firebase init apphosting' and try again.",
);
}

const localAppHostingConfigPath = resolve(backendRoot, APPHOSTING_LOCAL_YAML_FILE);
if (fs.fileExistsSync(localAppHostingConfigPath)) {
localAppHostingConfig = await AppHostingYamlConfig.loadFromFile(localAppHostingConfigPath);
}

const configToExport = await loadConfigToExport(cwd, environmentConfigFile);
const secretsToExport = configToExport.secrets;
if (!secretsToExport) {
logger.warn("No secrets found to export in the chosen App Hosting config files");
return;
}

const secretMaterial = await fetchSecrets(projectId, secretsToExport);
for (const [key, value] of secretMaterial) {
localAppHostingConfig.addEnvironmentVariable({
variable: key,
value: value,
availability: ["RUNTIME"],
});
}

// update apphosting.local.yaml
localAppHostingConfig.upsertFile(localAppHostingConfigPath);
logger.info(`Wrote secrets as environment variables to ${APPHOSTING_LOCAL_YAML_FILE}.`);
await exportConfig(cwd, backendRoot, projectId, environmentConfigFile);
});
2 changes: 1 addition & 1 deletion src/emulator/apphosting/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "path";
import * as utils from "./utils";
import * as utils from "./developmentServer";

import * as sinon from "sinon";
import { expect } from "chai";
Expand Down
Loading
Loading