1
1
import axios from "axios"
2
2
import { execFile } from "child_process"
3
3
import { getBuildInfo } from "coder/site/src/api/api"
4
- import { createWriteStream } from "fs"
4
+ import * as crypto from "crypto"
5
+ import { createWriteStream , createReadStream } from "fs"
5
6
import { ensureDir } from "fs-extra"
6
7
import fs from "fs/promises"
7
8
import { IncomingMessage } from "http"
@@ -73,39 +74,16 @@ export class Storage {
73
74
// fetchBinary returns the path to a Coder binary.
74
75
// The binary will be cached if a matching server version already exists.
75
76
public async fetchBinary ( ) : Promise < string | undefined > {
77
+ await this . cleanUpOldBinaries ( )
76
78
const baseURL = this . getURL ( )
77
79
if ( ! baseURL ) {
78
80
throw new Error ( "Must be logged in!" )
79
81
}
80
82
const baseURI = vscode . Uri . parse ( baseURL )
81
83
82
84
const buildInfo = await getBuildInfo ( )
83
- const binPath = this . binaryPath ( buildInfo . version )
84
- const exists = await fs
85
- . stat ( binPath )
86
- . then ( ( ) => true )
87
- . catch ( ( ) => false )
88
- if ( exists ) {
89
- // Even if the file exists, it could be corrupted.
90
- // We run `coder version` to ensure the binary can be executed.
91
- this . output . appendLine ( `Using cached binary: ${ binPath } ` )
92
- const valid = await new Promise < boolean > ( ( resolve ) => {
93
- try {
94
- execFile ( binPath , [ "version" ] , ( err ) => {
95
- if ( err ) {
96
- this . output . appendLine ( "Check for binary corruption: " + err )
97
- }
98
- resolve ( err === null )
99
- } )
100
- } catch ( ex ) {
101
- this . output . appendLine ( "The cached binary cannot be executed: " + ex )
102
- resolve ( false )
103
- }
104
- } )
105
- if ( valid ) {
106
- return binPath
107
- }
108
- }
85
+ const binPath = this . binaryPath ( )
86
+ const exists = await this . checkBinaryExists ( binPath )
109
87
const os = goos ( )
110
88
const arch = goarch ( )
111
89
let binName = `coder-${ os } -${ arch } `
@@ -114,106 +92,153 @@ export class Storage {
114
92
binName += ".exe"
115
93
}
116
94
const controller = new AbortController ( )
95
+
96
+ if ( exists ) {
97
+ this . output . appendLine ( `Found existing binary: ${ binPath } ` )
98
+ const valid = await this . checkBinaryValid ( binPath )
99
+ if ( ! valid ) {
100
+ const removed = await this . rmBinary ( binPath )
101
+ if ( ! removed ) {
102
+ vscode . window . showErrorMessage ( "Failed to remove existing binary!" )
103
+ return undefined
104
+ }
105
+ }
106
+ }
107
+ const etag = await this . getBinaryETag ( )
108
+ this . output . appendLine ( `Using binName: ${ binName } ` )
109
+ this . output . appendLine ( `Using binPath: ${ binPath } ` )
110
+ this . output . appendLine ( `Using ETag: ${ etag } ` )
111
+
117
112
const resp = await axios . get ( "/bin/" + binName , {
118
113
signal : controller . signal ,
119
114
baseURL : baseURL ,
120
115
responseType : "stream" ,
121
116
headers : {
122
117
"Accept-Encoding" : "gzip" ,
118
+ "If-None-Match" : `"${ etag } "` ,
123
119
} ,
124
120
decompress : true ,
125
121
// Ignore all errors so we can catch a 404!
126
122
validateStatus : ( ) => true ,
127
123
} )
128
- if ( resp . status === 404 ) {
129
- vscode . window
130
- . showErrorMessage (
131
- "Coder isn't supported for your platform. Please open an issue, we'd love to support it!" ,
132
- "Open an Issue" ,
133
- )
134
- . then ( ( value ) => {
135
- if ( ! value ) {
136
- return
137
- }
138
- const params = new URLSearchParams ( {
139
- title : `Support the \`${ os } -${ arch } \` platform` ,
140
- body : `I'd like to use the \`${ os } -${ arch } \` architecture with the VS Code extension.` ,
141
- } )
142
- const uri = vscode . Uri . parse ( `https://github.com/coder/vscode-coder/issues/new?` + params . toString ( ) )
143
- vscode . env . openExternal ( uri )
144
- } )
145
- return
146
- }
147
- if ( resp . status !== 200 ) {
148
- vscode . window . showErrorMessage ( "Failed to fetch the Coder binary: " + resp . statusText )
149
- return
150
- }
124
+ this . output . appendLine ( "Response status code: " + resp . status )
151
125
152
- const contentLength = Number . parseInt ( resp . headers [ "content-length" ] )
126
+ switch ( resp . status ) {
127
+ case 200 : {
128
+ const contentLength = Number . parseInt ( resp . headers [ "content-length" ] )
153
129
154
- // Ensure the binary directory exists!
155
- await fs . mkdir ( path . dirname ( binPath ) , { recursive : true } )
130
+ // Ensure the binary directory exists!
131
+ await fs . mkdir ( path . dirname ( binPath ) , { recursive : true } )
132
+ const tempFile = binPath + ".temp-" + Math . random ( ) . toString ( 36 ) . substring ( 8 )
156
133
157
- const completed = await vscode . window . withProgress < boolean > (
158
- {
159
- location : vscode . ProgressLocation . Notification ,
160
- title : `Downloading the latest binary (${ buildInfo . version } from ${ baseURI . authority } )` ,
161
- cancellable : true ,
162
- } ,
163
- async ( progress , token ) => {
164
- const readStream = resp . data as IncomingMessage
165
- let cancelled = false
166
- token . onCancellationRequested ( ( ) => {
167
- controller . abort ( )
168
- readStream . destroy ( )
169
- cancelled = true
170
- } )
134
+ const completed = await vscode . window . withProgress < boolean > (
135
+ {
136
+ location : vscode . ProgressLocation . Notification ,
137
+ title : `Downloading the latest binary (${ buildInfo . version } from ${ baseURI . authority } )` ,
138
+ cancellable : true ,
139
+ } ,
140
+ async ( progress , token ) => {
141
+ const readStream = resp . data as IncomingMessage
142
+ let cancelled = false
143
+ token . onCancellationRequested ( ( ) => {
144
+ controller . abort ( )
145
+ readStream . destroy ( )
146
+ cancelled = true
147
+ } )
171
148
172
- let contentLengthPretty = ""
173
- // Reverse proxies might not always send a content length!
174
- if ( ! Number . isNaN ( contentLength ) ) {
175
- contentLengthPretty = " / " + prettyBytes ( contentLength )
176
- }
149
+ let contentLengthPretty = ""
150
+ // Reverse proxies might not always send a content length!
151
+ if ( ! Number . isNaN ( contentLength ) ) {
152
+ contentLengthPretty = " / " + prettyBytes ( contentLength )
153
+ }
177
154
178
- const writeStream = createWriteStream ( binPath , {
179
- autoClose : true ,
180
- mode : 0o755 ,
181
- } )
182
- let written = 0
183
- readStream . on ( "data" , ( buffer : Buffer ) => {
184
- writeStream . write ( buffer , ( ) => {
185
- written += buffer . byteLength
186
- progress . report ( {
187
- message : `${ prettyBytes ( written ) } ${ contentLengthPretty } ` ,
188
- increment : ( buffer . byteLength / contentLength ) * 100 ,
155
+ const writeStream = createWriteStream ( tempFile , {
156
+ autoClose : true ,
157
+ mode : 0o755 ,
189
158
} )
190
- } )
159
+ let written = 0
160
+ readStream . on ( "data" , ( buffer : Buffer ) => {
161
+ writeStream . write ( buffer , ( ) => {
162
+ written += buffer . byteLength
163
+ progress . report ( {
164
+ message : `${ prettyBytes ( written ) } ${ contentLengthPretty } ` ,
165
+ increment : ( buffer . byteLength / contentLength ) * 100 ,
166
+ } )
167
+ } )
168
+ } )
169
+ try {
170
+ await new Promise < void > ( ( resolve , reject ) => {
171
+ readStream . on ( "error" , ( err ) => {
172
+ reject ( err )
173
+ } )
174
+ readStream . on ( "close" , ( ) => {
175
+ if ( cancelled ) {
176
+ return reject ( )
177
+ }
178
+ writeStream . close ( )
179
+ resolve ( )
180
+ } )
181
+ } )
182
+ return true
183
+ } catch ( ex ) {
184
+ return false
185
+ }
186
+ } ,
187
+ )
188
+ if ( ! completed ) {
189
+ return
190
+ }
191
+ this . output . appendLine ( `Downloaded binary: ${ binPath } ` )
192
+ const oldBinPath = binPath + ".old-" + Math . random ( ) . toString ( 36 ) . substring ( 8 )
193
+ await fs . rename ( binPath , oldBinPath ) . catch ( ( ) => {
194
+ this . output . appendLine ( `Warning: failed to rename ${ binPath } to ${ oldBinPath } ` )
191
195
} )
192
- try {
193
- await new Promise < void > ( ( resolve , reject ) => {
194
- readStream . on ( "error" , ( err ) => {
195
- reject ( err )
196
+ await fs . rename ( tempFile , binPath )
197
+ await fs . rm ( oldBinPath , { force : true } ) . catch ( ( error ) => {
198
+ this . output . appendLine ( `Warning: failed to remove old binary: ${ error } ` )
199
+ } )
200
+ return binPath
201
+ }
202
+ case 304 : {
203
+ this . output . appendLine ( `Using cached binary: ${ binPath } ` )
204
+ return binPath
205
+ }
206
+ case 404 : {
207
+ vscode . window
208
+ . showErrorMessage (
209
+ "Coder isn't supported for your platform. Please open an issue, we'd love to support it!" ,
210
+ "Open an Issue" ,
211
+ )
212
+ . then ( ( value ) => {
213
+ if ( ! value ) {
214
+ return
215
+ }
216
+ const params = new URLSearchParams ( {
217
+ title : `Support the \`${ os } -${ arch } \` platform` ,
218
+ body : `I'd like to use the \`${ os } -${ arch } \` architecture with the VS Code extension.` ,
196
219
} )
197
- readStream . on ( "close" , ( ) => {
198
- if ( cancelled ) {
199
- return reject ( )
200
- }
201
- writeStream . close ( )
202
- resolve ( )
220
+ const uri = vscode . Uri . parse ( `https://github.com/coder/vscode-coder/issues/new?` + params . toString ( ) )
221
+ vscode . env . openExternal ( uri )
222
+ } )
223
+ return undefined
224
+ }
225
+ default : {
226
+ vscode . window
227
+ . showErrorMessage ( "Failed to download binary. Please open an issue." , "Open an Issue" )
228
+ . then ( ( value ) => {
229
+ if ( ! value ) {
230
+ return
231
+ }
232
+ const params = new URLSearchParams ( {
233
+ title : `Failed to download binary on \`${ os } -${ arch } \`` ,
234
+ body : `Received status code \`${ resp . status } \` when downloading the binary.` ,
203
235
} )
236
+ const uri = vscode . Uri . parse ( `https://github.com/coder/vscode-coder/issues/new?` + params . toString ( ) )
237
+ vscode . env . openExternal ( uri )
204
238
} )
205
- return true
206
- } catch ( ex ) {
207
- return false
208
- }
209
- } ,
210
- )
211
- if ( ! completed ) {
212
- return
239
+ return undefined
240
+ }
213
241
}
214
-
215
- this . output . appendLine ( `Downloaded binary: ${ binPath } ` )
216
- return binPath
217
242
}
218
243
219
244
// getBinaryCachePath returns the path where binaries are cached.
@@ -240,6 +265,23 @@ export class Storage {
240
265
return path . join ( this . globalStorageUri . fsPath , "url" )
241
266
}
242
267
268
+ public getBinaryETag ( ) : Promise < string > {
269
+ const hash = crypto . createHash ( "sha1" )
270
+ const stream = createReadStream ( this . binaryPath ( ) )
271
+ return new Promise ( ( resolve , reject ) => {
272
+ stream . on ( "end" , ( ) => {
273
+ hash . end ( )
274
+ resolve ( hash . digest ( "hex" ) )
275
+ } )
276
+ stream . on ( "error" , ( err ) => {
277
+ reject ( err )
278
+ } )
279
+ stream . on ( "data" , ( chunk ) => {
280
+ hash . update ( chunk )
281
+ } )
282
+ } )
283
+ }
284
+
243
285
private appDataDir ( ) : string {
244
286
switch ( process . platform ) {
245
287
case "darwin" :
@@ -264,16 +306,62 @@ export class Storage {
264
306
}
265
307
}
266
308
267
- private binaryPath ( version : string ) : string {
309
+ private async cleanUpOldBinaries ( ) : Promise < void > {
310
+ const binPath = this . binaryPath ( )
311
+ const binDir = path . dirname ( binPath )
312
+ const files = await fs . readdir ( binDir )
313
+ for ( const file of files ) {
314
+ const fileName = path . basename ( file )
315
+ if ( fileName . includes ( ".old-" ) ) {
316
+ try {
317
+ await fs . rm ( path . join ( binDir , file ) , { force : true } )
318
+ } catch ( error ) {
319
+ this . output . appendLine ( `Warning: failed to remove ${ fileName } . Error: ${ error } ` )
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ private binaryPath ( ) : string {
268
326
const os = goos ( )
269
327
const arch = goarch ( )
270
- let binPath = path . join ( this . getBinaryCachePath ( ) , `coder-${ os } -${ arch } - ${ version } ` )
328
+ let binPath = path . join ( this . getBinaryCachePath ( ) , `coder-${ os } -${ arch } ` )
271
329
if ( os === "windows" ) {
272
330
binPath += ".exe"
273
331
}
274
332
return binPath
275
333
}
276
334
335
+ private async checkBinaryExists ( binPath : string ) : Promise < boolean > {
336
+ return await fs
337
+ . stat ( binPath )
338
+ . then ( ( ) => true )
339
+ . catch ( ( ) => false )
340
+ }
341
+
342
+ private async rmBinary ( binPath : string ) : Promise < boolean > {
343
+ return await fs
344
+ . rm ( binPath , { force : true } )
345
+ . then ( ( ) => true )
346
+ . catch ( ( ) => false )
347
+ }
348
+
349
+ private async checkBinaryValid ( binPath : string ) : Promise < boolean > {
350
+ return await new Promise < boolean > ( ( resolve ) => {
351
+ try {
352
+ execFile ( binPath , [ "version" ] , ( err ) => {
353
+ if ( err ) {
354
+ this . output . appendLine ( "Check for binary corruption: " + err )
355
+ }
356
+ resolve ( err === null )
357
+ } )
358
+ } catch ( ex ) {
359
+ this . output . appendLine ( "The cached binary cannot be executed: " + ex )
360
+ resolve ( false )
361
+ }
362
+ } )
363
+ }
364
+
277
365
private async updateSessionToken ( ) {
278
366
const token = await this . getSessionToken ( )
279
367
if ( token ) {
0 commit comments