Skip to content

fix: keep binary in same place, update if needed #33

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

Merged
merged 11 commits into from
Jan 20, 2023
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"build": "webpack",
"watch": "webpack --watch",
"package": "webpack --mode production --devtool hidden-source-map",
"package:prerelease": "npx vsce package --pre-release",
"lint": "eslint . --ext ts,md",
"tsc:compile": "tsc",
"tsc:watch": "tsc -w",
Expand Down
302 changes: 195 additions & 107 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import axios from "axios"
import { execFile } from "child_process"
import { getBuildInfo } from "coder/site/src/api/api"
import { createWriteStream } from "fs"
import * as crypto from "crypto"
import { createWriteStream, createReadStream } from "fs"
import { ensureDir } from "fs-extra"
import fs from "fs/promises"
import { IncomingMessage } from "http"
Expand Down Expand Up @@ -73,39 +74,16 @@ export class Storage {
// fetchBinary returns the path to a Coder binary.
// The binary will be cached if a matching server version already exists.
public async fetchBinary(): Promise<string | undefined> {
await this.cleanUpOldBinaries()
const baseURL = this.getURL()
if (!baseURL) {
throw new Error("Must be logged in!")
}
const baseURI = vscode.Uri.parse(baseURL)

const buildInfo = await getBuildInfo()
const binPath = this.binaryPath(buildInfo.version)
const exists = await fs
.stat(binPath)
.then(() => true)
.catch(() => false)
if (exists) {
// Even if the file exists, it could be corrupted.
// We run `coder version` to ensure the binary can be executed.
this.output.appendLine(`Using cached binary: ${binPath}`)
const valid = await new Promise<boolean>((resolve) => {
try {
execFile(binPath, ["version"], (err) => {
if (err) {
this.output.appendLine("Check for binary corruption: " + err)
}
resolve(err === null)
})
} catch (ex) {
this.output.appendLine("The cached binary cannot be executed: " + ex)
resolve(false)
}
})
if (valid) {
return binPath
}
}
const binPath = this.binaryPath()
const exists = await this.checkBinaryExists(binPath)
const os = goos()
const arch = goarch()
let binName = `coder-${os}-${arch}`
Expand All @@ -114,106 +92,153 @@ export class Storage {
binName += ".exe"
}
const controller = new AbortController()

if (exists) {
this.output.appendLine(`Found existing binary: ${binPath}`)
const valid = await this.checkBinaryValid(binPath)
if (!valid) {
const removed = await this.rmBinary(binPath)
if (!removed) {
vscode.window.showErrorMessage("Failed to remove existing binary!")
return undefined
}
}
}
const etag = await this.getBinaryETag()
this.output.appendLine(`Using binName: ${binName}`)
this.output.appendLine(`Using binPath: ${binPath}`)
this.output.appendLine(`Using ETag: ${etag}`)

const resp = await axios.get("/bin/" + binName, {
signal: controller.signal,
baseURL: baseURL,
responseType: "stream",
headers: {
"Accept-Encoding": "gzip",
"If-None-Match": `"${etag}"`,
},
decompress: true,
// Ignore all errors so we can catch a 404!
validateStatus: () => true,
})
if (resp.status === 404) {
vscode.window
.showErrorMessage(
"Coder isn't supported for your platform. Please open an issue, we'd love to support it!",
"Open an Issue",
)
.then((value) => {
if (!value) {
return
}
const params = new URLSearchParams({
title: `Support the \`${os}-${arch}\` platform`,
body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`,
})
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
vscode.env.openExternal(uri)
})
return
}
if (resp.status !== 200) {
vscode.window.showErrorMessage("Failed to fetch the Coder binary: " + resp.statusText)
return
}
this.output.appendLine("Response status code: " + resp.status)

const contentLength = Number.parseInt(resp.headers["content-length"])
switch (resp.status) {
case 200: {
const contentLength = Number.parseInt(resp.headers["content-length"])

// Ensure the binary directory exists!
await fs.mkdir(path.dirname(binPath), { recursive: true })
// Ensure the binary directory exists!
await fs.mkdir(path.dirname(binPath), { recursive: true })
const tempFile = binPath + ".temp-" + Math.random().toString(36).substring(8)

const completed = await vscode.window.withProgress<boolean>(
{
location: vscode.ProgressLocation.Notification,
title: `Downloading the latest binary (${buildInfo.version} from ${baseURI.authority})`,
cancellable: true,
},
async (progress, token) => {
const readStream = resp.data as IncomingMessage
let cancelled = false
token.onCancellationRequested(() => {
controller.abort()
readStream.destroy()
cancelled = true
})
const completed = await vscode.window.withProgress<boolean>(
{
location: vscode.ProgressLocation.Notification,
title: `Downloading the latest binary (${buildInfo.version} from ${baseURI.authority})`,
cancellable: true,
},
async (progress, token) => {
const readStream = resp.data as IncomingMessage
let cancelled = false
token.onCancellationRequested(() => {
controller.abort()
readStream.destroy()
cancelled = true
})

let contentLengthPretty = ""
// Reverse proxies might not always send a content length!
if (!Number.isNaN(contentLength)) {
contentLengthPretty = " / " + prettyBytes(contentLength)
}
let contentLengthPretty = ""
// Reverse proxies might not always send a content length!
if (!Number.isNaN(contentLength)) {
contentLengthPretty = " / " + prettyBytes(contentLength)
}

const writeStream = createWriteStream(binPath, {
autoClose: true,
mode: 0o755,
})
let written = 0
readStream.on("data", (buffer: Buffer) => {
writeStream.write(buffer, () => {
written += buffer.byteLength
progress.report({
message: `${prettyBytes(written)}${contentLengthPretty}`,
increment: (buffer.byteLength / contentLength) * 100,
const writeStream = createWriteStream(tempFile, {
autoClose: true,
mode: 0o755,
})
})
let written = 0
readStream.on("data", (buffer: Buffer) => {
writeStream.write(buffer, () => {
written += buffer.byteLength
progress.report({
message: `${prettyBytes(written)}${contentLengthPretty}`,
increment: (buffer.byteLength / contentLength) * 100,
})
})
})
try {
await new Promise<void>((resolve, reject) => {
readStream.on("error", (err) => {
reject(err)
})
readStream.on("close", () => {
if (cancelled) {
return reject()
}
writeStream.close()
resolve()
})
})
return true
} catch (ex) {
return false
}
},
)
if (!completed) {
return
}
this.output.appendLine(`Downloaded binary: ${binPath}`)
const oldBinPath = binPath + ".old-" + Math.random().toString(36).substring(8)
await fs.rename(binPath, oldBinPath).catch(() => {
this.output.appendLine(`Warning: failed to rename ${binPath} to ${oldBinPath}`)
})
try {
await new Promise<void>((resolve, reject) => {
readStream.on("error", (err) => {
reject(err)
await fs.rename(tempFile, binPath)
await fs.rm(oldBinPath, { force: true }).catch((error) => {
this.output.appendLine(`Warning: failed to remove old binary: ${error}`)
})
return binPath
}
case 304: {
this.output.appendLine(`Using cached binary: ${binPath}`)
return binPath
}
case 404: {
vscode.window
.showErrorMessage(
"Coder isn't supported for your platform. Please open an issue, we'd love to support it!",
"Open an Issue",
)
.then((value) => {
if (!value) {
return
}
const params = new URLSearchParams({
title: `Support the \`${os}-${arch}\` platform`,
body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`,
})
readStream.on("close", () => {
if (cancelled) {
return reject()
}
writeStream.close()
resolve()
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
vscode.env.openExternal(uri)
})
return undefined
}
default: {
vscode.window
.showErrorMessage("Failed to download binary. Please open an issue.", "Open an Issue")
.then((value) => {
if (!value) {
return
}
const params = new URLSearchParams({
title: `Failed to download binary on \`${os}-${arch}\``,
body: `Received status code \`${resp.status}\` when downloading the binary.`,
})
const uri = vscode.Uri.parse(`https://github.com/coder/vscode-coder/issues/new?` + params.toString())
vscode.env.openExternal(uri)
})
return true
} catch (ex) {
return false
}
},
)
if (!completed) {
return
return undefined
}
}

