Skip to content
Merged
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
102 changes: 66 additions & 36 deletions .github/workflows/build-image.yaml
Original file line number Diff line number Diff line change
@@ -1,51 +1,81 @@
name: Build runner image
name: Build & Push runner image

# 用 runner-versions.yaml 里的每个底座版本构建自有 runner 镜像。
# 底座版本升级只改 .github/runner-versions.yaml,不改 Dockerfile。
# 默认只构建(不推送);配置好 registry 后取消 push 相关步骤的注释。
# 手动触发:构建并推送自有 runner 镜像到 SWR。
# 不在 push 时自动跑(省资源、避免堆镜像 tag),也不创建 git tag。
# 触发:Actions 页面 → Run workflow,可选填 runner_version / image_tag。
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
runner_version:
description: 'runner base version (default: latest in .github/runner-versions.yaml)'
type: string
required: false
image_tag:
description: 'image tag (default: <runner-version>-<branch>-<shortsha>)'
type: string
required: false

env:
REGISTRY: swr.cn-southwest-2.myhuaweicloud.com
IMAGE_NAME: swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/runner
BASE_REGISTRY: ghcr.io

concurrency:
group: build-image-${{ github.ref }}
cancel-in-progress: false

jobs:
versions:
resolve:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.read.outputs.matrix }}
runner_version: ${{ steps.r.outputs.runner_version }}
image_tag: ${{ steps.r.outputs.image_tag }}
steps:
- uses: actions/checkout@v5
- id: read
- id: r
run: |
matrix=$(grep -E '^\s*-\s*"' .github/runner-versions.yaml \
| sed 's/.*"\(.*\)".*/\1/' \
| jq -R -s -c 'split("\n")[:-1]')
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
rv="${{ inputs.runner_version }}"
if [ -z "$rv" ]; then
rv=$(grep -E '^\s*-\s*"' .github/runner-versions.yaml \
| sed 's/.*"\(.*\)".*/\1/' | sort -V | tail -1)
fi
echo "runner_version=${rv}" >> "$GITHUB_OUTPUT"

it="${{ inputs.image_tag }}"
if [ -z "$it" ]; then
short=$(printf '%s' "$GITHUB_SHA" | cut -c1-6)
branch=$(printf '%s' "${GITHUB_REF_NAME}" | sed -E 's#[^A-Za-z0-9_.-]#-#g')
it="${rv}-${branch}-${short}"
fi
it="${it:0:120}"
echo "image_tag=${it}" >> "$GITHUB_OUTPUT"
echo "runner_version=${rv} image_tag=${it}"

build:
needs: versions
needs: resolve
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runner_version: ${{ fromJson(needs.versions.outputs.matrix) }}
steps:
- uses: actions/checkout@v5
- name: Build image for runner ${{ matrix.runner_version }}
run: |
docker build \
--build-arg RUNNER_VERSION=${{ matrix.runner_version }} \
-t runner-container-hooks:${{ matrix.runner_version }}-${{ github.ref_name }} \
.
# - name: Log in to registry
# uses: docker/login-action@v3
# with:
# registry: ${{ vars.REGISTRY }}
# username: ${{ secrets.REGISTRY_USERNAME }}
# password: ${{ secrets.REGISTRY_PASSWORD }}
# - name: Push image
# run: |
# docker tag runner-container-hooks:${{ matrix.runner_version }}-${{ github.ref_name }} \
# ${{ vars.REGISTRY }}/runner:${{ matrix.runner_version }}-${{ github.ref_name }}
# docker push ${{ vars.REGISTRY }}/runner:${{ matrix.runner_version }}-${{ github.ref_name }}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- name: Login to SWR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.SWR_USERNAME }}
password: ${{ secrets.SWR_PASSWORD }}

