Compare commits

..

1 Commits

Author SHA1 Message Date
Rob Herley f0e253f8e0 support http proxy for artifact upload 2023-12-11 18:29:35 -05:00
5 changed files with 104 additions and 77 deletions
@@ -4,7 +4,6 @@ import {HttpClient} from '@actions/http-client'
import * as config from '../src/internal/shared/config'
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
import {noopLogs} from './common'
import {NetworkError, UsageError} from '../src/internal/shared/errors'
jest.mock('@actions/http-client')
@@ -258,42 +257,9 @@ describe('artifact-http-client', () => {
name: 'artifact',
version: 4
})
}).rejects.toThrowError(new NetworkError('ENOTFOUND').message)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1)
})
it('should properly describe a usage error', async () => {
const mockPost = jest.fn(() => {
const msgFailed = new http.IncomingMessage(new net.Socket())
msgFailed.statusCode = 403
msgFailed.statusMessage = 'Forbidden'
return {
message: msgFailed,
readBody: async () => {
return Promise.resolve(
`{"msg": "insufficient usage to create artifact"}`
)
}
}
})
const mockHttpClient = (
HttpClient as unknown as jest.Mock
).mockImplementation(() => {
return {
post: mockPost
}
})
const client = internalArtifactTwirpClient()
await expect(async () => {
await client.CreateArtifact({
workflowRunBackendId: '1234',
workflowJobRunBackendId: '5678',
name: 'artifact',
version: 4
})
}).rejects.toThrowError(new UsageError().message)
}).rejects.toThrowError(
'Failed to CreateArtifact: Unable to make request: ENOTFOUND\nIf you are using self-hosted runners, please make sure your runner has access to all GitHub endpoints: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#communication-between-self-hosted-runners-and-github'
)
expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1)
})
@@ -352,3 +352,46 @@ describe('upload-artifact', () => {
expect(uploadResp).rejects.toThrow()
})
})
describe('getBlobClientOptions', () => {
afterEach(() => {
delete process.env['HTTPS_PROXY']
delete process.env['HTTP_PROXY']
delete process.env['NO_PROXY']
jest.restoreAllMocks()
})
it('should not use proxy settings if not specified', () => {
const opts = blobUpload.getBlobClientOptions('https://blob-storage.local')
expect(opts.proxyOptions).toBeUndefined()
})
it('should use https proxy settings from environment', () => {
process.env['HTTPS_PROXY'] = 'https://foo:bar@my-proxy.local'
const opts = blobUpload.getBlobClientOptions('https://blob-storage.local')
expect(opts.proxyOptions).toEqual({
host: 'my-proxy.local',
port: 443,
username: 'foo',
password: 'bar'
})
})
it('should use http proxy settings from environment', () => {
process.env['HTTP_PROXY'] = 'http://foo:bar@my-proxy.local:1234'
const opts = blobUpload.getBlobClientOptions('http://blob-storage.local')
expect(opts.proxyOptions).toEqual({
host: 'my-proxy.local',
port: 1234,
username: 'foo',
password: 'bar'
})
})
it('should respect NO_PROXY', () => {
process.env['HTTPS_PROXY'] = 'https://foo:bar@my-proxy.local'
process.env['NO_PROXY'] = 'no-proxy-me.local'
const opts = blobUpload.getBlobClientOptions('https://no-proxy-me.local')
expect(opts.proxyOptions).toBeUndefined()
})
})
@@ -4,7 +4,7 @@ import {info, debug} from '@actions/core'
import {ArtifactServiceClientJSON} from '../../generated'
import {getResultsServiceUrl, getRuntimeToken} from './config'
import {getUserAgentString} from './user-agent'
import {NetworkError, UsageError} from './errors'
import {NetworkError} from './errors'
// The twirp http client must implement this interface
interface Rpc {
@@ -64,7 +64,7 @@ class ArtifactHttpClient implements Rpc {
this.httpClient.post(url, JSON.stringify(data), headers)
)
return body
return JSON.parse(body)
} catch (error) {
throw new Error(`Failed to ${method}: ${error.message}`)
}
@@ -72,49 +72,34 @@ class ArtifactHttpClient implements Rpc {
async retryableRequest(
operation: () => Promise<HttpClientResponse>
): Promise<{response: HttpClientResponse; body: object}> {
): Promise<{response: HttpClientResponse; body: string}> {
let attempt = 0
let errorMessage = ''
let rawBody = ''
while (attempt < this.maxAttempts) {
let isRetryable = false
try {
const response = await operation()
const statusCode = response.message.statusCode
rawBody = await response.readBody()
const body = await response.readBody()
debug(`[Response] - ${response.message.statusCode}`)
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
const body = JSON.parse(rawBody)
debug(`Body: ${JSON.stringify(body, null, 2)}`)
debug(`Body: ${body}`)
if (this.isSuccessStatusCode(statusCode)) {
return {response, body}
}
isRetryable = this.isRetryableHttpStatusCode(statusCode)
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
if (body.msg) {
if (UsageError.isUsageErrorMessage(body.msg)) {
throw new UsageError()
}
errorMessage = `${errorMessage}: ${body.msg}`
const responseMessage = JSON.parse(body).msg
if (responseMessage) {
errorMessage = `${errorMessage}: ${responseMessage}`
}
} catch (error) {
if (error instanceof SyntaxError) {
debug(`Raw Body: ${rawBody}`)
throw error
}
if (error instanceof UsageError) {
throw error
}
isRetryable = true
errorMessage = error.message
if (NetworkError.isNetworkErrorCode(error?.code)) {
throw new NetworkError(error?.code)
}
isRetryable = true
errorMessage = error.message
}
if (!isRetryable) {
@@ -57,16 +57,3 @@ export class NetworkError extends Error {
].includes(code)
}
}
export class UsageError extends Error {
constructor() {
const message = `Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
super(message)
this.name = 'UsageError'
}
static isUsageErrorMessage = (msg?: string): boolean => {
if (!msg) return false
return msg.includes('insufficient usage')
}
}
@@ -1,11 +1,18 @@
import {BlobClient, BlockBlobUploadStreamOptions} from '@azure/storage-blob'
import {
AnonymousCredential,
BlobClient,
BlockBlobUploadStreamOptions,
StoragePipelineOptions
} from '@azure/storage-blob'
import {TransferProgressEvent} from '@azure/core-http'
import {ZipUploadStream} from './zip'
import {getUploadChunkSize, getConcurrency} from '../shared/config'
import {getProxyUrl} from '@actions/http-client'
import * as core from '@actions/core'
import * as crypto from 'crypto'
import * as stream from 'stream'
import {NetworkError} from '../shared/errors'
import {getUserAgentString} from '../shared/user-agent'
export interface BlobUploadResponse {
/**
@@ -27,7 +34,12 @@ export async function uploadZipToBlobStorage(
const maxConcurrency = getConcurrency()
const bufferSize = getUploadChunkSize()
const blobClient = new BlobClient(authenticatedUploadURL)
const blobClient = new BlobClient(
authenticatedUploadURL,
new AnonymousCredential(),
getBlobClientOptions(authenticatedUploadURL)
)
const blockBlobClient = blobClient.getBlockBlobClient()
core.debug(
@@ -85,3 +97,37 @@ export async function uploadZipToBlobStorage(
sha256Hash
}
}
export function getBlobClientOptions(sasURL: string): StoragePipelineOptions {
const options: StoragePipelineOptions = {
userAgentOptions: {
userAgentPrefix: getUserAgentString()
}
}
const proxyUrl = getProxyUrl(sasURL)
if (proxyUrl !== '') {
const {
port: portString,
hostname: host,
username,
password,
protocol
} = new URL(proxyUrl)
core.debug(`Using proxy server for blob storage upload, host: ${host}`)
let port = protocol === 'https:' ? 443 : 80
if (portString !== '') {
port = parseInt(portString)
}
options.proxyOptions = {
host,
port,
username,
password
}
}
return options
}