Skip to content
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

Add retries and retryTimeout option #20

Merged
merged 16 commits into from
Dec 20, 2020
Merged
57 changes: 47 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
Sauce Connect Proxy GitHub Action
=================================
# Sauce Connect Proxy GitHub Action
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vscode auto reformatted this file. Can revert the style changes if you like


A GitHub action to launch Sauce Connect Proxy.

Expand All @@ -14,113 +13,151 @@ jobs:
# ...
- uses: saucelabs/sauce-connect-action@master
with:
username: ${{ secrets.SAUCE_USERNAME }}
accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
tunnelIdentifier: github-action-tunnel
scVersion: 4.6.2
username: ${{ secrets.SAUCE_USERNAME }}
accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
tunnelIdentifier: github-action-tunnel
scVersion: 4.6.2
# ...
```

## Inputs

### `username`:

**Required** Sauce Labs user name.

### `accesskey`:

**Required** Sauce Labs API Key.

### `cainfo`:

CA certificate bundle to use for verifying REST connections. (default "/usr/local/etc/openssl/cert.pem")

### `capath`:

Directory of CA certs to use for verifying REST connections. (default "/etc/ssl/certs")

### `configFile`:

Path to YAML config file. Please refer to https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Command+Line +Reference for a sample configuration file.

### `directDomains`:
Comma-separated list of domains. Requests whose host matches one of these will be relayed directly through the internet, instead of through the tunnel.

Comma-separated list of domains. Requests whose host matches one of these will be relayed directly through the internet, instead of through the tunnel.

### `dns`:
"Use specified name server. To specify multiple servers, separate them with comma. Use IP addresses, optionally with a port number, the two separated by a colon. Example: --dns 8.8.8.8,8.8.4.4:53"

"Use specified name server. To specify multiple servers, separate them with comma. Use IP addresses, optionally with a port number, the two separated by a colon. Example: --dns 8.8.8.8,8.8.4.4:53"

### `doctor`:

Perform checks to detect possible misconfiguration or problems.

### `fastFailRegexps`:
Comma-separated list of regular expressions. Requests matching one of these will get dropped instantly and will not go through the tunnel.

Comma-separated list of regular expressions. Requests matching one of these will get dropped instantly and will not go through the tunnel.

### `logStats`:

Log statistics about HTTP traffic every <seconds>.

### `maxLogsize`:

Rotate logfile after reaching <bytes> size. Disabled by default.

### `maxMissedAcks`:

The maximum amount of keepalive acks that can be missed before the client will trigger a reconnect. (default 30)

### `metricsAddress`:

'host:port for the internal web server used to expose client side metrics. (default "localhost:8888")'

### `noAutodetect`:

Disable the autodetection of proxy settings.

### `noProxyCaching`:

Disable caching in Sauce Connect. All requests will be sent through the tunnel.

### `noRemoveCollidingTunnels`:

Don't remove identified tunnels with the same name, or any other default tunnels if this is a default tunnel. Jobs will be distributed between these tunnels, enabling load balancing and high availability. By default, colliding tunnels will be removed when Sauce Connect is starting up.

### `noSSLBumpDomains`:

Comma-separated list of domains. Requests whose host matches one of these will not be SSL re-encrypted.

### `pac`:

Proxy autoconfiguration. Can be an http(s) or local file:// (absolute path only) URI.

### `proxy`:

Proxy host and port that Sauce Connect should use to connect to the Sauce Labs cloud.

### `proxyTunnel`:

Use the proxy configured with -p for the tunnel connection.

### `proxyUserpwd`:

Username and password required to access the proxy configured with -p.

### `restUrl`:
'Advanced feature: Connect to Sauce REST API at alternative URL. Use only if directed to do so by Sauce Labs support. (default "https://saucelabs.com/rest/v1")'

'Advanced feature: Connect to Sauce REST API at alternative URL. Use only if directed to do so by Sauce Labs support. (default "https://saucelabs.com/rest/v1")'

### `scproxyPort`:

Port on which scproxy will be listening.

### `scproxyReadLimit`:

Rate limit reads in scproxy to X bytes per second. This option can be used to adjust local network transfer rate in order not to overload the tunnel connection.

### `scproxyWriteLimit`:

Rate limit writes in scproxy to X bytes per second. This option can be used to adjust local network transfer rate in order not to overload the tunnel connection.

### `sePort`:

Port on which Sauce Connect's Selenium relay will listen for requests. Selenium commands reaching Connect on this port will be relayed to Sauce Labs securely and reliably through Connect's tunnel (default 4445)

### `sharedTunnel`:

Let sub-accounts of the tunnel owner use the tunnel if requested.

### `tunnelCainfo`:

CA certificate bundle to use for verifying tunnel connections. (default "/usr/local/etc/openssl/cert.pem")

### `tunnelCapath`:

Directory of CA certs to use for verifying tunnel connections. (default "/etc/ssl/certs")

### `tunnelCert`:

'Specify certificate to use for the tunnel connection, either public or private. Default: private. (default "private")'

### `tunnelDomains`:

Inverse of '--direct-domains'. Only requests for domains in this list will be sent through the tunnel. Overrides '--direct-domains'.

### `tunnelIdentifier`:

Don't automatically assign jobs to this tunnel. Jobs will use it only by explicitly providing the right identifier.

### `verbose`:

Enable verbose logging. Can be used up to two times. (default "true")

### `scVersion`:

Version of the saucelabs/sauce-connect docker image.

### `retryTimeout`:

Do not retry if this amount of seconds has passed since starting. (default: "0")
98 changes: 23 additions & 75 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,30 @@
import {getInput, info, saveState, setFailed} from '@actions/core'
import {exec} from '@actions/exec'
import {join} from 'path'
import {tmpdir} from 'os'
import {promises} from 'fs'
import {wait} from './wait'
import optionMappingJson from './option-mapping.json'
import {getInput, saveState, setFailed, warning} from '@actions/core'
import {start} from './start'

const LOG_FILE = '/srv/sauce-connect.log'
const PID_FILE = '/srv/sauce-connect.pid'
const READY_FILE = '/opt/sauce-connect-action/sc.ready'

type OptionMapping = {
actionOption: string
dockerOption: string
required?: boolean
flag?: boolean
}
const optionMappings: OptionMapping[] = optionMappingJson

function buildOptions(): string[] {
const params = [
`--logfile=${LOG_FILE}`,
`--pidfile=${PID_FILE}`,
`--readyfile=${READY_FILE}`,
`--verbose`
]

for (const optionMapping of optionMappings) {
const input = getInput(optionMapping.actionOption, {
required: optionMapping.required
})

if (input === '') {
// user input nothing for this option
} else if (optionMapping.flag) {
// for flag options like --doctor option
params.push(`--${optionMapping.dockerOption}`)
} else {
params.push(`--${optionMapping.dockerOption}=${input}`)
}
}
return params
}
const retryDelays = [1, 1, 1, 2, 3, 4, 5, 10, 20, 40, 60].map(a => a * 1000)

async function run(): Promise<void> {
const DIR_IN_HOST = await promises.mkdtemp(
join(tmpdir(), `sauce-connect-action`)
)
const containerVersion = getInput('scVersion')
const containerName = `saucelabs/sauce-connect:${containerVersion}`
try {
await exec('docker', ['pull', containerName])
let containerId = ''
await exec(
'docker',
[
'run',
'--network=host',
'--detach',
'-v',
`${DIR_IN_HOST}:/opt/sauce-connect-action`,
'--rm',
containerName
].concat(buildOptions()),
{
listeners: {
stdout: (data: Buffer) => {
containerId += data.toString()
}
}
const retryTimeout = parseInt(getInput('retryTimeout') || '0', 10) * 1000
const startTime = Date.now()

for (let i = 0; ; i++) {
christian-bromann marked this conversation as resolved.
Show resolved Hide resolved
try {
const containerId = await start()
saveState('containerId', containerId)
return
} catch (e) {
if (Date.now() - startTime >= retryTimeout) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means by default there will be no retries, correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep I thought we should keep the existing behaviour the same?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if we should enable retries by default. We expect the tunnel to start with the first try so we wouldn't modify the existing behavior anyway. And having the action to retry rather than fail seems to me like an desired feature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok cool. Do you think 10 minutes would be a good default?

break
}
)
saveState('containerId', containerId.trim())
await wait(DIR_IN_HOST)
info('SC ready')
} catch (error) {
setFailed(error.message)
const delay = retryDelays[Math.min(retryDelays.length - 1, i)]
warning(
`Error occurred on attempt ${i + 1}. Retrying in ${delay} ms...`
)
await new Promise(resolve => setTimeout(() => resolve(), delay))
}
throw new Error('Timed out')
}
}

run()
// eslint-disable-next-line github/no-then
run().catch(error => setFailed(error.message))
15 changes: 5 additions & 10 deletions src/post.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {getState, info, warning, setFailed} from '@actions/core'
import {exec} from '@actions/exec'
import {getState, warning, setFailed} from '@actions/core'
import {stopContainer} from './stop-container'

async function run(): Promise<void> {
const containerId = getState('containerId')
Expand All @@ -10,13 +10,8 @@ async function run(): Promise<void> {
return
}

try {
info(`Trying to stop the docker container with ID ${containerId}...`)
await exec('docker', ['container', 'stop', containerId])
info('Done.')
} catch (error) {
setFailed(error.message)
}
await stopContainer(containerId)
}

run()
// eslint-disable-next-line github/no-then
run().catch(error => setFailed(error.message))
82 changes: 82 additions & 0 deletions src/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {getInput, info} from '@actions/core'
import {exec} from '@actions/exec'
import {join} from 'path'
import {tmpdir} from 'os'
import {promises} from 'fs'
import {wait} from './wait'
import optionMappingJson from './option-mapping.json'
import {stopContainer} from './stop-container'

const LOG_FILE = '/srv/sauce-connect.log'
const PID_FILE = '/srv/sauce-connect.pid'
const READY_FILE = '/opt/sauce-connect-action/sc.ready'

type OptionMapping = {
actionOption: string
dockerOption: string
required?: boolean
flag?: boolean
}
const optionMappings: OptionMapping[] = optionMappingJson

function buildOptions(): string[] {
const params = [
`--logfile=${LOG_FILE}`,
`--pidfile=${PID_FILE}`,
`--readyfile=${READY_FILE}`,
`--verbose`
]

for (const optionMapping of optionMappings) {
const input = getInput(optionMapping.actionOption, {
required: optionMapping.required
})

if (input === '') {
// user input nothing for this option
} else if (optionMapping.flag) {
// for flag options like --doctor option
params.push(`--${optionMapping.dockerOption}`)
} else {
params.push(`--${optionMapping.dockerOption}=${input}`)
}
}
return params
}

export async function start(): Promise<string> {
tjenkinson marked this conversation as resolved.
Show resolved Hide resolved
const DIR_IN_HOST = await promises.mkdtemp(
join(tmpdir(), `sauce-connect-action`)
)
const containerVersion = getInput('scVersion')
const containerName = `saucelabs/sauce-connect:${containerVersion}`
await exec('docker', ['pull', containerName])
let containerId = ''
await exec(
'docker',
[
'run',
'--network=host',
'--detach',
'-v',
`${DIR_IN_HOST}:/opt/sauce-connect-action`,
'--rm',
containerName
].concat(buildOptions()),
{
listeners: {
stdout: (data: Buffer) => {
containerId += data.toString()
}
}
}
)
containerId = containerId.trim()
try {
await wait(DIR_IN_HOST)
info('SC ready')
} finally {
await stopContainer(containerId)
}
return containerId
}
8 changes: 8 additions & 0 deletions src/stop-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {exec} from '@actions/exec'
import {info} from 'console'

export async function stopContainer(containerId: string): Promise<void> {
info(`Trying to stop the docker container with ID ${containerId}...`)
await exec('docker', ['container', 'stop', containerId])
info('Done.')
}