diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 55bb9dec..820784ea 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -14,7 +14,7 @@ import * as util from "util" import * as zlib from "zlib" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" -import { settings } from "../settings" +import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings" import { tmpdir } from "../util" import { ipcMain } from "../wrapper" @@ -23,6 +23,10 @@ export interface Update { version: string } +export interface LatestResponse { + name: string +} + /** * Update HTTP provider. */ @@ -30,7 +34,26 @@ export class UpdateHttpProvider extends HttpProvider { private update?: Promise private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks. - public constructor(options: HttpProviderOptions, public readonly enabled: boolean) { + public constructor( + options: HttpProviderOptions, + public readonly enabled: boolean, + /** + * The URL for getting the latest version of code-server. Should return JSON + * that fulfills `LatestResponse`. + */ + private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", + /** + * The URL for downloading a version of code-server. {{VERSION}} and + * {{RELEASE_NAME}} will be replaced (for example 2.1.0 and + * code-server-2.1.0-linux-x86_64.tar.gz). + */ + private readonly downloadUrl = "https://github.com/cdr/code-server/releases/download/{{VERSION}}/{{RELEASE_NAME}}", + /** + * Update information will be stored here. If not provided, the global + * settings will be used. + */ + private readonly settings: SettingsProvider = globalSettings, + ) { super(options) } @@ -82,6 +105,7 @@ export class UpdateHttpProvider extends HttpProvider { throw new Error("updates are not enabled") } + // Don't run multiple requests at a time. if (!this.update) { this.update = this._getUpdate(force) this.update.then(() => (this.update = undefined)) @@ -91,15 +115,14 @@ export class UpdateHttpProvider extends HttpProvider { } private async _getUpdate(force?: boolean): Promise { - const url = "https://api.github.com/repos/cdr/code-server/releases/latest" const now = Date.now() try { - let { update } = !force ? await settings.read() : { update: undefined } + let { update } = !force ? await this.settings.read() : { update: undefined } if (!update || update.checked + this.updateInterval < now) { - const buffer = await this.request(url) - const data = JSON.parse(buffer.toString()) - update = { checked: now, version: data.name as string } - settings.write({ update }) + const buffer = await this.request(this.latestUrl) + const data = JSON.parse(buffer.toString()) as LatestResponse + update = { checked: now, version: data.name } + await this.settings.write({ update }) } logger.debug("Got latest version", field("latest", update.version)) return update @@ -160,16 +183,16 @@ export class UpdateHttpProvider extends HttpProvider { } } - private async downloadUpdate(update: Update): Promise { - const releaseName = await this.getReleaseName(update) - const url = `https://github.com/cdr/code-server/releases/download/${update.version.replace}/${releaseName}` + public async downloadUpdate(update: Update, targetPath?: string, target?: string): Promise { + const releaseName = await this.getReleaseName(update, target) + const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName) - await fs.mkdirp(tmpdir) + let downloadPath = path.join(tmpdir, "updates", releaseName) + fs.mkdirp(path.dirname(downloadPath)) const response = await this.requestResponse(url) try { - let downloadPath = path.join(tmpdir, releaseName) if (downloadPath.endsWith(".tar.gz")) { downloadPath = await this.extractTar(response, downloadPath) } else { @@ -177,12 +200,53 @@ export class UpdateHttpProvider extends HttpProvider { } logger.debug("Downloaded update", field("path", downloadPath)) - const target = path.resolve(__dirname, "../") - logger.debug("Replacing files", field("target", target)) - await fs.unlink(target) - await fs.move(downloadPath, target) + // The archive should have a code-server directory at the top level. + try { + const stat = await fs.stat(path.join(downloadPath, "code-server")) + if (!stat.isDirectory()) { + throw new Error("ENOENT") + } + } catch (error) { + throw new Error("no code-server directory found in downloaded archive") + } - ipcMain().relaunch(update.version) + // The archive might contain a binary or it might contain loose files. + // This is probably stupid but just check if `node` exists since we + // package it with the loose files. + const isBinary = !(await fs.pathExists(path.join(downloadPath, "code-server/node"))) + + // In the binary we need to replace the binary, otherwise we can replace + // the directory. + if (!targetPath) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + targetPath = (process.versions as any).nbin ? process.argv[0] : path.resolve(__dirname, "../../../") + } + + // If we're currently running a binary it must be unlinked to avoid + // ETXTBSY. + try { + const stat = await fs.stat(targetPath) + if (stat.isFile()) { + await fs.unlink(targetPath) + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error + } + } + + logger.debug("Replacing files", field("target", targetPath), field("isBinary", isBinary)) + if (isBinary) { + await fs.move(path.join(downloadPath, "code-server/code-server"), targetPath, { overwrite: true }) + } else { + await fs.move(path.join(downloadPath, "code-server"), targetPath, { overwrite: true }) + } + + await fs.remove(downloadPath) + + if (process.send) { + ipcMain().relaunch(update.version) + } } catch (error) { response.destroy(error) throw error @@ -252,8 +316,7 @@ export class UpdateHttpProvider extends HttpProvider { /** * Given an update return the name for the packaged archived. */ - private async getReleaseName(update: Update): Promise { - let target: string = os.platform() + private async getReleaseName(update: Update, target: string = os.platform()): Promise { if (target === "linux") { const result = await util .promisify(cp.exec)("ldd --version") @@ -294,7 +357,8 @@ export class UpdateHttpProvider extends HttpProvider { return new Promise((resolve, reject) => { const request = (uri: string): void => { logger.debug("Making request", field("uri", uri)) - https.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { + const httpx = uri.startsWith("https") ? https : http + httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { if ( response.statusCode && response.statusCode >= 300 && diff --git a/src/node/http.ts b/src/node/http.ts index 294c3eb6..26336a89 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -500,6 +500,7 @@ export class HttpServer { response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError) response.end(error.message) }) + payload.stream.on("close", () => response.end()) payload.stream.pipe(response) } else if (typeof payload.content === "string" || payload.content instanceof Buffer) { response.end(payload.content) diff --git a/src/node/settings.ts b/src/node/settings.ts index 2b4a277c..0d6152b1 100644 --- a/src/node/settings.ts +++ b/src/node/settings.ts @@ -40,20 +40,23 @@ export class SettingsProvider { } } -/** - * Global code-server settings. - */ -export interface CoderSettings { - lastVisited: { - url: string - workspace: boolean - } +export interface UpdateSettings { update: { checked: number version: string } } +/** + * Global code-server settings. + */ +export interface CoderSettings extends UpdateSettings { + lastVisited: { + url: string + workspace: boolean + } +} + /** * Global code-server settings file. */ diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..5197ce27 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/test/update.test.ts b/test/update.test.ts new file mode 100644 index 00000000..19523a04 --- /dev/null +++ b/test/update.test.ts @@ -0,0 +1,256 @@ +import zip from "adm-zip" +import * as assert from "assert" +import * as fs from "fs-extra" +import * as http from "http" +import * as os from "os" +import * as path from "path" +import * as tar from "tar-fs" +import * as zlib from "zlib" +import { LatestResponse, UpdateHttpProvider } from "../src/node/app/update" +import { AuthType } from "../src/node/http" +import { SettingsProvider, UpdateSettings } from "../src/node/settings" +import { tmpdir } from "../src/node/util" + +describe("update", () => { + const archivePaths = { + loose: path.join(tmpdir, "tests/updates/code-server-loose-source"), + binary: path.join(tmpdir, "tests/updates/code-server-binary-source"), + } + + let useBinary = false + let version = "1.0.0" + let spy: string[] = [] + const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { + if (!request.url) { + throw new Error("no url") + } + spy.push(request.url) + response.writeHead(200) + if (request.url === "/latest") { + const latest: LatestResponse = { + name: version, + } + return response.end(JSON.stringify(latest)) + } + + const path = + (useBinary ? archivePaths.binary : archivePaths.loose) + (request.url.endsWith(".tar.gz") ? ".tar.gz" : ".zip") + + const stream = fs.createReadStream(path) + stream.on("error", (error: NodeJS.ErrnoException) => { + response.writeHead(500) + response.end(error.message) + }) + response.writeHead(200) + stream.on("close", () => response.end()) + stream.pipe(response) + }) + + const jsonPath = path.join(tmpdir, "tests/updates/update.json") + const settings = new SettingsProvider(jsonPath) + + let _provider: UpdateHttpProvider | undefined + const provider = (): UpdateHttpProvider => { + if (!_provider) { + const address = server.address() + if (!address || typeof address === "string" || !address.port) { + throw new Error("unexpected address") + } + _provider = new UpdateHttpProvider( + { + auth: AuthType.None, + base: "/update", + commit: "test", + }, + true, + `http://${address.address}:${address.port}/latest`, + `http://${address.address}:${address.port}/download/{{VERSION}}/{{RELEASE_NAME}}`, + settings, + ) + } + return _provider + } + + before(async () => { + await fs.remove(path.join(tmpdir, "tests/updates")) + await Promise.all(Object.values(archivePaths).map((p) => fs.mkdirp(path.join(p, "code-server")))) + + await Promise.all([ + fs.writeFile(path.join(archivePaths.binary, "code-server", "code-server"), "BINARY"), + fs.writeFile(path.join(archivePaths.loose, "code-server", "code-server"), `console.log("UPDATED")`), + fs.writeFile(path.join(archivePaths.loose, "code-server", "node"), `NODE BINARY`), + ]) + + await Promise.all( + Object.values(archivePaths).map((p) => { + return Promise.all([ + new Promise((resolve, reject) => { + const write = fs.createWriteStream(p + ".tar.gz") + const compress = zlib.createGzip() + compress.pipe(write) + compress.on("error", (error) => compress.destroy(error)) + compress.on("close", () => write.end()) + tar.pack(p).pipe(compress) + write.on("close", reject) + write.on("finish", () => { + resolve() + }) + }), + new Promise((resolve, reject) => { + const zipFile = new zip() + zipFile.addLocalFolder(p) + zipFile.writeZip(p + ".zip", (error) => { + return error ? reject(error) : resolve(error) + }) + }), + ]) + }), + ) + + await new Promise((resolve, reject) => { + server.on("error", reject) + server.on("listening", resolve) + server.listen({ + port: 0, + host: "localhost", + }) + }) + }) + + after(() => { + server.close() + }) + + beforeEach(() => { + spy = [] + }) + + it("should get the latest", async () => { + version = "2.1.0" + + const p = provider() + const now = Date.now() + const update = await p.getUpdate() + + assert.deepEqual({ update }, await settings.read()) + assert.equal(isNaN(update.checked), false) + assert.equal(update.checked < Date.now() && update.checked >= now, true) + assert.equal(update.version, "2.1.0") + assert.deepEqual(spy, ["/latest"]) + }) + + it("should keep existing information", async () => { + version = "3.0.1" + + const p = provider() + const now = Date.now() + const update = await p.getUpdate() + + assert.deepEqual({ update }, await settings.read()) + assert.equal(isNaN(update.checked), false) + assert.equal(update.checked < now, true) + assert.equal(update.version, "2.1.0") + assert.deepEqual(spy, []) + }) + + it("should force getting the latest", async () => { + version = "4.1.1" + + const p = provider() + const now = Date.now() + const update = await p.getUpdate(true) + + assert.deepEqual({ update }, await settings.read()) + assert.equal(isNaN(update.checked), false) + assert.equal(update.checked < Date.now() && update.checked >= now, true) + assert.equal(update.version, "4.1.1") + assert.deepEqual(spy, ["/latest"]) + }) + + it("should get latest after interval passes", async () => { + const p = provider() + await p.getUpdate() + assert.deepEqual(spy, []) + + let checked = Date.now() - 1000 * 60 * 60 * 23 + await settings.write({ update: { checked, version } }) + await p.getUpdate() + assert.deepEqual(spy, []) + + checked = Date.now() - 1000 * 60 * 60 * 25 + await settings.write({ update: { checked, version } }) + + const update = await p.getUpdate() + assert.notEqual(update.checked, checked) + assert.deepEqual(spy, ["/latest"]) + }) + + it("should check if it's the current version", async () => { + version = "9999999.99999.9999" + + const p = provider() + let update = await p.getUpdate(true) + assert.equal(p.isLatestVersion(update), false) + + version = "0.0.0" + update = await p.getUpdate(true) + assert.equal(p.isLatestVersion(update), true) + + // Old version format; make sure it doesn't report as being later. + version = "999999.9999-invalid999.99.9" + update = await p.getUpdate(true) + assert.equal(p.isLatestVersion(update), true) + }) + + it("should download and apply an update", async () => { + version = "9999999.99999.9999" + + const p = provider() + const update = await p.getUpdate(true) + + // Create an existing version. + const destination = path.join(tmpdir, "tests/updates/code-server") + await fs.mkdirp(destination) + const entry = path.join(destination, "code-server") + await fs.writeFile(entry, `console.log("OLD")`) + assert.equal(`console.log("OLD")`, await fs.readFile(entry, "utf8")) + + // Updating should replace the existing version. + await p.downloadUpdate(update, destination) + assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8")) + + // Should still work if there is no existing version somehow. + await fs.remove(destination) + await p.downloadUpdate(update, destination) + assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8")) + + // Try the other platform. + const altTarget = os.platform() === "darwin" ? "linux" : "darwin" + await fs.writeFile(entry, `console.log("OLD")`) + await p.downloadUpdate(update, destination, altTarget) + assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8")) + + // Extracting a binary should also work. + useBinary = true + await p.downloadUpdate(update, destination) + assert.equal(`BINARY`, await fs.readFile(destination, "utf8")) + + // Back to flat files. + useBinary = false + await fs.remove(destination) + await p.downloadUpdate(update, destination) + assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8")) + + const target = os.platform() + const targetExt = target === "darwin" ? "zip" : "tar.gz" + const altTargetExt = altTarget === "darwin" ? "zip" : "tar.gz" + assert.deepEqual(spy, [ + "/latest", + `/download/${version}/code-server-${version}-${target}-x86_64.${targetExt}`, + `/download/${version}/code-server-${version}-${target}-x86_64.${targetExt}`, + `/download/${version}/code-server-${version}-${altTarget}-x86_64.${altTargetExt}`, + `/download/${version}/code-server-${version}-${target}-x86_64.${targetExt}`, + `/download/${version}/code-server-${version}-${target}-x86_64.${targetExt}`, + ]) + }) +})