-
Notifications
You must be signed in to change notification settings - Fork 1k
Add support for deploying local source to App Hosting #8516
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
Changes from 11 commits
944ab1f
a0f00ff
1588252
f7be953
b5519ac
e344fae
68caf08
588b11f
8cc675f
219b901
cbe7ffe
908eb34
d771ce8
affa033
bc0cfbf
41ca746
f0eefcc
f84ff77
9fe4133
d019b57
93db1a6
e72954e
57b9d4c
23c3e2f
d6e37bc
611dc97
1526d3d
a999b7a
09bcac9
df7042d
f8cce50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,6 +30,7 @@ const FILTERABLE_TARGETS = new Set([ | |
"storage", | ||
"database", | ||
"dataconnect", | ||
"apphosting", | ||
]); | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,6 +23,7 @@ | |
"remoteconfig", | ||
"extensions", | ||
"dataconnect", | ||
"apphosting", | ||
]; | ||
export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does apphosting require any permissions we need to check for? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added calls with |
||
database: ["firebasedatabase.instances.update"], | ||
|
@@ -98,14 +99,14 @@ | |
) | ||
.before(requireConfig) | ||
.before((options) => { | ||
options.filteredTargets = filterTargets(options, VALID_DEPLOY_TARGETS); | ||
Check warning on line 102 in src/commands/deploy.ts
|
||
const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { | ||
Check warning on line 103 in src/commands/deploy.ts
|
||
return perms.concat(TARGET_PERMISSIONS[target]); | ||
}, []); | ||
return requirePermissions(options, permissions); | ||
}) | ||
.before((options) => { | ||
if (options.filteredTargets.includes("functions")) { | ||
return checkServiceAccountIam(options.project); | ||
} | ||
}) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { AppHostingSingle } from "../../firebaseConfig"; | ||
|
||
export interface Context { | ||
backendConfigs: Map<string, AppHostingSingle>; | ||
backendLocations: Map<string, string>; | ||
backendStorageUris: Map<string, string>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import { expect } from "chai"; | ||
import * as sinon from "sinon"; | ||
import { Config } from "../../config"; | ||
import { FirebaseError } from "../../error"; | ||
import { AppHostingSingle } from "../../firebaseConfig"; | ||
import * as gcs from "../../gcp/storage"; | ||
import { RC } from "../../rc"; | ||
import { Context } from "./args"; | ||
import deploy from "./deploy"; | ||
import * as util from "./util"; | ||
import * as fs from "fs"; | ||
import * as getProjectNumber from "../../getProjectNumber"; | ||
|
||
const BASE_OPTS = { | ||
cwd: "/", | ||
configPath: "/", | ||
except: "", | ||
force: false, | ||
nonInteractive: false, | ||
interactive: false, | ||
debug: false, | ||
filteredTargets: [], | ||
rc: new RC(), | ||
json: false, | ||
}; | ||
|
||
function initializeContext(): Context { | ||
return { | ||
backendConfigs: new Map<string, AppHostingSingle>([ | ||
[ | ||
"foo", | ||
{ | ||
backendId: "foo", | ||
rootDir: "/", | ||
ignore: [], | ||
}, | ||
], | ||
]), | ||
backendLocations: new Map<string, string>([["foo", "us-central1"]]), | ||
backendStorageUris: new Map<string, string>(), | ||
}; | ||
} | ||
|
||
describe("apphosting", () => { | ||
let getBucketStub: sinon.SinonStub; | ||
let createBucketStub: sinon.SinonStub; | ||
let uploadObjectStub: sinon.SinonStub; | ||
let createArchiveStub: sinon.SinonStub; | ||
let createReadStreamStub: sinon.SinonStub; | ||
let getProjectNumberStub: sinon.SinonStub; | ||
|
||
beforeEach(() => { | ||
getProjectNumberStub = sinon | ||
.stub(getProjectNumber, "getProjectNumber") | ||
.throws("Unexpected getProjectNumber call"); | ||
getBucketStub = sinon.stub(gcs, "getBucket").throws("Unexpected getBucket call"); | ||
createBucketStub = sinon.stub(gcs, "createBucket").throws("Unexpected createBucket call"); | ||
uploadObjectStub = sinon.stub(gcs, "uploadObject").throws("Unexpected uploadObject call"); | ||
createArchiveStub = sinon.stub(util, "createArchive").throws("Unexpected createArchive call"); | ||
createReadStreamStub = sinon | ||
.stub(fs, "createReadStream") | ||
.throws("Unexpected createReadStream call"); | ||
}); | ||
|
||
afterEach(() => { | ||
sinon.verifyAndRestore(); | ||
}); | ||
|
||
describe("deploy", () => { | ||
const opts = { | ||
...BASE_OPTS, | ||
projectId: "my-project", | ||
only: "apphosting", | ||
config: new Config({ | ||
apphosting: { | ||
backendId: "foo", | ||
rootDir: "/", | ||
ignore: [], | ||
}, | ||
}), | ||
}; | ||
|
||
it("creates regional GCS bucket if one doesn't exist yet", async () => { | ||
const context = initializeContext(); | ||
getProjectNumberStub.resolves("000000000000"); | ||
getBucketStub.onFirstCall().rejects( | ||
new FirebaseError("error", { | ||
original: new FirebaseError("original error", { status: 404 }), | ||
}), | ||
); | ||
createBucketStub.resolves(); | ||
createArchiveStub.resolves({ | ||
projectSourcePath: "my-project/", | ||
zippedSourcePath: "path/to/foo-1234.zip", | ||
}); | ||
uploadObjectStub.resolves({ | ||
bucket: "firebaseapphosting-sources-12345678-us-central1", | ||
object: "foo-1234", | ||
}); | ||
createReadStreamStub.resolves(); | ||
|
||
await deploy(context, opts); | ||
|
||
expect(createBucketStub).to.be.calledOnce; | ||
}); | ||
|
||
it("correctly creates and sets storage URIs", async () => { | ||
const context = initializeContext(); | ||
getProjectNumberStub.resolves("000000000000"); | ||
getBucketStub.resolves(); | ||
createBucketStub.resolves(); | ||
createArchiveStub.resolves({ | ||
projectSourcePath: "my-project/", | ||
zippedSourcePath: "path/to/foo-1234.zip", | ||
}); | ||
uploadObjectStub.resolves({ | ||
bucket: "firebaseapphosting-sources-12345678-us-central1", | ||
object: "foo-1234", | ||
}); | ||
createReadStreamStub.resolves(); | ||
|
||
await deploy(context, opts); | ||
|
||
expect(context.backendStorageUris.get("foo")).to.equal( | ||
"gs://firebaseapphosting-sources-000000000000-us-central1/foo-1234.zip", | ||
); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import * as fs from "fs"; | ||
import * as path from "path"; | ||
import { FirebaseError, getErrStatus } from "../../error"; | ||
import * as gcs from "../../gcp/storage"; | ||
import { getProjectNumber } from "../../getProjectNumber"; | ||
import { Options } from "../../options"; | ||
import { needProjectId } from "../../projectUtils"; | ||
import { logBullet, logWarning } from "../../utils"; | ||
import { Context } from "./args"; | ||
import { createArchive } from "./util"; | ||
|
||
/** | ||
* Zips and uploads App Hosting source code to Google Cloud Storage in preparation for | ||
* build and deployment. Creates storage buckets if necessary. | ||
*/ | ||
export default async function (context: Context, options: Options): Promise<void> { | ||
annajowang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const projectId = needProjectId(options); | ||
options.projectNumber = await getProjectNumber(options); | ||
if (!context.backendConfigs) { | ||
return; | ||
} | ||
|
||
// Ensure that a bucket exists in each region that a backend is or will be deployed to | ||
for (const loc of context.backendLocations.values()) { | ||
const bucketName = `firebaseapphosting-sources-${options.projectNumber}-${loc.toLowerCase()}`; | ||
try { | ||
await gcs.getBucket(bucketName); | ||
} catch (err) { | ||
const errStatus = getErrStatus((err as FirebaseError).original); | ||
// Unfortunately, requests for a non-existent bucket from the GCS API sometimes return 403 responses as well as 404s. | ||
// We must attempt to create a new bucket on both 403s and 404s. | ||
if (errStatus === 403 || errStatus === 404) { | ||
annajowang marked this conversation as resolved.
Show resolved
Hide resolved
|
||
logBullet( | ||
`Creating Cloud Storage bucket in ${loc} to store App Hosting source code uploads at ${bucketName}...`, | ||
); | ||
try { | ||
await gcs.createBucket(projectId, { | ||
name: bucketName, | ||
location: loc, | ||
lifecycle: { | ||
rule: [ | ||
{ | ||
action: { | ||
type: "Delete", | ||
}, | ||
condition: { | ||
age: 30, | ||
}, | ||
}, | ||
], | ||
}, | ||
}); | ||
} catch (err) { | ||
if (getErrStatus((err as FirebaseError).original) === 403) { | ||
logWarning( | ||
"Failed to create Cloud Storage bucket because user does not have sufficient permissions. " + | ||
"See https://cloud.google.com/storage/docs/access-control/iam-roles for more details on " + | ||
"IAM roles that are able to create a Cloud Storage bucket, and ask your project administrator " + | ||
"to grant you one of those roles.", | ||
); | ||
throw (err as FirebaseError).original; | ||
} | ||
} | ||
} else { | ||
throw err; | ||
} | ||
} | ||
} | ||
|
||
for (const cfg of context.backendConfigs.values()) { | ||
const { projectSourcePath, zippedSourcePath } = await createArchive(cfg, options.projectRoot); | ||
const backendLocation = context.backendLocations.get(cfg.backendId); | ||
if (!backendLocation) { | ||
throw new FirebaseError( | ||
`Failed to find location for backend ${cfg.backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`, | ||
); | ||
} | ||
logBullet(`Uploading source code at ${projectSourcePath}...`); | ||
const { bucket, object } = await gcs.uploadObject( | ||
{ | ||
file: zippedSourcePath, | ||
stream: fs.createReadStream(zippedSourcePath), | ||
}, | ||
`firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}`, | ||
); | ||
logBullet(`Source code uploaded at gs://${bucket}/${object}`); | ||
context.backendStorageUris.set( | ||
cfg.backendId, | ||
`gs://firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}/${path.basename(zippedSourcePath)}`, | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import prepare from "./prepare"; | ||
import deploy from "./deploy"; | ||
import release from "./release"; | ||
|
||
export { prepare, deploy, release }; | ||
annajowang marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment in the code explaining why this new roles/storage.objectViewer stuff is necessary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
^ for better code comprehension e.g. letting reader know that deploy from source has the source code bundled in storage buckets so we need access to that bucket.
but also, now I see below that
roles/storage.objectViewer
is also added in provisionDefaultComputeServiceAccount() below, which is called in this ensureAppHostingComputeServiceAccount()and I'm not sure why?
either there's a better way to do it to make things more clear natively or let's document what we're doing and explain any weird/confusing stuff.