Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/k8s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ rules:
- `GITHUB_WORKSPACE` is expected to be set to the workspace of the job


## Pre-seeded externals (opt-in)

On every job the `fs-init` init container moves the runner image's
`/home/runner/externals` (the bundled Node runtimes, roughly 600 MB in ~9,000
files) into the job pod's `externals` volume before the job container starts.
A platform that can provision that volume already populated — for example
from a node-local copy of the pinned runner image's externals — can skip the
move:

- Supply the `externals` volume through the hook template
(`ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE`, `spec.volumes`, `name: externals`),
pre-populated with the runner version's externals and a marker file
`.externals-seeded-<runner version>` whose content is that version. The
volume must be readable and writable by uid/gid 1001, like the emptyDir it
replaces.
- Set `ACTIONS_RUNNER_PRESEEDED_EXTERNALS_VERSION` on the runner to that runner
version (the runner exports no version to the hook, so it is declared beside
the image).

`fs-init` then checks the marker inside the mounted volume and skips the move
only when it is present with the expected version as its content; a missing
or mismatched marker (a volume seeded for another runner version, or not
seeded at all) falls back to the move. Unset, the env changes nothing: the
`externals` volume is the emptyDir and the move runs as before. With the env
set but no `externals` volume in the template, the emptyDir is used and the
move runs.