- name: Build and push (linux/amd64, linux/arm64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
build-args: |
RUNNER_VERSION=${{ needs.resolve.outputs.runner_version }}
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.image_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
15 changes: 13 additions & 2 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,20 @@ git merge vX.Y.0 # 合并上游新版本 tag

## 自有 runner 镜像

- 底座版本集中在 `.github/runner-versions.yaml`,升级 runner 只改此文件。
由 GitHub Actions(`.github/workflows/build-image.yaml`)**手动触发**构建并推送到 SWR(替换原 Jenkins 流水线,不在 push 时自动跑,省资源)。

- 入口:Actions → **Build & Push runner image** → Run workflow(可选填 `runner_version`、`image_tag`)。
- 底座版本集中在 `.github/runner-versions.yaml`,默认取**最新版**;手动运行时可指定。
- `Dockerfile` 通过 `--build-arg RUNNER_VERSION=<version>` 接收底座版本。
- 镜像 tag 编码两个维度:`<runner-version>-<hooks-tag>`,例如 `2.334.0-v0.8.1-ascend.1`。

镜像 tag 约定:

| 场景 | 镜像 tag | 说明 |
|---|---|---|
| 生产 | `<runner>-vX.Y.Z-ascend.N` | 手动运行并指定 `image_tag`,不可变 |
| 测试 | `<runner>-<branch>-<短sha>` | 不填 `image_tag` 时的默认值,可变可弃 |

- 需要 secrets:`SWR_USERNAME`(含 region 前缀的完整用户名)、`SWR_PASSWORD`;镜像名与仓库见 workflow 的 `IMAGE_NAME`。

## 回滚

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion packages/docker/src/utils/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { optionsWithDockerEnvs, sanitize, fixArgs, checkEnvironment } from './index'
import {
optionsWithDockerEnvs,
sanitize,
fixArgs,
checkEnvironment
} from './index'

describe('sanitize', () => {
it('strips non-alphanumeric chars except underscore', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/k8s/src/hooks/prepare-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ export async function prepareJob(
// The boundary may be '"\nHeaders:' (real newline) or the literal
// string ends before Headers — use the last '"' before 'Headers:' as fallback
const headersIdx = raw.indexOf('Headers:')
const bodyEnd = headersIdx !== -1
? raw.lastIndexOf('"', headersIdx) // last " before Headers:
: raw.indexOf('"\nHeaders:')
const bodyEnd =
headersIdx !== -1
? raw.lastIndexOf('"', headersIdx) // last " before Headers:
: raw.indexOf('"\nHeaders:')
if (bodyStart !== -1 && bodyEnd !== -1 && bodyEnd > bodyStart) {
// Body content is a JSON string literal (without surrounding quotes).
// Wrap it in quotes and JSON.parse to properly unescape \" \\ \n \t etc.
Expand Down
4 changes: 2 additions & 2 deletions packages/k8s/src/hooks/run-container-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ export async function runContainerStep(
}
} catch (error) {
try {
const pod = await getPodByName(podName)
const terminatedErrors = getContainerTerminatedErrors(pod)
const fetchedPod = await getPodByName(podName)
const terminatedErrors = getContainerTerminatedErrors(fetchedPod)
if (terminatedErrors.length > 0) {
const details = await describePodFailure(podName)
core.error(
Expand Down
12 changes: 10 additions & 2 deletions packages/k8s/src/hooks/run-script-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import * as fs from 'fs'
import * as core from '@actions/core'
import { RunScriptStepArgs } from 'hooklib'
import { execCpFromPod, execCpToPod, execPodStep, execPodStepWithOutput } from '../k8s'
import {
execCpFromPod,
execCpToPod,
execPodStep,
execPodStepWithOutput
} from '../k8s'
import { writeRunScript } from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants'
import { dirname } from 'path'
Expand Down Expand Up @@ -84,7 +89,10 @@ export async function runScriptStep(
}
} catch (err) {
core.debug(`execPodStep failed: ${JSON.stringify(err)}`)
if (err instanceof Error && err.message.startsWith('failed to run script step')) {
if (
err instanceof Error &&
err.message.startsWith('failed to run script step')
) {
throw err
}
const message = (err as any)?.response?.body?.message || err
Expand Down
13 changes: 7 additions & 6 deletions packages/k8s/src/k8s/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,30 +300,30 @@ export async function execPodStepWithOutput(

// Ring buffer of the last N non-empty lines (cap to keep memory bounded).
const buffer: string[] = []
const push = (line: string) => {
const push = (line: string): void => {
if (line.length === 0) return
buffer.push(line)
if (buffer.length > tailLines) buffer.shift()
}

// Separate pending buffers per stream to prevent stdout/stderr interleaving.
let pendingOut = ''
const ingestOut = (chunk: Buffer | string) => {
const ingestOut = (chunk: Buffer | string): void => {
pendingOut += chunk.toString('utf8')
const lines = pendingOut.split(/\r?\n/)
pendingOut = lines.pop() ?? ''
for (const line of lines) push(line)
}

let pendingErr = ''
const ingestErr = (chunk: Buffer | string) => {
const ingestErr = (chunk: Buffer | string): void => {
pendingErr += chunk.toString('utf8')
const lines = pendingErr.split(/\r?\n/)
pendingErr = lines.pop() ?? ''
for (const line of lines) push(line)
}

const flushPending = () => {
const flushPending = (): void => {
if (pendingOut) {
push(pendingOut)
pendingOut = ''
Expand Down Expand Up @@ -682,7 +682,7 @@ export async function execCpFromPod(
errStream,
null,
false,
async _status => {
async () => {
if (errStream.size()) {
reject(
new Error(
Expand Down Expand Up @@ -1493,6 +1493,7 @@ async function describePodWarningEvents(podName: string): Promise<string[]> {
// until the timeout, not terminate early. This function always returns [] as a
// result, but is kept so checkUnrecoverableErrors compiles and the dedup logic
// remains intact for future use.
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature kept for callers; always returns []
export function getPodConditionErrors(_pod: k8s.V1Pod): string[] {
return []
}
Expand Down Expand Up @@ -1610,7 +1611,7 @@ export async function waitForPodPhases(

try {
await backOffManager.backOff()
} catch (error) {
} catch {
// 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.
Expand Down
39 changes: 29 additions & 10 deletions packages/k8s/src/k8s/utils/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ describe('generateContainerName', () => {
expect(generateContainerName('postgres:latest')).toBe('postgres')
expect(generateContainerName('postgres')).toBe('postgres')
expect(
generateContainerName('public.ecr.aws/url/with/multiple/slashes/postgres:latest')
generateContainerName(
'public.ecr.aws/url/with/multiple/slashes/postgres:latest'
)
).toBe('postgres')
})

it('throws on invalid image string', () => {
expect(() => generateContainerName(':latest')).toThrow()
expect(() => generateContainerName('localstack/localstack/:latest')).toThrow()
expect(() =>
generateContainerName('localstack/localstack/:latest')
).toThrow()
})
})

Expand Down Expand Up @@ -144,7 +148,10 @@ describe('writeRunScript', () => {
})

it('returns containerPath and runnerPath', () => {
const { containerPath, runnerPath } = writeRunScript('/work', 'sh', ['-e', 'script.sh'])
const { containerPath, runnerPath } = writeRunScript('/work', 'sh', [
'-e',
'script.sh'
])
expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/)
expect(runnerPath).toContain(tmpDir)
expect(fs.existsSync(runnerPath)).toBe(true)
Expand Down Expand Up @@ -175,7 +182,7 @@ describe('writeRunScript', () => {

it('throws if env key contains "$"', () => {
expect(() =>
writeRunScript('/work', 'sh', [], [], { 'BAD$KEY': 'val' })
writeRunScript('/work', 'sh', [], [], { BAD$KEY: 'val' })
).toThrow()
})
})
Expand Down Expand Up @@ -205,9 +212,9 @@ describe('writeContainerStepScript', () => {
})

it('throws on invalid working directory', () => {
expect(() =>
writeContainerStepScript(tmpDir, 'tooshort', 'sh')
).toThrow('Invalid working directory')
expect(() => writeContainerStepScript(tmpDir, 'tooshort', 'sh')).toThrow(
'Invalid working directory'
)
})

it('throws if env key contains invalid chars', () => {
Expand All @@ -234,16 +241,28 @@ describe('prepareJobScript', () => {

it('returns paths and file exists', () => {
const { containerPath, runnerPath } = prepareJobScript([
{ sourceVolumePath: '/src', targetVolumePath: '/mnt/data', readOnly: false }
{
sourceVolumePath: '/src',
targetVolumePath: '/mnt/data',
readOnly: false
}
])
expect(containerPath).toMatch(/\/__w\/_temp\/.*\.sh/)
expect(fs.existsSync(runnerPath)).toBe(true)
})

it('includes mkdir for each mount target', () => {
const { runnerPath } = prepareJobScript([
{ sourceVolumePath: '/s1', targetVolumePath: '/mnt/vol1', readOnly: false },
{ sourceVolumePath: '/s2', targetVolumePath: '/mnt/vol2', readOnly: false }
{
sourceVolumePath: '/s1',
targetVolumePath: '/mnt/vol1',
readOnly: false
},
{
sourceVolumePath: '/s2',
targetVolumePath: '/mnt/vol2',
readOnly: false
}
])
const content = fs.readFileSync(runnerPath, 'utf8')
expect(content).toContain('/mnt/vol1')
Expand Down
Loading
Loading