-
Notifications
You must be signed in to change notification settings - Fork 124
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
Deploy option to enable continuous deployment #1745
base: main
Are you sure you want to change the base?
Changes from 6 commits
adaa867
fa5e762
6e614ca
a7efd4c
1077838
44b1e5f
b6c2a3f
bb790ef
93203ca
8128173
56e4d14
6f7c852
9e234a2
c9effe0
8c75f52
75cbf50
e80786b
ea676b1
06b52f7
a7ba250
5830afc
048fd88
7c719ae
a6ac45e
8407f88
476ebec
bc60124
14ce19b
27128bb
3a5de29
82f7b0d
105f0ed
e511cc7
bb9c311
ff60b80
f1cd74d
e188a39
fae07e1
41f9682
dd50d70
3f0bbb8
fede926
172bf50
87f707d
159d708
1a5c9f9
4a8e4e7
a23c35c
36e2153
3e5e669
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 |
---|---|---|
@@ -1,7 +1,10 @@ | ||
import {exec} from "node:child_process"; | ||
import {createHash} from "node:crypto"; | ||
import type {Stats} from "node:fs"; | ||
import {existsSync} from "node:fs"; | ||
import {readFile, stat} from "node:fs/promises"; | ||
import {join} from "node:path/posix"; | ||
import {promisify} from "node:util"; | ||
import slugify from "@sindresorhus/slugify"; | ||
import wrapAnsi from "wrap-ansi"; | ||
import type {BuildEffects, BuildManifest, BuildOptions} from "./build.js"; | ||
|
@@ -20,6 +23,7 @@ import type { | |
DeployManifestFile, | ||
GetCurrentUserResponse, | ||
GetDeployResponse, | ||
GetProjectEnvironmentResponse, | ||
GetProjectResponse, | ||
WorkspaceResponse | ||
} from "./observableApiClient.js"; | ||
|
@@ -33,6 +37,10 @@ const DEPLOY_POLL_MAX_MS = 1000 * 60 * 5; | |
const DEPLOY_POLL_INTERVAL_MS = 1000 * 5; | ||
const BUILD_AGE_WARNING_MS = 1000 * 60 * 5; | ||
|
||
export function formatGitUrl(url: string) { | ||
return new URL(url).pathname.slice(1).replace(/\.git$/, ""); | ||
} | ||
|
||
export interface DeployOptions { | ||
config: Config; | ||
deployConfigPath: string | undefined; | ||
|
@@ -82,9 +90,14 @@ const defaultEffects: DeployEffects = { | |
|
||
type DeployTargetInfo = | ||
| {create: true; workspace: {id: string; login: string}; projectSlug: string; title: string; accessLevel: string} | ||
| {create: false; workspace: {id: string; login: string}; project: GetProjectResponse}; | ||
|
||
/** Deploy a project to ObservableHQ */ | ||
| { | ||
create: false; | ||
workspace: {id: string; login: string}; | ||
project: GetProjectResponse; | ||
environment: GetProjectEnvironmentResponse; | ||
}; | ||
|
||
/** Deploy a project to Observable */ | ||
export async function deploy(deployOptions: DeployOptions, effects = defaultEffects): Promise<void> { | ||
Telemetry.record({event: "deploy", step: "start", force: deployOptions.force}); | ||
effects.clack.intro(`${inverse(" observable deploy ")} ${faint(`v${process.env.npm_package_version}`)}`); | ||
|
@@ -190,14 +203,124 @@ class Deployer { | |
return deployInfo; | ||
} | ||
|
||
private async startNewDeploy(): Promise<GetDeployResponse> { | ||
const deployConfig = await this.getUpdatedDeployConfig(); | ||
const deployTarget = await this.getDeployTarget(deployConfig); | ||
const buildFilePaths = await this.getBuildFilePaths(); | ||
const deployId = await this.createNewDeploy(deployTarget); | ||
private async cloudBuild(deployTarget: DeployTargetInfo) { | ||
if (deployTarget.create) { | ||
throw Error("Incorrect deployTarget state"); | ||
tophtucker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions; | ||
await this.apiClient.postProjectBuild(deployTarget.project.id); | ||
const spinner = this.effects.clack.spinner(); | ||
spinner.start("Requesting deploy"); | ||
const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; | ||
while (true) { | ||
if (Date.now() > pollExpiration) { | ||
spinner.stop("Requesting deploy timed out"); | ||
throw new CliError("Requesting deploy failed"); | ||
} | ||
const {latestCreatedDeployId} = await this.apiClient.getProject({ | ||
workspaceLogin: deployTarget.workspace.login, | ||
projectSlug: deployTarget.project.slug | ||
}); | ||
if (latestCreatedDeployId !== deployTarget.project.latestCreatedDeployId) { | ||
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. chatting with fil: there's no guarantee that the new deploy ID is the one you just kicked off; maybe your colleague click the deploy button around the same time. currently, the postProjectBuild method can't return the new deploy ID because it just dispatches a message to the job-manager, which at some point will get around to making a new deploy. two options:
but fil thinks this is probably not a blocking issue with this PR 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. not planning to do anything about this for now |
||
spinner.stop( | ||
`Deploy started. Watch logs: ${process.env["OBSERVABLE_ORIGIN"] ?? "https://observablehq.com/"}projects/@${ | ||
deployTarget.workspace.login | ||
}/${deployTarget.project.slug}/deploys/${latestCreatedDeployId}` | ||
); | ||
// latestCreatedDeployId is initially null for a new project, but once | ||
// it changes to a string it can never change back; since we know it has | ||
// changed, we assert here that it’s not null | ||
return latestCreatedDeployId!; | ||
} | ||
await new Promise((resolve) => setTimeout(resolve, pollInterval)); | ||
} | ||
} | ||
|
||
await this.uploadFiles(deployId, buildFilePaths); | ||
await this.markDeployUploaded(deployId); | ||
private async maybeLinkGitHub(deployTarget: DeployTargetInfo): Promise<boolean> { | ||
if (deployTarget.create) { | ||
throw Error("Incorrect deployTarget state"); | ||
tophtucker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
if (!this.effects.isTty) return false; | ||
tophtucker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (deployTarget.environment.build_environment_id && deployTarget.environment.source) { | ||
// can do cloud build | ||
return true; | ||
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. fil notes: if we broke our local git configuration, shouldn't we check it? do we want to check if everything is correct? there's a good chance everything will work, but… if you have erased your .git folder, or you have changed your remotes, or if you're not on the right branch… should we check that local and remote git refs match?? the user is expecting a cloud deploy of the same application state they are looking at locally!! if they have local unstaged changes, or are on a different branch, they could get surprising results from doing a cloud build. if observable cloud is configured to pull from the main branch, and you're on toph/onramp locally, what are you expecting to happen when you run and it'll get more confusing with branch previews. how does it work on vercel? on vercel, there's no way to trigger a deploy except to push, so there's no question here. maybe that should be our norm? except you still wanna be able to re-run data loaders, which depend on changing external state not captured by commits. 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. maybe at a minimum we should just check that the local git repo still exists, still has that remote, and is on the same branch. (i.e. move all the tests in the "else" block up above the if/else.) 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. should also verify that the repo hasnt been unlinked on the web side 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. done — now validates repo every time and checks that local and remote repos match. i don't check refs yet; not planning to do it in this PR. |
||
} else { | ||
// We only support cloud builds from the root directory so this ignores this.deployOptions.config.root | ||
const isGit = existsSync(".git"); | ||
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. todo: log cwd when running yarn deploy. you can run yarn deploy from a child directory like src, but i think it still runs in the context of the root directory, in which case this is correct. 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. tried logging cwd out of curiosity and indeed, as one would hope/expect, yarn deploy runs in the root directory no matter where you call it from. |
||
if (isGit) { | ||
const remotes = (await promisify(exec)("git remote -v", {cwd: this.deployOptions.config.root})).stdout | ||
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. note that in loader.ts we make our promises "by hand" (and with spawn instead of exec), but in create.ts we already use promisify, so… seems fine! |
||
.split("\n") | ||
.filter((d) => d) | ||
.map((d) => d.split(/\s/g)); | ||
const gitHub = remotes.find(([, url]) => url.startsWith("https://github.com/")); | ||
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. fil instead has, i think, the SSH case, which we should also 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. done |
||
if (gitHub) { | ||
const repoName = formatGitUrl(gitHub[1]); | ||
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. formatGitUrl will also be different in the SSH case 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. done |
||
const {repositories} = await this.apiClient.getGitHubRepositories(); | ||
const authedRepo = repositories.find(({url}) => formatGitUrl(url) === repoName); | ||
if (authedRepo) { | ||
// authed repo found | ||
await this.apiClient.postProjectEnvironment(deployTarget.project.id, { | ||
source: { | ||
provider: authedRepo.provider, | ||
provider_id: authedRepo.provider_id, | ||
url: authedRepo.url, | ||
branch: null // TODO detect branch | ||
} | ||
}); | ||
return true; | ||
} else { | ||
// repo not auth’ed; link to auth page and poll for auth | ||
this.effects.clack.log.info( | ||
`Authorize Observable to access the ${bold(repoName)} repository: ${link( | ||
"https://github.com/apps/observable-data-apps-dev/installations/select_target" | ||
)}` | ||
); | ||
const spinner = this.effects.clack.spinner(); | ||
spinner.start("Waiting for repository to be authorized"); | ||
const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; | ||
while (true) { | ||
if (Date.now() > pollExpiration) { | ||
spinner.stop("Waiting for repository to be authorized timed out"); | ||
throw new CliError("Deploy failed"); | ||
} | ||
const {repositories} = await this.apiClient.getGitHubRepositories(); | ||
const authedRepo = repositories.find(({url}) => formatGitUrl(url) === repoName); | ||
if (authedRepo) { | ||
spinner.stop("Repository authorized"); | ||
await this.apiClient.postProjectEnvironment(deployTarget.project.id, { | ||
source: { | ||
provider: authedRepo.provider, | ||
provider_id: authedRepo.provider_id, | ||
url: authedRepo.url, | ||
branch: null // TODO detect branch | ||
} | ||
}); | ||
return true; | ||
} | ||
await new Promise((resolve) => setTimeout(resolve, 2000)); | ||
} | ||
} | ||
} else { | ||
this.effects.clack.log.error("No GitHub remote found"); | ||
} | ||
} else { | ||
this.effects.clack.log.error("Not at root of a git repository"); | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
private async startNewDeploy(): Promise<GetDeployResponse> { | ||
const {deployConfig, deployTarget} = await this.getDeployTarget(await this.getUpdatedDeployConfig()); | ||
let deployId: string | null; | ||
if (deployConfig.continuousDeployment) { | ||
deployId = await this.cloudBuild(deployTarget); | ||
} else { | ||
const buildFilePaths = await this.getBuildFilePaths(); | ||
deployId = await this.createNewDeploy(deployTarget); | ||
await this.uploadFiles(deployId, buildFilePaths); | ||
await this.markDeployUploaded(deployId); | ||
} | ||
return await this.pollForProcessingCompletion(deployId); | ||
tophtucker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
|
@@ -274,15 +397,18 @@ class Deployer { | |
} | ||
|
||
// Get the deploy target, prompting the user as needed. | ||
private async getDeployTarget(deployConfig: DeployConfig): Promise<DeployTargetInfo> { | ||
private async getDeployTarget( | ||
deployConfig: DeployConfig | ||
): Promise<{deployTarget: DeployTargetInfo; deployConfig: DeployConfig}> { | ||
let deployTarget: DeployTargetInfo; | ||
if (deployConfig.workspaceLogin && deployConfig.projectSlug) { | ||
try { | ||
const project = await this.apiClient.getProject({ | ||
workspaceLogin: deployConfig.workspaceLogin, | ||
projectSlug: deployConfig.projectSlug | ||
}); | ||
deployTarget = {create: false, workspace: project.owner, project}; | ||
const environment = await this.apiClient.getProjectEnvironment({id: project.id}); | ||
tophtucker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
deployTarget = {create: false, workspace: project.owner, project, environment}; | ||
} catch (error) { | ||
if (!isHttpError(error) || error.statusCode !== 404) { | ||
throw error; | ||
|
@@ -360,7 +486,17 @@ class Deployer { | |
workspaceId: deployTarget.workspace.id, | ||
accessLevel: deployTarget.accessLevel | ||
}); | ||
deployTarget = {create: false, workspace: deployTarget.workspace, project}; | ||
deployTarget = { | ||
create: false, | ||
workspace: deployTarget.workspace, | ||
project, | ||
// TODO: In the future we may have a default environment | ||
environment: { | ||
automatic_builds_enabled: null, | ||
build_environment_id: null, | ||
source: null | ||
} | ||
}; | ||
} catch (error) { | ||
if (isApiError(error) && error.details.errors.some((e) => e.code === "TOO_MANY_PROJECTS")) { | ||
this.effects.clack.log.error( | ||
|
@@ -384,18 +520,40 @@ class Deployer { | |
} | ||
} | ||
|
||
let {continuousDeployment} = deployConfig; | ||
if (continuousDeployment === null) { | ||
const enable = await this.effects.clack.confirm({ | ||
message: wrapAnsi( | ||
`Do you want to enable continuous deployment? ${faint( | ||
"This builds in the cloud and redeploys whenever you push to this repository." | ||
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. Dunstan says this could note upfront that continuous deployment requires a GitHub repository 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. done |
||
)}`, | ||
this.effects.outputColumns | ||
), | ||
active: "Yes, enable and build in cloud", | ||
inactive: "No, build locally" | ||
}); | ||
if (this.effects.clack.isCancel(enable)) throw new CliError("User canceled deploy", {print: false, exitCode: 0}); | ||
continuousDeployment = enable; | ||
} | ||
|
||
// Disables continuous deployment if there’s no env/source & we can’t link GitHub | ||
if (continuousDeployment) continuousDeployment = await this.maybeLinkGitHub(deployTarget); | ||
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. fil says: if you enable continuous deployment, it should stay on, and if we can't connect to github for whatever reason (you're not in a repo, or no git remote, or no link in our database), the deploy should just fail. 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. done, and it feels like a much better way to think about it |
||
|
||
const newDeployConfig = { | ||
projectId: deployTarget.project.id, | ||
projectSlug: deployTarget.project.slug, | ||
workspaceLogin: deployTarget.workspace.login, | ||
continuousDeployment | ||
}; | ||
|
||
await this.effects.setDeployConfig( | ||
this.deployOptions.config.root, | ||
this.deployOptions.deployConfigPath, | ||
{ | ||
projectId: deployTarget.project.id, | ||
projectSlug: deployTarget.project.slug, | ||
workspaceLogin: deployTarget.workspace.login | ||
}, | ||
newDeployConfig, | ||
this.effects | ||
); | ||
|
||
return deployTarget; | ||
return {deployConfig: newDeployConfig, deployTarget}; | ||
} | ||
|
||
// Create the new deploy on the server. | ||
|
@@ -756,7 +914,17 @@ export async function promptDeployTarget( | |
if (effects.clack.isCancel(chosenProject)) { | ||
throw new CliError("User canceled deploy.", {print: false, exitCode: 0}); | ||
} else if (chosenProject !== null) { | ||
return {create: false, workspace, project: existingProjects.find((p) => p.slug === chosenProject)!}; | ||
// TODO(toph): initial env config | ||
return { | ||
create: false, | ||
workspace, | ||
project: existingProjects.find((p) => p.slug === chosenProject)!, | ||
environment: { | ||
automatic_builds_enabled: null, | ||
build_environment_id: null, | ||
source: null | ||
} | ||
}; | ||
} | ||
} else { | ||
const confirmChoice = await effects.clack.confirm({ | ||
|
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.
fil notes that this could be parallelized: getBuildFilePaths doesn't depend on the previous two; it's filesystem i/o, as opposed to talking to the api. might speed up local deploys a bit
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.
not gonna worry about this for now; also, buildFilePaths is only relevant for the local deploy path, so it's not as clean to parallelize anymore