this.output.appendLine(`Downloaded binary: ${binPath}`)
return binPath
}

// getBinaryCachePath returns the path where binaries are cached.
Expand All @@ -240,6 +265,23 @@ export class Storage {
return path.join(this.globalStorageUri.fsPath, "url")
}

public getBinaryETag(): Promise<string> {
const hash = crypto.createHash("sha1")
const stream = createReadStream(this.binaryPath())
return new Promise((resolve, reject) => {
stream.on("end", () => {
hash.end()
resolve(hash.digest("hex"))
})
stream.on("error", (err) => {
reject(err)
})
stream.on("data", (chunk) => {
hash.update(chunk)
})
})
}

private appDataDir(): string {
switch (process.platform) {
case "darwin":
Expand All @@ -264,16 +306,62 @@ export class Storage {
}
}

private binaryPath(version: string): string {
private async cleanUpOldBinaries(): Promise<void> {
const binPath = this.binaryPath()
const binDir = path.dirname(binPath)
const files = await fs.readdir(binDir)
for (const file of files) {
const fileName = path.basename(file)
if (fileName.includes(".old-")) {
try {
await fs.rm(path.join(binDir, file), { force: true })
} catch (error) {
this.output.appendLine(`Warning: failed to remove ${fileName}. Error: ${error}`)
}
}
}
}

private binaryPath(): string {
const os = goos()
const arch = goarch()
let binPath = path.join(this.getBinaryCachePath(), `coder-${os}-${arch}-${version}`)
let binPath = path.join(this.getBinaryCachePath(), `coder-${os}-${arch}`)
if (os === "windows") {
binPath += ".exe"
}
return binPath
}

private async checkBinaryExists(binPath: string): Promise<boolean> {
return await fs
.stat(binPath)
.then(() => true)
.catch(() => false)
}

private async rmBinary(binPath: string): Promise<boolean> {
return await fs
.rm(binPath, { force: true })
.then(() => true)
.catch(() => false)
}

private async checkBinaryValid(binPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
try {
execFile(binPath, ["version"], (err) => {
if (err) {
this.output.appendLine("Check for binary corruption: " + err)
}
resolve(err === null)
})
} catch (ex) {
this.output.appendLine("The cached binary cannot be executed: " + ex)
resolve(false)
}
})
}

private async updateSessionToken() {
const token = await this.getSessionToken()
if (token) {
Expand Down