Skip to content
Closed
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
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ COPY . /app/

RUN npm install && npm run bootstrap && npm run build-all

FROM ghcr.nju.edu.cn/actions/actions-runner:2.329.0
FROM ghcr.nju.edu.cn/actions/actions-runner:2.335.1

USER root

COPY --from=runner_builder /app/packages/k8s/dist/index.js /home/runner/k8s/index.js

RUN chown runner:runner /home/runner/k8s/index.js

USER runner
18 changes: 18 additions & 0 deletions packages/k8s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,31 @@ rules:
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
```
- (Optional, recommended) Granting `events` read access lets the hooks attach
recent `Warning` events (e.g. `FailedScheduling`, `FailedMount`) to the error
message when a pod fails to come online. This permission is **not required** —
if it is missing the hooks still work, they just omit the event section from
diagnostics.
```
- apiGroups: [""]
resources: ["events"]
verbs: ["get", "list"]
```
- The `ACTIONS_RUNNER_POD_NAME` env should be set to the name of the pod
- The `ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER` env should be set to true to prevent the runner from running any jobs outside of a container
- The runner pod should map a persistent volume claim into the `_work` directory
- The `ACTIONS_RUNNER_CLAIM_NAME` env should be set to the persistent volume claim that contains the runner's working directory, otherwise it defaults to `${ACTIONS_RUNNER_POD_NAME}-work`
- Some actions runner env's are expected to be set. These are set automatically by the runner.
- `RUNNER_WORKSPACE` is expected to be set to the workspace of the runner
- `GITHUB_WORKSPACE` is expected to be set to the workspace of the job
- Optional tuning env's
- `ACTIONS_RUNNER_K8S_UNRECOVERABLE_WAITING_REASONS` — comma-separated list of
extra container `waiting` reasons that should be treated as deterministic
terminal failures (fail fast instead of waiting for the timeout). These are
*added* to the built-ins (`ImagePullBackOff`, `ErrImagePull`,
`InvalidImageName`, `CreateContainerConfigError`, `CreateContainerError`);
the built-ins can never be removed. Use with care — only list reasons that
are truly unrecoverable for your workloads (e.g. `CrashLoopBackOff`).


## Limitations
Expand Down
250 changes: 227 additions & 23 deletions packages/k8s/src/k8s/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,181 @@ export async function pruneSecrets(): Promise<void> {
)
}

export const UNRECOVERABLE_WAITING_REASONS = new Set([
'ImagePullBackOff',
'ErrImagePull',
'InvalidImageName',
'CreateContainerConfigError',
'CreateContainerError'
])

// Maximum number of Warning events to include in a pod's failure description so
// that the GitHub Actions log is not flooded.
const MAX_DIAGNOSTIC_EVENTS = 10

// The fast-fail whitelist can be extended (not narrowed) from the environment so
// operators can add deterministic terminal reasons without a code change. This
// reuses the same env-var configuration pattern as the prepare-job timeout. When
// the variable is unset, behaviour is identical to the built-in defaults.
export function getUnrecoverableWaitingReasons(): Set<string> {
const extra = process.env['ACTIONS_RUNNER_K8S_UNRECOVERABLE_WAITING_REASONS']
if (!extra) {
return UNRECOVERABLE_WAITING_REASONS
}
const reasons = new Set(UNRECOVERABLE_WAITING_REASONS)
for (const reason of extra.split(',')) {
const trimmed = reason.trim()
if (trimmed) {
reasons.add(trimmed)
}
}
return reasons
}

export function getContainerErrors(pod: k8s.V1Pod): string[] {
const errors: string[] = []
const unrecoverableReasons = getUnrecoverableWaitingReasons()
const allStatuses = [
...(pod.status?.initContainerStatuses ?? []),
...(pod.status?.containerStatuses ?? [])
]
for (const cs of allStatuses) {
const waiting = cs.state?.waiting
if (waiting?.reason && unrecoverableReasons.has(waiting.reason)) {
errors.push(
`container "${cs.name}": ${waiting.reason}${
waiting.message ? ` - ${waiting.message}` : ''
}`
)
}
}
return errors
}

// describePodFailure aggregates everything that might explain why a pod is not
// healthy into a single human-readable string. It makes NO success/failure
// judgement of its own (a terminated exitCode=0 container is just reported as
// such) -- callers decide what is "bad". It never throws: if it cannot read the
// pod or list events it returns/embeds a best-effort note instead, so it is safe
// to call from any error path.
export async function describePodFailure(podName: string): Promise<string> {
let pod: k8s.V1Pod
try {
pod = await readPod(podName)
} catch (err) {
return `Could not read pod ${podName} for diagnostics: ${
err instanceof Error ? err.message : String(err)
}`
}

const lines: string[] = []
const status = pod.status

if (status?.phase) {
lines.push(
`Phase: ${status.phase}${
status.reason ? ` (reason: ${status.reason})` : ''
}`
)
}
if (status?.message) {
lines.push(`Message: ${status.message}`)
}

// Surface conditions that are blocking readiness, e.g. PodScheduled=False
// which is the only place a FailedScheduling shows up on the pod itself.
for (const cond of status?.conditions ?? []) {
if (cond.status === 'False') {
lines.push(
`Condition ${cond.type}=False${
cond.reason ? ` (reason: ${cond.reason})` : ''
}${cond.message ? `: ${cond.message}` : ''}`
)
}
}

const allStatuses = [
...(status?.initContainerStatuses ?? []),
...(status?.containerStatuses ?? [])
]
for (const cs of allStatuses) {
const waiting = cs.state?.waiting
if (waiting?.reason) {
lines.push(
`Container "${cs.name}" waiting: ${waiting.reason}${
waiting.message ? ` - ${waiting.message}` : ''
}`
)
}
const terminated = cs.state?.terminated
if (terminated) {
lines.push(
`Container "${cs.name}" terminated: ${
terminated.reason ?? 'Unknown'
} (exit code ${terminated.exitCode})${
terminated.message ? ` - ${terminated.message}` : ''
}`
)
}
}

lines.push(...(await describePodWarningEvents(podName)))

if (!lines.length) {
return `No additional diagnostic information available for pod ${podName}`
}
return lines.join('\n')
}

// Reads the most recent Warning events for a pod. Best-effort: listing events
// requires the optional "events" get/list permission, so any error (including
// RBAC Forbidden) is swallowed and an empty list is returned.
async function describePodWarningEvents(podName: string): Promise<string[]> {
let items: k8s.CoreV1Event[]
try {
const { body } = await k8sApi.listNamespacedEvent(
namespace(),
undefined,
undefined,
undefined,
`involvedObject.name=${podName}`
)
items = body.items
} catch (err) {
core.debug(
`Could not list events for pod ${podName} (the 'events' permission may be missing): ${
err instanceof Error ? err.message : String(err)
}`
)
return []
}

const warnings = items.filter(e => e.type === 'Warning')
if (!warnings.length) {
return []
}

const eventTime = (e: k8s.CoreV1Event): number => {
const t = e.lastTimestamp ?? e.eventTime ?? e.firstTimestamp
return t ? new Date(t).getTime() : 0
}
warnings.sort((a, b) => eventTime(a) - eventTime(b))

const recent = warnings.slice(-MAX_DIAGNOSTIC_EVENTS)
const lines = recent.map(e => {
const count = e.count && e.count > 1 ? ` (x${e.count})` : ''
return `Event [Warning] ${e.reason ?? ''}${count}: ${
e.message ?? ''
}`.trim()
})
if (warnings.length > recent.length) {
lines.unshift(
`(showing ${recent.length} of ${warnings.length} warning events)`
)
}
return lines
}

export async function waitForPodPhases(
podName: string,
awaitingPhases: Set<PodPhase>,
Expand All @@ -376,22 +551,48 @@ export async function waitForPodPhases(
): Promise<void> {
const backOffManager = new BackOffManager(maxTimeSeconds)
let phase: PodPhase = PodPhase.UNKNOWN
try {
while (true) {
phase = await getPodPhase(podName)
if (awaitingPhases.has(phase)) {
return
}
while (true) {
const pod = await readPod(podName)
phase = parsePodPhase(pod)
if (awaitingPhases.has(phase)) {
return
}

if (!backOffPhases.has(phase)) {
throw new Error(
`Pod ${podName} is unhealthy with phase status ${phase}`
)
}
// The pod reached a phase we are not willing to keep waiting on
// (a terminal/unhealthy phase). Attach full diagnostics and stop.
if (!backOffPhases.has(phase)) {
const details = await describePodFailure(podName)
throw new Error(
`Pod ${podName} is unhealthy with phase status ${phase}\n${details}`
)
}

// Still in a back-off phase, but a container hit a deterministic terminal
// error (e.g. ImagePullBackOff) -- fail fast with diagnostics instead of
// waiting out the full timeout.
const containerErrors = getContainerErrors(pod)
if (containerErrors.length > 0) {
const details = await describePodFailure(podName)
throw new Error(
`Pod ${podName} has unrecoverable container errors: ${containerErrors.join(
'; '
)}\n${details}`
)
}

try {
await backOffManager.backOff()
} catch (error) {
// BackOffManager throws "backoff timeout" when maxTimeSeconds is exceeded.
// Don't surface that bare message: collect diagnostics first so the user
// can see WHY the pod never became ready. This is the safest improvement
// -- it only runs when we were going to fail anyway and changes no
// success/failure decision.
const details = await describePodFailure(podName)
throw new Error(
`Pod ${podName} is unhealthy: timed out after ${maxTimeSeconds}s in phase ${phase}\n${details}`
)
}
} catch (error) {
throw new Error(`Pod ${podName} is unhealthy with phase status ${phase}`)
}
}

Expand All @@ -414,21 +615,24 @@ export function getPrepareJobTimeoutSeconds(): number {
return timeoutSeconds
}

async function getPodPhase(podName: string): Promise<PodPhase> {
const podPhaseLookup = new Set<string>([
PodPhase.PENDING,
PodPhase.RUNNING,
PodPhase.SUCCEEDED,
PodPhase.FAILED,
PodPhase.UNKNOWN
])
async function readPod(podName: string): Promise<k8s.V1Pod> {
const { body } = await k8sApi.readNamespacedPod(podName, namespace())
const pod = body
return body
}

const podPhaseLookup = new Set<string>([
PodPhase.PENDING,
PodPhase.RUNNING,
PodPhase.SUCCEEDED,
PodPhase.FAILED,
PodPhase.UNKNOWN
])

export function parsePodPhase(pod: k8s.V1Pod): PodPhase {
if (!pod.status?.phase || !podPhaseLookup.has(pod.status.phase)) {
return PodPhase.UNKNOWN
}
return pod.status?.phase as PodPhase
return pod.status.phase as PodPhase
}

async function isJobSucceeded(jobName: string): Promise<boolean> {
Expand Down
Loading
Loading