## Limitations
- A [job containers](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container) will be required for all jobs
- Building container actions from a dockerfile is not supported at this time
Expand Down
34 changes: 28 additions & 6 deletions packages/k8s/src/k8s/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {
sleep,
EXTERNALS_VOLUME_NAME,
GITHUB_VOLUME_NAME,
WORK_VOLUME
WORK_VOLUME,
ENV_PRESEEDED_EXTERNALS_VERSION,
preseededExternalsVersion,
externalsInitCommand,
extensionSuppliesVolume
} from './utils'
import * as shlex from 'shlex'
import { parsePositiveMsEnv, WebSocketHeartbeat } from './heartbeat'
Expand Down Expand Up @@ -105,11 +109,13 @@ export async function createJobPod(
const githubWorkspace = process.env.GITHUB_WORKSPACE
const workingDirPath = githubWorkspace?.split('/').slice(-2).join('/') ?? ''

const preseededVersion = preseededExternalsVersion()

const initCommands = [
'mkdir -p /mnt/externals',
'mkdir -p /mnt/work',
'mkdir -p /mnt/github',
'mv /home/runner/externals/* /mnt/externals/'
externalsInitCommand(preseededVersion)
]

if (workingDirPath) {
Expand All @@ -127,6 +133,13 @@ export async function createJobPod(
runAsGroup: 1001,
runAsUser: 1001
},
...(preseededVersion
? {
env: [
{ name: ENV_PRESEEDED_EXTERNALS_VERSION, value: preseededVersion }
]
}
: {}),
volumeMounts: [
{
name: EXTERNALS_VOLUME_NAME,
Expand All @@ -146,11 +159,20 @@ export async function createJobPod(

appPod.spec.restartPolicy = 'Never'

appPod.spec.volumes = [
{
appPod.spec.volumes = []
// With the pre-seed opt-in the platform supplies the externals volume
// through the extension (the default emptyDir could never hold the seed);
// without one, fall back to the emptyDir and fs-init copies as usual.
if (
!preseededVersion ||
!extensionSuppliesVolume(EXTERNALS_VOLUME_NAME, extension)
) {
appPod.spec.volumes.push({
name: EXTERNALS_VOLUME_NAME,
emptyDir: {}
},
})
}
appPod.spec.volumes.push(
{
name: GITHUB_VOLUME_NAME,
emptyDir: {}
Expand All @@ -159,7 +181,7 @@ export async function createJobPod(
name: WORK_VOLUME,
emptyDir: {}
}
]
)

if (registry) {
const secret = await createDockerSecret(registry)
Expand Down
36 changes: 36 additions & 0 deletions packages/k8s/src/k8s/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail'

export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE'
export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER'
export const ENV_PRESEEDED_EXTERNALS_VERSION =
'ACTIONS_RUNNER_PRESEEDED_EXTERNALS_VERSION'

export const EXTERNALS_VOLUME_NAME = 'externals'
export const GITHUB_VOLUME_NAME = 'github'
Expand Down Expand Up @@ -269,6 +271,40 @@ export function useKubeScheduler(): boolean {
return process.env[ENV_USE_KUBE_SCHEDULER] === 'true'
}

// Opt-in: the platform pre-seeded the `externals` volume it supplies through
// the hook template with this runner version's externals, and left the marker
// `.externals-seeded-<version>` (content: the version) beside them.
export function preseededExternalsVersion(): string | undefined {
return process.env[ENV_PRESEEDED_EXTERNALS_VERSION] || undefined
}

// The fs-init command that seeds /mnt/externals. Without the opt-in this is
// the unconditional move. With it, the move is skipped only when the marker
// for the version named in the environment is present with that version as
// its content; the version is read from the init container's environment so
// it never has to be quoted into the script.
export function externalsInitCommand(preseededVersion?: string): string {
const move = 'mv /home/runner/externals/* /mnt/externals/'
if (!preseededVersion) {
return move
}
const version = `$${ENV_PRESEEDED_EXTERNALS_VERSION}`
const marker = `/mnt/externals/.externals-seeded-${version}`
return [
`if [ "$(cat "${marker}" 2>/dev/null)" = "${version}" ]`,
`then echo "externals pre-seeded for runner ${version}; skipping the copy"`,
`else ${move}`,
'fi'
].join('; ')
}

export function extensionSuppliesVolume(
name: string,
extension?: k8s.V1PodTemplateSpec
): boolean {
return extension?.spec?.volumes?.some(v => v.name === name) ?? false
}

export enum PodPhase {
PENDING = 'Pending',
RUNNING = 'Running',
Expand Down
97 changes: 96 additions & 1 deletion packages/k8s/tests/k8s-utils-test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { execFileSync } from 'child_process'
import { containerPorts } from '../src/k8s'
import {
generateContainerName,
writeRunScript,
mergePodSpecWithOptions,
mergeContainerWithOptions,
readExtensionFromFile,
ENV_HOOK_TEMPLATE_PATH
ENV_HOOK_TEMPLATE_PATH,
ENV_PRESEEDED_EXTERNALS_VERSION,
EXTERNALS_VOLUME_NAME,
preseededExternalsVersion,
externalsInitCommand,
extensionSuppliesVolume
} from '../src/k8s/utils'
import * as k8s from '@kubernetes/client-node'
import { TestHelper } from './test-setup'
Expand Down Expand Up @@ -228,6 +236,93 @@ describe('k8s utils', () => {
})
})

describe('pre-seeded externals', () => {
const unconditionalMove = 'mv /home/runner/externals/* /mnt/externals/'

afterEach(() => {
delete process.env[ENV_PRESEEDED_EXTERNALS_VERSION]
})

it('should not opt in when the env is unset or empty', () => {
delete process.env[ENV_PRESEEDED_EXTERNALS_VERSION]
expect(preseededExternalsVersion()).toBeUndefined()
process.env[ENV_PRESEEDED_EXTERNALS_VERSION] = ''
expect(preseededExternalsVersion()).toBeUndefined()
})

it('should read the version from the env', () => {
process.env[ENV_PRESEEDED_EXTERNALS_VERSION] = '2.336.0'
expect(preseededExternalsVersion()).toBe('2.336.0')
})

it('should keep the unconditional move without the opt-in', () => {
expect(externalsInitCommand(undefined)).toBe(unconditionalMove)
})

it('should gate the move on the marker with the opt-in', () => {
const command = externalsInitCommand('2.336.0')
expect(command).toContain(unconditionalMove)
expect(command).toContain(
`/mnt/externals/.externals-seeded-$${ENV_PRESEEDED_EXTERNALS_VERSION}`
)
// the version is read from the init container's env, never inlined
expect(command).not.toContain('2.336.0')
})

it('should skip the move only when the marker matches', () => {
const command = externalsInitCommand('2.336.0')
const externals = fs.mkdtempSync(path.join(os.tmpdir(), 'externals-'))
const marker = path.join(externals, '.externals-seeded-2.336.0')
const run = (version: string): string =>
execFileSync(
'sh',
['-c', command.split('/mnt/externals').join(externals)],
{
env: {
PATH: process.env.PATH,
[ENV_PRESEEDED_EXTERNALS_VERSION]: version
},
stdio: ['ignore', 'pipe', 'ignore']
}
).toString()
try {
// no marker: the move runs (and fails here, since there is no source)
expect(() => run('2.336.0')).toThrow()
// marker for another version: the move runs
fs.writeFileSync(marker, '2.336.0')
expect(() => run('2.337.0')).toThrow()
// marker with the wrong content: the move runs
fs.writeFileSync(marker, 'something else')
expect(() => run('2.336.0')).toThrow()
// marker present with the version as its content: skipped
fs.writeFileSync(marker, '2.336.0\n')
expect(run('2.336.0')).toContain('externals pre-seeded')
} finally {
fs.rmSync(externals, { recursive: true })
}
})

it('should tell whether the extension supplies a volume', () => {
expect(extensionSuppliesVolume(EXTERNALS_VOLUME_NAME, undefined)).toBe(
false
)
expect(extensionSuppliesVolume(EXTERNALS_VOLUME_NAME, {})).toBe(false)
expect(
extensionSuppliesVolume(EXTERNALS_VOLUME_NAME, {
spec: { containers: [], volumes: [{ name: 'other' }] }
})
).toBe(false)
expect(
extensionSuppliesVolume(EXTERNALS_VOLUME_NAME, {
spec: {
containers: [],
volumes: [{ name: EXTERNALS_VOLUME_NAME, hostPath: { path: '/x' } }]
}
})
).toBe(true)
})
})

describe('read extension', () => {
beforeEach(async () => {
testHelper = new TestHelper()
Expand Down