* run script copies back only runner file commands * wip * fix * fmt * user volume mount * try doing only file commands * typo * remove _temp_pre * Update packages/k8s/src/hooks/run-script-step.ts Co-authored-by: Copilot <[email protected]> * Update packages/k8s/src/hooks/run-script-step.ts Co-authored-by: Copilot <[email protected]> * better escape * no useless escapes --------- Co-authored-by: Copilot <[email protected]>
934 lines
23 KiB
TypeScript
934 lines
23 KiB
TypeScript
import * as core from '@actions/core'
|
|
import * as path from 'path'
|
|
import { spawn } from 'child_process'
|
|
import * as k8s from '@kubernetes/client-node'
|
|
import tar from 'tar-fs'
|
|
import * as stream from 'stream'
|
|
import { WritableStreamBuffer } from 'stream-buffers'
|
|
import { createHash } from 'crypto'
|
|
import type { ContainerInfo, Registry } from 'hooklib'
|
|
import {
|
|
getSecretName,
|
|
JOB_CONTAINER_NAME,
|
|
RunnerInstanceLabel
|
|
} from '../hooks/constants'
|
|
import {
|
|
PodPhase,
|
|
mergePodSpecWithOptions,
|
|
mergeObjectMeta,
|
|
fixArgs,
|
|
listDirAllCommand,
|
|
sleep,
|
|
EXTERNALS_VOLUME_NAME,
|
|
GITHUB_VOLUME_NAME,
|
|
WORK_VOLUME
|
|
} from './utils'
|
|
import * as shlex from 'shlex'
|
|
|
|
const kc = new k8s.KubeConfig()
|
|
|
|
kc.loadFromDefault()
|
|
|
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
|
|
const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
|
|
const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
|
|
|
|
const DEFAULT_WAIT_FOR_POD_TIME_SECONDS = 10 * 60 // 10 min
|
|
|
|
export const requiredPermissions = [
|
|
{
|
|
group: '',
|
|
verbs: ['get', 'list', 'create', 'delete'],
|
|
resource: 'pods',
|
|
subresource: ''
|
|
},
|
|
{
|
|
group: '',
|
|
verbs: ['get', 'create'],
|
|
resource: 'pods',
|
|
subresource: 'exec'
|
|
},
|
|
{
|
|
group: '',
|
|
verbs: ['get', 'list', 'watch'],
|
|
resource: 'pods',
|
|
subresource: 'log'
|
|
},
|
|
{
|
|
group: '',
|
|
verbs: ['create', 'delete', 'get', 'list'],
|
|
resource: 'secrets',
|
|
subresource: ''
|
|
}
|
|
]
|
|
|
|
export async function createJobPod(
|
|
name: string,
|
|
jobContainer?: k8s.V1Container,
|
|
services?: k8s.V1Container[],
|
|
registry?: Registry,
|
|
extension?: k8s.V1PodTemplateSpec
|
|
): Promise<k8s.V1Pod> {
|
|
const containers: k8s.V1Container[] = []
|
|
if (jobContainer) {
|
|
containers.push(jobContainer)
|
|
}
|
|
if (services?.length) {
|
|
containers.push(...services)
|
|
}
|
|
|
|
const appPod = new k8s.V1Pod()
|
|
|
|
appPod.apiVersion = 'v1'
|
|
appPod.kind = 'Pod'
|
|
|
|
appPod.metadata = new k8s.V1ObjectMeta()
|
|
appPod.metadata.name = name
|
|
|
|
const instanceLabel = new RunnerInstanceLabel()
|
|
appPod.metadata.labels = {
|
|
[instanceLabel.key]: instanceLabel.value
|
|
}
|
|
appPod.metadata.annotations = {}
|
|
|
|
appPod.spec = new k8s.V1PodSpec()
|
|
appPod.spec.containers = containers
|
|
appPod.spec.securityContext = {
|
|
fsGroup: 1001
|
|
}
|
|
|
|
// Extract working directory from GITHUB_WORKSPACE
|
|
// GITHUB_WORKSPACE is like /__w/repo-name/repo-name
|
|
const githubWorkspace = process.env.GITHUB_WORKSPACE
|
|
const workingDirPath = githubWorkspace?.split('/').slice(-2).join('/') ?? ''
|
|
|
|
const initCommands = [
|
|
'mkdir -p /mnt/externals',
|
|
'mkdir -p /mnt/work',
|
|
'mkdir -p /mnt/github',
|
|
'mv /home/runner/externals/* /mnt/externals/'
|
|
]
|
|
|
|
if (workingDirPath) {
|
|
initCommands.push(`mkdir -p /mnt/work/${workingDirPath}`)
|
|
}
|
|
|
|
appPod.spec.initContainers = [
|
|
{
|
|
name: 'fs-init',
|
|
image:
|
|
process.env.ACTIONS_RUNNER_IMAGE ||
|
|
'ghcr.io/actions/actions-runner:latest',
|
|
command: ['sh', '-c', initCommands.join(' && ')],
|
|
securityContext: {
|
|
runAsGroup: 1001,
|
|
runAsUser: 1001
|
|
},
|
|
volumeMounts: [
|
|
{
|
|
name: EXTERNALS_VOLUME_NAME,
|
|
mountPath: '/mnt/externals'
|
|
},
|
|
{
|
|
name: WORK_VOLUME,
|
|
mountPath: '/mnt/work'
|
|
},
|
|
{
|
|
name: GITHUB_VOLUME_NAME,
|
|
mountPath: '/mnt/github'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
appPod.spec.restartPolicy = 'Never'
|
|
|
|
appPod.spec.volumes = [
|
|
{
|
|
name: EXTERNALS_VOLUME_NAME,
|
|
emptyDir: {}
|
|
},
|
|
{
|
|
name: GITHUB_VOLUME_NAME,
|
|
emptyDir: {}
|
|
},
|
|
{
|
|
name: WORK_VOLUME,
|
|
emptyDir: {}
|
|
}
|
|
]
|
|
|
|
if (registry) {
|
|
const secret = await createDockerSecret(registry)
|
|
if (!secret?.metadata?.name) {
|
|
throw new Error(`created secret does not have secret.metadata.name`)
|
|
}
|
|
const secretReference = new k8s.V1LocalObjectReference()
|
|
secretReference.name = secret.metadata.name
|
|
appPod.spec.imagePullSecrets = [secretReference]
|
|
}
|
|
|
|
if (extension?.metadata) {
|
|
mergeObjectMeta(appPod, extension.metadata)
|
|
}
|
|
|
|
if (extension?.spec) {
|
|
mergePodSpecWithOptions(appPod.spec, extension.spec)
|
|
}
|
|
|
|
return await k8sApi.createNamespacedPod({
|
|
namespace: namespace(),
|
|
body: appPod
|
|
})
|
|
}
|
|
|
|
export async function createContainerStepPod(
|
|
name: string,
|
|
container: k8s.V1Container,
|
|
extension?: k8s.V1PodTemplateSpec
|
|
): Promise<k8s.V1Pod> {
|
|
const appPod = new k8s.V1Pod()
|
|
|
|
appPod.apiVersion = 'v1'
|
|
appPod.kind = 'Pod'
|
|
|
|
appPod.metadata = new k8s.V1ObjectMeta()
|
|
appPod.metadata.name = name
|
|
|
|
const instanceLabel = new RunnerInstanceLabel()
|
|
appPod.metadata.labels = {
|
|
[instanceLabel.key]: instanceLabel.value
|
|
}
|
|
appPod.metadata.annotations = {}
|
|
|
|
appPod.spec = new k8s.V1PodSpec()
|
|
appPod.spec.containers = [container]
|
|
|
|
appPod.spec.restartPolicy = 'Never'
|
|
|
|
appPod.spec.volumes = [
|
|
{
|
|
name: EXTERNALS_VOLUME_NAME,
|
|
emptyDir: {}
|
|
},
|
|
{
|
|
name: GITHUB_VOLUME_NAME,
|
|
emptyDir: {}
|
|
},
|
|
{
|
|
name: WORK_VOLUME,
|
|
emptyDir: {}
|
|
}
|
|
]
|
|
|
|
if (extension?.metadata) {
|
|
mergeObjectMeta(appPod, extension.metadata)
|
|
}
|
|
|
|
if (extension?.spec) {
|
|
mergePodSpecWithOptions(appPod.spec, extension.spec)
|
|
}
|
|
|
|
return await k8sApi.createNamespacedPod({
|
|
namespace: namespace(),
|
|
body: appPod
|
|
})
|
|
}
|
|
|
|
export async function deletePod(name: string): Promise<void> {
|
|
await k8sApi.deleteNamespacedPod({
|
|
name,
|
|
namespace: namespace(),
|
|
gracePeriodSeconds: 0
|
|
})
|
|
}
|
|
|
|
export async function execPodStep(
|
|
command: string[],
|
|
podName: string,
|
|
containerName: string,
|
|
stdin?: stream.Readable
|
|
): Promise<number> {
|
|
const exec = new k8s.Exec(kc)
|
|
|
|
command = fixArgs(command)
|
|
return await new Promise(function (resolve, reject) {
|
|
exec
|
|
.exec(
|
|
namespace(),
|
|
podName,
|
|
containerName,
|
|
command,
|
|
process.stdout,
|
|
process.stderr,
|
|
stdin ?? null,
|
|
false /* tty */,
|
|
resp => {
|
|
core.debug(`execPodStep response: ${JSON.stringify(resp)}`)
|
|
if (resp.status === 'Success') {
|
|
resolve(resp.code || 0)
|
|
} else {
|
|
core.debug(
|
|
JSON.stringify({
|
|
message: resp?.message,
|
|
details: resp?.details
|
|
})
|
|
)
|
|
reject(new Error(resp?.message || 'execPodStep failed'))
|
|
}
|
|
}
|
|
)
|
|
.catch(e => reject(e))
|
|
})
|
|
}
|
|
|
|
export async function execCalculateOutputHashSorted(
|
|
podName: string,
|
|
containerName: string,
|
|
command: string[]
|
|
): Promise<string> {
|
|
const exec = new k8s.Exec(kc)
|
|
|
|
let output = ''
|
|
const outputWriter = new stream.Writable({
|
|
write(chunk, _enc, cb) {
|
|
try {
|
|
output += chunk.toString('utf8')
|
|
cb()
|
|
} catch (e) {
|
|
cb(e as Error)
|
|
}
|
|
}
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
exec
|
|
.exec(
|
|
namespace(),
|
|
podName,
|
|
containerName,
|
|
command,
|
|
outputWriter, // capture stdout
|
|
process.stderr,
|
|
null,
|
|
false /* tty */,
|
|
resp => {
|
|
core.debug(`internalExecOutput response: ${JSON.stringify(resp)}`)
|
|
if (resp.status === 'Success') {
|
|
resolve()
|
|
} else {
|
|
core.debug(
|
|
JSON.stringify({
|
|
message: resp?.message,
|
|
details: resp?.details
|
|
})
|
|
)
|
|
reject(new Error(resp?.message || 'internalExecOutput failed'))
|
|
}
|
|
}
|
|
)
|
|
.catch(e => reject(e))
|
|
})
|
|
|
|
outputWriter.end()
|
|
|
|
// Sort lines for consistent ordering across platforms
|
|
const sortedOutput =
|
|
output
|
|
.split('\n')
|
|
.filter(line => line.length > 0)
|
|
.sort()
|
|
.join('\n') + '\n'
|
|
|
|
const hash = createHash('sha256')
|
|
hash.update(sortedOutput)
|
|
return hash.digest('hex')
|
|
}
|
|
|
|
export async function localCalculateOutputHashSorted(
|
|
commands: string[]
|
|
): Promise<string> {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const child = spawn(commands[0], commands.slice(1), {
|
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
})
|
|
|
|
let output = ''
|
|
child.stdout.on('data', chunk => {
|
|
output += chunk.toString('utf8')
|
|
})
|
|
child.on('error', reject)
|
|
child.on('close', (code: number) => {
|
|
if (code === 0) {
|
|
// Sort lines for consistent ordering across distributions/platforms
|
|
const sortedOutput =
|
|
output
|
|
.split('\n')
|
|
.filter(line => line.length > 0)
|
|
.sort()
|
|
.join('\n') + '\n'
|
|
|
|
const hash = createHash('sha256')
|
|
hash.update(sortedOutput)
|
|
resolve(hash.digest('hex'))
|
|
} else {
|
|
reject(new Error(`child process exited with code ${code}`))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function execCpToPod(
|
|
podName: string,
|
|
runnerPath: string,
|
|
containerPath: string
|
|
): Promise<void> {
|
|
core.debug(`Copying ${runnerPath} to pod ${podName} at ${containerPath}`)
|
|
|
|
let attempt = 0
|
|
while (true) {
|
|
try {
|
|
const exec = new k8s.Exec(kc)
|
|
// Use tar to extract with --no-same-owner to avoid ownership issues.
|
|
// Then use find to fix permissions. The -m flag helps but we also need to fix permissions after.
|
|
const command = [
|
|
'sh',
|
|
'-c',
|
|
`tar xf - --no-same-owner -C ${shlex.quote(containerPath)} 2>/dev/null; ` +
|
|
`find ${shlex.quote(containerPath)} -type f -exec chmod u+rw {} \\; 2>/dev/null; ` +
|
|
`find ${shlex.quote(containerPath)} -type d -exec chmod u+rwx {} \\; 2>/dev/null`
|
|
]
|
|
const readStream = tar.pack(runnerPath)
|
|
const errStream = new WritableStreamBuffer()
|
|
await new Promise((resolve, reject) => {
|
|
exec
|
|
.exec(
|
|
namespace(),
|
|
podName,
|
|
JOB_CONTAINER_NAME,
|
|
command,
|
|
null,
|
|
errStream,
|
|
readStream,
|
|
false,
|
|
async status => {
|
|
if (errStream.size()) {
|
|
reject(
|
|
new Error(
|
|
`Error from execCpToPod - status: ${status.status}, details: \n ${errStream.getContentsAsString()}`
|
|
)
|
|
)
|
|
}
|
|
resolve(status)
|
|
}
|
|
)
|
|
.catch(e => reject(e))
|
|
})
|
|
break
|
|
} catch (error) {
|
|
core.debug(`cpToPod: Attempt ${attempt + 1} failed: ${error}`)
|
|
attempt++
|
|
if (attempt >= 30) {
|
|
throw new Error(
|
|
`cpToPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
|
|
)
|
|
}
|
|
await sleep(1000)
|
|
}
|
|
}
|
|
|
|
let attempts = 15
|
|
const delay = 1000
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
const want = await localCalculateOutputHashSorted([
|
|
'sh',
|
|
'-c',
|
|
listDirAllCommand(runnerPath)
|
|
])
|
|
|
|
const got = await execCalculateOutputHashSorted(
|
|
podName,
|
|
JOB_CONTAINER_NAME,
|
|
['sh', '-c', listDirAllCommand(containerPath)]
|
|
)
|
|
|
|
if (got !== want) {
|
|
core.debug(
|
|
`The hash of the directory does not match the expected value; want='${want}' got='${got}'`
|
|
)
|
|
await sleep(delay)
|
|
continue
|
|
}
|
|
|
|
break
|
|
} catch (error) {
|
|
core.debug(`Attempt ${i + 1} failed: ${error}`)
|
|
await sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function execCpFromPod(
|
|
podName: string,
|
|
containerPath: string,
|
|
parentRunnerPath: string
|
|
): Promise<void> {
|
|
const targetRunnerPath = `${parentRunnerPath}/${path.basename(containerPath)}`
|
|
core.debug(
|
|
`Copying from pod ${podName} ${containerPath} to ${targetRunnerPath}`
|
|
)
|
|
|
|
let attempt = 0
|
|
while (true) {
|
|
try {
|
|
// make temporary directory
|
|
const exec = new k8s.Exec(kc)
|
|
const containerPaths = containerPath.split('/')
|
|
const dirname = containerPaths.pop() as string
|
|
const command = [
|
|
'tar',
|
|
'cf',
|
|
'-',
|
|
'-C',
|
|
containerPaths.join('/') || '/',
|
|
dirname
|
|
]
|
|
const writerStream = tar.extract(parentRunnerPath)
|
|
const errStream = new WritableStreamBuffer()
|
|
|
|
await new Promise((resolve, reject) => {
|
|
exec
|
|
.exec(
|
|
namespace(),
|
|
podName,
|
|
JOB_CONTAINER_NAME,
|
|
command,
|
|
writerStream,
|
|
errStream,
|
|
null,
|
|
false,
|
|
async status => {
|
|
if (errStream.size()) {
|
|
reject(
|
|
new Error(
|
|
`Error from cpFromPod - details: \n ${errStream.getContentsAsString()}`
|
|
)
|
|
)
|
|
}
|
|
resolve(status)
|
|
}
|
|
)
|
|
.catch(e => reject(e))
|
|
})
|
|
break
|
|
} catch (error) {
|
|
core.debug(`Attempt ${attempt + 1} failed: ${error}`)
|
|
attempt++
|
|
if (attempt >= 30) {
|
|
throw new Error(
|
|
`execCpFromPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
|
|
)
|
|
}
|
|
await sleep(1000)
|
|
}
|
|
}
|
|
|
|
let attempts = 15
|
|
const delay = 1000
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
const want = await execCalculateOutputHashSorted(
|
|
podName,
|
|
JOB_CONTAINER_NAME,
|
|
['sh', '-c', listDirAllCommand(containerPath)]
|
|
)
|
|
|
|
const got = await localCalculateOutputHashSorted([
|
|
'sh',
|
|
'-c',
|
|
listDirAllCommand(targetRunnerPath)
|
|
])
|
|
|
|
if (got !== want) {
|
|
core.debug(
|
|
`The hash of the directory does not match the expected value; want='${want}' got='${got}'`
|
|
)
|
|
await sleep(delay)
|
|
continue
|
|
}
|
|
|
|
break
|
|
} catch (error) {
|
|
core.debug(`Attempt ${i + 1} failed: ${error}`)
|
|
await sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function waitForJobToComplete(jobName: string): Promise<void> {
|
|
const backOffManager = new BackOffManager()
|
|
while (true) {
|
|
try {
|
|
if (await isJobSucceeded(jobName)) {
|
|
return
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`job ${jobName} has failed: ${JSON.stringify(error)}`)
|
|
}
|
|
await backOffManager.backOff()
|
|
}
|
|
}
|
|
|
|
export async function createDockerSecret(
|
|
registry: Registry
|
|
): Promise<k8s.V1Secret> {
|
|
const authContent = {
|
|
auths: {
|
|
[registry.serverUrl || 'https://index.docker.io/v1/']: {
|
|
username: registry.username,
|
|
password: registry.password,
|
|
auth: Buffer.from(`${registry.username}:${registry.password}`).toString(
|
|
'base64'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const runnerInstanceLabel = new RunnerInstanceLabel()
|
|
|
|
const secretName = getSecretName()
|
|
const secret = new k8s.V1Secret()
|
|
secret.immutable = true
|
|
secret.apiVersion = 'v1'
|
|
secret.metadata = new k8s.V1ObjectMeta()
|
|
secret.metadata.name = secretName
|
|
secret.metadata.namespace = namespace()
|
|
secret.metadata.labels = {
|
|
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
|
}
|
|
secret.type = 'kubernetes.io/dockerconfigjson'
|
|
secret.kind = 'Secret'
|
|
secret.data = {
|
|
'.dockerconfigjson': Buffer.from(JSON.stringify(authContent)).toString(
|
|
'base64'
|
|
)
|
|
}
|
|
|
|
return await k8sApi.createNamespacedSecret({
|
|
namespace: namespace(),
|
|
body: secret
|
|
})
|
|
}
|
|
|
|
export async function createSecretForEnvs(envs: {
|
|
[key: string]: string
|
|
}): Promise<string> {
|
|
const runnerInstanceLabel = new RunnerInstanceLabel()
|
|
|
|
const secret = new k8s.V1Secret()
|
|
const secretName = getSecretName()
|
|
secret.immutable = true
|
|
secret.apiVersion = 'v1'
|
|
secret.metadata = new k8s.V1ObjectMeta()
|
|
secret.metadata.name = secretName
|
|
|
|
secret.metadata.labels = {
|
|
[runnerInstanceLabel.key]: runnerInstanceLabel.value
|
|
}
|
|
secret.kind = 'Secret'
|
|
secret.data = {}
|
|
for (const [key, value] of Object.entries(envs)) {
|
|
secret.data[key] = Buffer.from(value).toString('base64')
|
|
}
|
|
|
|
await k8sApi.createNamespacedSecret({
|
|
namespace: namespace(),
|
|
body: secret
|
|
})
|
|
return secretName
|
|
}
|
|
|
|
export async function deleteSecret(name: string): Promise<void> {
|
|
await k8sApi.deleteNamespacedSecret({
|
|
name,
|
|
namespace: namespace()
|
|
})
|
|
}
|
|
|
|
export async function pruneSecrets(): Promise<void> {
|
|
const secretList = await k8sApi.listNamespacedSecret({
|
|
namespace: namespace(),
|
|
labelSelector: new RunnerInstanceLabel().toString()
|
|
})
|
|
if (!secretList.items.length) {
|
|
return
|
|
}
|
|
|
|
await Promise.all(
|
|
secretList.items.map(
|
|
async secret =>
|
|
secret.metadata?.name && (await deleteSecret(secret.metadata.name))
|
|
)
|
|
)
|
|
}
|
|
|
|
export async function waitForPodPhases(
|
|
podName: string,
|
|
awaitingPhases: Set<PodPhase>,
|
|
backOffPhases: Set<PodPhase>,
|
|
maxTimeSeconds = DEFAULT_WAIT_FOR_POD_TIME_SECONDS
|
|
): Promise<void> {
|
|
const backOffManager = new BackOffManager(maxTimeSeconds)
|
|
let phase: PodPhase = PodPhase.UNKNOWN
|
|
try {
|
|
while (true) {
|
|
phase = await getPodPhase(podName)
|
|
if (awaitingPhases.has(phase)) {
|
|
return
|
|
}
|
|
|
|
if (!backOffPhases.has(phase)) {
|
|
throw new Error(
|
|
`Pod ${podName} is unhealthy with phase status ${phase}`
|
|
)
|
|
}
|
|
await backOffManager.backOff()
|
|
}
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Pod ${podName} is unhealthy with phase status ${phase}: ${JSON.stringify(error)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function getPrepareJobTimeoutSeconds(): number {
|
|
const envTimeoutSeconds =
|
|
process.env['ACTIONS_RUNNER_PREPARE_JOB_TIMEOUT_SECONDS']
|
|
|
|
if (!envTimeoutSeconds) {
|
|
return DEFAULT_WAIT_FOR_POD_TIME_SECONDS
|
|
}
|
|
|
|
const timeoutSeconds = parseInt(envTimeoutSeconds, 10)
|
|
if (!timeoutSeconds || timeoutSeconds <= 0) {
|
|
core.warning(
|
|
`Prepare job timeout is invalid ("${timeoutSeconds}"): use an int > 0`
|
|
)
|
|
return DEFAULT_WAIT_FOR_POD_TIME_SECONDS
|
|
}
|
|
|
|
return timeoutSeconds
|
|
}
|
|
|
|
async function getPodPhase(name: string): Promise<PodPhase> {
|
|
const podPhaseLookup = new Set<string>([
|
|
PodPhase.PENDING,
|
|
PodPhase.RUNNING,
|
|
PodPhase.SUCCEEDED,
|
|
PodPhase.FAILED,
|
|
PodPhase.UNKNOWN
|
|
])
|
|
const pod = await k8sApi.readNamespacedPod({
|
|
name,
|
|
namespace: namespace()
|
|
})
|
|
|
|
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
|
|
return PodPhase.UNKNOWN
|
|
}
|
|
return pod.status?.phase as PodPhase
|
|
}
|
|
|
|
async function isJobSucceeded(name: string): Promise<boolean> {
|
|
const job = await k8sBatchV1Api.readNamespacedJob({
|
|
name,
|
|
namespace: namespace()
|
|
})
|
|
if (job.status?.failed) {
|
|
throw new Error(`job ${name} has failed`)
|
|
}
|
|
return !!job.status?.succeeded
|
|
}
|
|
|
|
export async function getPodLogs(
|
|
podName: string,
|
|
containerName: string
|
|
): Promise<void> {
|
|
const log = new k8s.Log(kc)
|
|
const logStream = new stream.PassThrough()
|
|
logStream.on('data', chunk => {
|
|
// use write rather than console.log to prevent double line feed
|
|
process.stdout.write(chunk)
|
|
})
|
|
|
|
logStream.on('error', err => {
|
|
process.stderr.write(err.message)
|
|
})
|
|
|
|
await log.log(namespace(), podName, containerName, logStream, {
|
|
follow: true,
|
|
pretty: false,
|
|
timestamps: false
|
|
})
|
|
await new Promise(resolve => logStream.on('end', () => resolve(null)))
|
|
}
|
|
|
|
export async function prunePods(): Promise<void> {
|
|
const podList = await k8sApi.listNamespacedPod({
|
|
namespace: namespace(),
|
|
labelSelector: new RunnerInstanceLabel().toString()
|
|
})
|
|
if (!podList.items.length) {
|
|
return
|
|
}
|
|
|
|
await Promise.all(
|
|
podList.items.map(
|
|
async pod => pod.metadata?.name && (await deletePod(pod.metadata.name))
|
|
)
|
|
)
|
|
}
|
|
|
|
export async function getPodStatus(
|
|
name: string
|
|
): Promise<k8s.V1PodStatus | undefined> {
|
|
const pod = await k8sApi.readNamespacedPod({
|
|
name,
|
|
namespace: namespace()
|
|
})
|
|
return pod.status
|
|
}
|
|
|
|
export async function isAuthPermissionsOK(): Promise<boolean> {
|
|
const sar = new k8s.V1SelfSubjectAccessReview()
|
|
const asyncs: Promise<k8s.V1SelfSubjectAccessReview>[] = []
|
|
for (const resource of requiredPermissions) {
|
|
for (const verb of resource.verbs) {
|
|
sar.spec = new k8s.V1SelfSubjectAccessReviewSpec()
|
|
sar.spec.resourceAttributes = new k8s.V1ResourceAttributes()
|
|
sar.spec.resourceAttributes.verb = verb
|
|
sar.spec.resourceAttributes.namespace = namespace()
|
|
sar.spec.resourceAttributes.group = resource.group
|
|
sar.spec.resourceAttributes.resource = resource.resource
|
|
sar.spec.resourceAttributes.subresource = resource.subresource
|
|
asyncs.push(
|
|
k8sAuthorizationV1Api.createSelfSubjectAccessReview({ body: sar })
|
|
)
|
|
}
|
|
}
|
|
const responses = await Promise.all(asyncs)
|
|
return responses.every(resp => resp.status?.allowed)
|
|
}
|
|
|
|
export async function isPodContainerAlpine(
|
|
podName: string,
|
|
containerName: string
|
|
): Promise<boolean> {
|
|
let isAlpine = true
|
|
try {
|
|
await execPodStep(
|
|
[
|
|
'sh',
|
|
'-c',
|
|
`[ $(cat /etc/*release* | grep -i -e "^ID=*alpine*" -c) != 0 ] || exit 1`
|
|
],
|
|
podName,
|
|
containerName
|
|
)
|
|
} catch {
|
|
isAlpine = false
|
|
}
|
|
|
|
return isAlpine
|
|
}
|
|
|
|
export function namespace(): string {
|
|
if (process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']) {
|
|
return process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
|
|
}
|
|
|
|
const context = kc.getContexts().find(ctx => ctx.namespace)
|
|
if (!context?.namespace) {
|
|
throw new Error(
|
|
'Failed to determine namespace, falling back to `default`. Namespace should be set in context, or in env variable "ACTIONS_RUNNER_KUBERNETES_NAMESPACE"'
|
|
)
|
|
}
|
|
return context.namespace
|
|
}
|
|
|
|
class BackOffManager {
|
|
private backOffSeconds = 1
|
|
totalTime = 0
|
|
constructor(private throwAfterSeconds?: number) {
|
|
if (!throwAfterSeconds || throwAfterSeconds < 0) {
|
|
this.throwAfterSeconds = undefined
|
|
}
|
|
}
|
|
|
|
async backOff(): Promise<void> {
|
|
await new Promise(resolve =>
|
|
setTimeout(resolve, this.backOffSeconds * 1000)
|
|
)
|
|
this.totalTime += this.backOffSeconds
|
|
if (this.throwAfterSeconds && this.throwAfterSeconds < this.totalTime) {
|
|
throw new Error('backoff timeout')
|
|
}
|
|
if (this.backOffSeconds < 20) {
|
|
this.backOffSeconds *= 2
|
|
}
|
|
if (this.backOffSeconds > 20) {
|
|
this.backOffSeconds = 20
|
|
}
|
|
}
|
|
}
|
|
|
|
export function containerPorts(
|
|
container: ContainerInfo
|
|
): k8s.V1ContainerPort[] {
|
|
const ports: k8s.V1ContainerPort[] = []
|
|
if (!container.portMappings?.length) {
|
|
return ports
|
|
}
|
|
for (const portDefinition of container.portMappings) {
|
|
const portProtoSplit = portDefinition.split('/')
|
|
if (portProtoSplit.length > 2) {
|
|
throw new Error(`Unexpected port format: ${portDefinition}`)
|
|
}
|
|
|
|
const port = new k8s.V1ContainerPort()
|
|
port.protocol =
|
|
portProtoSplit.length === 2 ? portProtoSplit[1].toUpperCase() : 'TCP'
|
|
|
|
const portSplit = portProtoSplit[0].split(':')
|
|
if (portSplit.length > 2) {
|
|
throw new Error('ports should have at most one ":" separator')
|
|
}
|
|
|
|
const parsePort = (p: string): number => {
|
|
const num = Number(p)
|
|
if (!Number.isInteger(num) || num < 1 || num > 65535) {
|
|
throw new Error(`invalid container port: ${p}`)
|
|
}
|
|
return num
|
|
}
|
|
|
|
if (portSplit.length === 1) {
|
|
port.containerPort = parsePort(portSplit[0])
|
|
} else {
|
|
port.hostPort = parsePort(portSplit[0])
|
|
port.containerPort = parsePort(portSplit[1])
|
|
}
|
|
|
|
ports.push(port)
|
|
}
|
|
return ports
|
|
}
|
|
|
|
export async function getPodByName(name): Promise<k8s.V1Pod> {
|
|
return await k8sApi.readNamespacedPod({
|
|
name,
|
|
namespace: namespace()
|
|
})
|
|
}
|