diff --git a/packages/k8s-workflow/gha-runner-rpc.py b/packages/k8s-workflow/gha-runner-rpc.py new file mode 100755 index 00000000..964012b7 --- /dev/null +++ b/packages/k8s-workflow/gha-runner-rpc.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# This implements a very simple RPC server that should be running on the job container of the workflow pod, +# and used by the k8s hook to execute steps in the workflow on the workflow pod. + +# It supports a running a single RPC call at a time, and will return an error if a new call is made while +# another one is still running (which is a valid assumption, as the runner is expected to execute one step at a time). + + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +import time +from flask import Flask, jsonify, request +from threading import Thread +from waitress import serve + +import argparse +import json +import logging +import os +import signal +import subprocess + +import logging +import json_logging + +app = Flask(__name__) +app.logger.setLevel(logging.DEBUG) +json_logging.init_flask(enable_json=True) +json_logging.init_request_instrument(app) + +@dataclass +class Response: + id: str + status: str + pid: int = None + returncode: int = None + error: str = None + +def readLines(path, fromLine, maxLines): + try: + with open(path, 'r') as f: + return [x for i, x in enumerate(f) if i >= fromLine and x.endswith('\n') and i < fromLine + maxLines] + except Exception as e: + app.logger.warning(f"Error reading file {path}: {e}") + return [] + +class State: + def __init__(self): + self.latest_id = None + self.status = Response(id = "", status = "idle") + self.worker = ThreadPoolExecutor(max_workers=1) + self.future = None + self.process = None + self.out = None + + def __run(self, id, path): + self.latest_id = id + try: + app.logger.debug(f"Running id {id}") + logsfilename = f"/logs/{id}.out" + self.out = open(logsfilename, "w") + self.process = subprocess.Popen(['sh', '-e', path], start_new_session=True, stdout=self.out, stderr=self.out) + app.logger.debug(f"Process for id {id} started with pid {self.process.pid}") + self.status = Response( + id = id, + status = 'running', + pid = self.process.pid + ) + self.process.wait() + self.out.close() + app.logger.debug(f"Process for id {id} finished (return code {self.process.returncode})") + self.status = Response( + id = id, + status = 'completed', + returncode = self.process.returncode, + ) + except Exception as e: + app.logger.error(f"Error starting process: {e}") + self.status = Response( + id = id, + status = 'failed', + error = str(e), + returncode = -1, + ) + + + def exec(self, id, path): + if self.future and not self.future.done(): + app.logger.error(f"A job is already running (ID {self.latest_id})") + return Response( + id = id, + status = 'failed', + error = f"A job is already running (ID {self.latest_id})", + returncode = -1, + ) + + app.logger.debug(f"Queueing job {id} with path {path}") + self.status = Response(id = id, status = "pending") + self.future = self.worker.submit(self.__run, id, path) + return self.status + + def cancel(self): + if not self.future: + return Response( + id = '', + status = 'failed', + error = 'No job has been started yet', + ) + elif self.future.done(): + # The job is already done, no need to cancel + return self.status + else: + app.logger.debug(f"Cancelling {self.latest_id} (pid {self.process.pid})") + os.killpg(os.getpgid(self.process.pid), signal.SIGINT) + + return Response( + id = self.latest_id, + status = 'cancelling', + pid = self.process.pid + ) + +state = State() + +# Post a new job +@app.route('/', methods=['POST']) +def call(): + data = json.loads(request.data) + if 'id' not in data or 'path' not in data: + return jsonify(Response( + id = '', + status = 'failed', + error = 'Missing id or path in request', + )) + id = data['id'] + path = data['path'] + return jsonify(state.exec(id, path)) + +# Cancel the current job +@app.route('/', methods=['DELETE']) +def cancel(): + return jsonify(state.cancel()) + +# Get the current status +@app.route('/') +def status(): + app.logger.debug(f"Status: {state.status}") + return jsonify(state.status) + +# Get the logs of a given job +@app.route('/logs') +def logs(): + if 'id' not in request.args: + return 'Missing id in request', 400 + id = request.args.get('id') + fromLine = int(request.args.get('fromLine', 0)) + maxLines = int(request.args.get('maxLines', 1000)) + path = f"/logs/{id}.out" + return jsonify(readLines(path, fromLine, maxLines)) + + +if __name__ == '__main__': + + parser = argparse.ArgumentParser() + parser.add_argument('--dev', action='store_true', help='Run in Flask development mode') + args = parser.parse_args() + if args.dev: + app.run(host='0.0.0.0', port=8080, debug=True) + else: + serve(app, host='0.0.0.0', port=8080, threads=1) + diff --git a/packages/k8s/package-lock.json b/packages/k8s/package-lock.json index 5db3e036..f90f527d 100644 --- a/packages/k8s/package-lock.json +++ b/packages/k8s/package-lock.json @@ -12,7 +12,7 @@ "@actions/core": "^1.9.1", "@actions/exec": "^1.1.1", "@actions/io": "^1.1.2", - "@kubernetes/client-node": "^0.22.2", + "@kubernetes/client-node": "^0.22.3", "hooklib": "file:../hooklib", "js-yaml": "^4.1.0", "shlex": "^2.1.2" @@ -1173,14 +1173,14 @@ } }, "node_modules/@kubernetes/client-node": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.22.2.tgz", - "integrity": "sha512-PPyzUVunPtgISnWNkCTSLp1SoZ3I13XOanrc8XAQRKp8XTnDF7VT9Sf3/haFmgnpONe4ORCtqrWueGp5fl8yzw==", + "version": "0.22.3", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.22.3.tgz", + "integrity": "sha512-dG8uah3+HDJLpJEESshLRZlAZ4PgDeV9mZXT0u1g7oy4KMRzdZ7n5g0JEIlL6QhK51/2ztcIqURAnjfjJt6Z+g==", "dependencies": { "byline": "^5.0.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", - "jsonpath-plus": "^10.0.0", + "jsonpath-plus": "^10.2.0", "request": "^2.88.0", "rfc4648": "^1.3.0", "stream-buffers": "^3.0.2", @@ -3524,13 +3524,14 @@ } }, "node_modules/jsonpath-plus": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.1.0.tgz", - "integrity": "sha512-gHfV1IYqH8uJHYVTs8BJX1XKy2/rR93+f8QQi0xhx95aCiXn1ettYAd5T+7FU6wfqyDoX/wy0pm/fL3jOKJ9Lg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", + "license": "MIT", "dependencies": { - "@jsep-plugin/assignment": "^1.2.1", - "@jsep-plugin/regex": "^1.0.3", - "jsep": "^1.3.9" + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", @@ -6005,14 +6006,14 @@ "requires": {} }, "@kubernetes/client-node": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.22.2.tgz", - "integrity": "sha512-PPyzUVunPtgISnWNkCTSLp1SoZ3I13XOanrc8XAQRKp8XTnDF7VT9Sf3/haFmgnpONe4ORCtqrWueGp5fl8yzw==", + "version": "0.22.3", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.22.3.tgz", + "integrity": "sha512-dG8uah3+HDJLpJEESshLRZlAZ4PgDeV9mZXT0u1g7oy4KMRzdZ7n5g0JEIlL6QhK51/2ztcIqURAnjfjJt6Z+g==", "requires": { "byline": "^5.0.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", - "jsonpath-plus": "^10.0.0", + "jsonpath-plus": "^10.2.0", "openid-client": "^6.1.3", "request": "^2.88.0", "rfc4648": "^1.3.0", @@ -7826,13 +7827,13 @@ "dev": true }, "jsonpath-plus": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.1.0.tgz", - "integrity": "sha512-gHfV1IYqH8uJHYVTs8BJX1XKy2/rR93+f8QQi0xhx95aCiXn1ettYAd5T+7FU6wfqyDoX/wy0pm/fL3jOKJ9Lg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", "requires": { - "@jsep-plugin/assignment": "^1.2.1", - "@jsep-plugin/regex": "^1.0.3", - "jsep": "^1.3.9" + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" } }, "jsprim": { diff --git a/packages/k8s/package.json b/packages/k8s/package.json index 28fad944..1fc97e47 100644 --- a/packages/k8s/package.json +++ b/packages/k8s/package.json @@ -16,7 +16,7 @@ "@actions/core": "^1.9.1", "@actions/exec": "^1.1.1", "@actions/io": "^1.1.2", - "@kubernetes/client-node": "^0.22.2", + "@kubernetes/client-node": "^0.22.3", "hooklib": "file:../hooklib", "js-yaml": "^4.1.0", "shlex": "^2.1.2" diff --git a/packages/k8s/src/hooks/cleanup-job.ts b/packages/k8s/src/hooks/cleanup-job.ts index acec1064..5b0b7eb0 100644 --- a/packages/k8s/src/hooks/cleanup-job.ts +++ b/packages/k8s/src/hooks/cleanup-job.ts @@ -1,5 +1,5 @@ -import { prunePods, pruneSecrets } from '../k8s' +import { prunePods, pruneServices, pruneSecrets } from '../k8s' export async function cleanupJob(): Promise { - await Promise.all([prunePods(), pruneSecrets()]) + await Promise.all([prunePods(), pruneServices(), pruneSecrets()]) } diff --git a/packages/k8s/src/hooks/prepare-job.ts b/packages/k8s/src/hooks/prepare-job.ts index 89202dcc..0acd1871 100644 --- a/packages/k8s/src/hooks/prepare-job.ts +++ b/packages/k8s/src/hooks/prepare-job.ts @@ -12,9 +12,10 @@ import { containerPorts, createPod, isPodContainerAlpine, - prunePods, + prunePodsAndServices, waitForPodPhases, - getPrepareJobTimeoutSeconds + getPrepareJobTimeoutSeconds, + createService, } from '../k8s' import { containerVolumes, @@ -27,6 +28,7 @@ import { fixArgs } from '../k8s/utils' import { CONTAINER_EXTENSION_PREFIX, JOB_CONTAINER_NAME } from './constants' +import { waitForRpcStatus } from '../k8s/rpc' export async function prepareJob( args: PrepareJobArgs, @@ -36,7 +38,7 @@ export async function prepareJob( throw new Error('Job Container is required.') } - await prunePods() + await prunePodsAndServices() const extension = readExtensionFromFile() await copyExternalsToRoot() @@ -58,7 +60,7 @@ export async function prepareJob( core.debug(`Adding service '${service.image}' to pod definition`) return createContainerSpec( service, - generateContainerName(service.image), + generateContainerName(service), false, extension ) @@ -78,7 +80,7 @@ export async function prepareJob( extension ) } catch (err) { - await prunePods() + await prunePodsAndServices() core.debug(`createPod failed: ${JSON.stringify(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to create job pod: ${message}`) @@ -87,6 +89,17 @@ export async function prepareJob( if (!createdPod?.metadata?.name) { throw new Error('created pod should have metadata.name') } + + let createdService: k8s.V1Service | undefined = undefined + try { + createdService = await createService(createdPod) + } catch (err) { + await prunePodsAndServices() + core.debug(`createService failed: ${JSON.stringify(err)}`) + const message = (err as any)?.response?.body?.message || err + throw new Error(`failed to create job pod: ${message}`) + } + core.debug( `Job pod created, waiting for it to come online ${createdPod?.metadata?.name}` ) @@ -98,8 +111,11 @@ export async function prepareJob( new Set([PodPhase.PENDING]), getPrepareJobTimeoutSeconds() ) + + await waitForRpcStatus(`http://${createdService?.metadata?.name}:8080`) + } catch (err) { - await prunePods() + await prunePodsAndServices() throw new Error(`pod failed to come online with error: ${err}`) } @@ -119,13 +135,14 @@ export async function prepareJob( throw new Error(`failed to determine if the pod is alpine: ${message}`) } core.debug(`Setting isAlpine to ${isAlpine}`) - generateResponseFile(responseFile, args, createdPod, isAlpine) + generateResponseFile(responseFile, args, createdPod, createdService, isAlpine) } function generateResponseFile( responseFile: string, args: PrepareJobArgs, appPod: k8s.V1Pod, + appService: k8s.V1Service, isAlpine ): void { if (!appPod.metadata?.name) { @@ -133,7 +150,8 @@ function generateResponseFile( } const response = { state: { - jobPod: appPod.metadata.name + jobPod: appPod.metadata.name, + jobService: appService.metadata?.name, }, context: {}, isAlpine @@ -159,7 +177,7 @@ function generateResponseFile( if (args.services?.length) { const serviceContainerNames = - args.services?.map(s => generateContainerName(s.image)) || [] + args.services?.map(s => generateContainerName(s)) || [] response.context['services'] = appPod?.spec?.containers ?.filter(c => serviceContainerNames.includes(c.name)) diff --git a/packages/k8s/src/hooks/run-script-step.ts b/packages/k8s/src/hooks/run-script-step.ts index 631946d3..98b6cac3 100644 --- a/packages/k8s/src/hooks/run-script-step.ts +++ b/packages/k8s/src/hooks/run-script-step.ts @@ -2,9 +2,8 @@ import * as fs from 'fs' import * as core from '@actions/core' import { RunScriptStepArgs } from 'hooklib' -import { execPodStep } from '../k8s' +import { rpcPodStep } from '../k8s/rpc' import { writeEntryPointScript } from '../k8s/utils' -import { JOB_CONTAINER_NAME } from './constants' export async function runScriptStep( args: RunScriptStepArgs, @@ -12,7 +11,7 @@ export async function runScriptStep( responseFile ): Promise { const { entryPoint, entryPointArgs, environmentVariables } = args - const { containerPath, runnerPath } = writeEntryPointScript( + const { containerPath, runnerPath, id } = writeEntryPointScript( args.workingDirectory, entryPoint, entryPointArgs, @@ -23,16 +22,14 @@ export async function runScriptStep( args.entryPoint = 'sh' args.entryPointArgs = ['-e', containerPath] try { - await execPodStep( - [args.entryPoint, ...args.entryPointArgs], - state.jobPod, - JOB_CONTAINER_NAME + await rpcPodStep( + id, + containerPath, + state.jobService, ) } catch (err) { core.debug(`execPodStep failed: ${JSON.stringify(err)}`) const message = (err as any)?.response?.body?.message || err - throw new Error(`failed to run script step: ${message}`) - } finally { - fs.rmSync(runnerPath) + throw new Error(`failed to run script step (id ${id}): ${message}`) } } diff --git a/packages/k8s/src/k8s/index.ts b/packages/k8s/src/k8s/index.ts index 120d3ad7..809fe3b5 100644 --- a/packages/k8s/src/k8s/index.ts +++ b/packages/k8s/src/k8s/index.ts @@ -8,6 +8,7 @@ import { getSecretName, getStepPodName, getVolumeClaimName, + JOB_CONTAINER_NAME, RunnerInstanceLabel } from '../hooks/constants' import { @@ -49,6 +50,12 @@ export const requiredPermissions = [ resource: 'pods', subresource: 'log' }, + { + group: '', + verbs: ['get', 'list', 'create', 'delete'], + resource: 'services', + subresource: '' + }, { group: 'batch', verbs: ['get', 'list', 'create', 'delete'], @@ -129,6 +136,26 @@ export async function createPod( return body } +export async function createService( + pod: k8s.V1Pod +): Promise { + const service = new k8s.V1Service() + service.apiVersion = 'v1' + service.kind = 'Service' + service.metadata = new k8s.V1ObjectMeta() + service.metadata.name = getJobPodName() + service.metadata.labels = pod.metadata?.labels + service.metadata.annotations = pod.metadata?.annotations + + service.spec = new k8s.V1ServiceSpec() + service.spec.selector = pod.metadata?.labels + service.spec.ports = [{ port: 8080, targetPort: 8080 }] + + const { body } = await k8sApi.createNamespacedService(namespace(), service) + return body +} + + export async function createJob( container: k8s.V1Container, extension?: k8s.V1PodTemplateSpec @@ -220,6 +247,16 @@ export async function deletePod(podName: string): Promise { ) } +export async function deleteService(svcName: string): Promise { + await k8sApi.deleteNamespacedService( + svcName, + namespace(), + undefined, + undefined, + 0 + ) +} + export async function execPodStep( command: string[], podName: string, @@ -459,6 +496,10 @@ export async function getPodLogs( await new Promise(resolve => r.on('close', () => resolve(null))) } +export async function prunePodsAndServices(): Promise { + await Promise.all([prunePods(), pruneServices()]) +} + export async function prunePods(): Promise { const podList = await k8sApi.listNamespacedPod( namespace(), @@ -479,6 +520,26 @@ export async function prunePods(): Promise { ) } +export async function pruneServices(): Promise { + const svcList = await k8sApi.listNamespacedService( + namespace(), + undefined, + undefined, + undefined, + undefined, + new RunnerInstanceLabel().toString() + ) + if (!svcList.body.items.length) { + return + } + + await Promise.all( + svcList.body.items.map( + svc => svc.metadata?.name && deleteService(svc.metadata.name) + ) + ) +} + export async function getPodStatus( name: string ): Promise { diff --git a/packages/k8s/src/k8s/rpc.ts b/packages/k8s/src/k8s/rpc.ts new file mode 100644 index 00000000..ee7cbd69 --- /dev/null +++ b/packages/k8s/src/k8s/rpc.ts @@ -0,0 +1,150 @@ +import * as core from '@actions/core' +import { log } from 'console' + +interface RpcResult { + id: string, + status: string, + pid?: number, + returncode?: number, + error?: string + +} + +async function startRpc(url: string, id: string, containerPath: string): Promise { + + new Promise((resolve) => { + process.on('SIGINT', () => { + core.warning('Received SIGINT, terminating'); + const request = new Request(`${url}/`, { method: 'DELETE' }) + fetch(request).then(() => resolve()); + }) + process.on('SIGTERM', () => { + core.warning('Received SIGTERM, terminating'); + const request = new Request(`${url}/`, { method: 'DELETE' }) + fetch(request).then(() => resolve()); + }) + }); + + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + core.debug(`Starting rpc with id ${id} and containerPath ${containerPath} at url ${url}`) + const request = new Request( + url, + { + method: 'POST', + headers: headers, + body: JSON.stringify({ "id": id, "path": containerPath }) + }) + const response = await fetch(request) + const status = await response.json() + if (status.status === 'failed' && status.id === id) { + throw new Error(`rpc failed to start: ${status.error}`) + } + return await waitForRpcStatus(url, id); +} + +async function getRpcStatus(url: string): Promise { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + const request = new Request(url, { method: 'GET', headers: headers }) + const response = await fetch(request) + return response.json() +} + +async function getLogs(url: string, id: string, fromLine: number): Promise { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + const params = new URLSearchParams({ + id: id, + fromLine: fromLine.toString() + }) + const request = new Request(`${url}?${params.toString()}`, { method: 'GET', headers: headers }) + const response = await fetch(request) + return response.json() +} + +async function flushLogs(url: string, id: string, beginLogsAfterLine: number): Promise { + let logLines = 0 + while (true) { + const logs = await getLogs(`${url}/logs`, id, beginLogsAfterLine) + logs.forEach(line => process.stdout.write(line)) + logLines += logs.length + beginLogsAfterLine += logs.length + + if (logs.length === 0) { + return logLines + } + } +} + +async function getLogsAndStatus(url: string, id: string, beginLogsAfterLine: number): Promise<{ status: RpcResult, logLines: number }> { + + const status = await getRpcStatus(url) + + if (status.id !== id) { + throw new Error(`unexpected id in status: ${status.id} (expected ${id})`) + } + + const logLines = await flushLogs(url, id, beginLogsAfterLine) + + return { + status: status, + logLines: logLines, + } +} + +async function awaitRpcCompletion(url: string, id: string): Promise { + + let { status, logLines } = await getLogsAndStatus(url, id, 0) + + while (status.status !== 'completed' && status.status !== 'failed') { + await new Promise(resolve => setTimeout(resolve, 1000)) + const logAndStatus = await getLogsAndStatus(url, id, logLines) + logLines += logAndStatus.logLines + status = logAndStatus.status + } + if (status.status === 'failed') { + await flushLogs(url, id, logLines) + throw new Error(`rpc failed: ${status.error}`) + } else if (status.status !== 'completed') { + throw new Error(`rpc failed: unexpected status ${status.status}`) + } else if (status.returncode !== 0) { + await flushLogs(url, id, logLines) + throw new Error(`step failed with return code ${status.returncode}`) + } + await flushLogs(url, id, logLines) + return status +} + +export async function rpcPodStep( + id: string, + containerPath: string, + serviceName: string +): Promise { + const url = `http://${serviceName}:8080` + const startStatus = await startRpc(url, id, containerPath) + if (startStatus.status === 'failed') { + throw new Error(`rpc failed to start: ${startStatus.error}`) + } + await awaitRpcCompletion(url, id) +} + +export async function waitForRpcStatus(url: string, expectedId?: string): Promise { + while (true) { + try { + const status = await getRpcStatus(url) + if (!expectedId || status.id === expectedId) { + return status + } + } catch (err) { + core.debug(`failed getting RPC status, not yet ready: ${JSON.stringify(err)}`) + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } +} diff --git a/packages/k8s/src/k8s/utils.ts b/packages/k8s/src/k8s/utils.ts index e46af8b8..3dd31a5f 100644 --- a/packages/k8s/src/k8s/utils.ts +++ b/packages/k8s/src/k8s/utils.ts @@ -2,15 +2,19 @@ import * as k8s from '@kubernetes/client-node' import * as fs from 'fs' import * as yaml from 'js-yaml' import * as core from '@actions/core' -import { Mount } from 'hooklib' +import { ServiceContainerInfo, Mount } from 'hooklib' import * as path from 'path' import { v1 as uuidv4 } from 'uuid' import { POD_VOLUME_NAME } from './index' import { CONTAINER_EXTENSION_PREFIX } from '../hooks/constants' import * as shlex from 'shlex' -export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [`-f`, `/dev/null`] -export const DEFAULT_CONTAINER_ENTRY_POINT = 'tail' +// For now, we assume that the entry point script exists in the workflow container image, +// and just invoke it. If this were to be generalized and reused outside of our own repo, +// we should probably remove this assumption and e.g. mount the entrypoint into that pod +// via a configMap instead. +export const DEFAULT_CONTAINER_ENTRY_POINT_ARGS = [] +export const DEFAULT_CONTAINER_ENTRY_POINT = '/gha-runner-rpc.py' export const ENV_HOOK_TEMPLATE_PATH = 'ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE' export const ENV_USE_KUBE_SCHEDULER = 'ACTIONS_RUNNER_USE_KUBE_SCHEDULER' @@ -110,7 +114,7 @@ export function writeEntryPointScript( entryPointArgs?: string[], prependPath?: string[], environmentVariables?: { [key: string]: string } -): { containerPath: string; runnerPath: string } { +): { containerPath: string; runnerPath: string, id: string } { let exportPath = '' if (prependPath?.length) { // TODO: remove compatibility with typeof prependPath === 'string' as we bump to next major version, the hooks will lose PrependPath compat with runners 2.293.0 and older @@ -151,23 +155,39 @@ exec ${environmentPrefix} ${entryPoint} ${ entryPointArgs?.length ? entryPointArgs.join(' ') : '' } ` - const filename = `${uuidv4()}.sh` + const id = uuidv4() + const filename = `${id}.sh` const entryPointPath = `${process.env.RUNNER_TEMP}/${filename}` fs.writeFileSync(entryPointPath, content) return { containerPath: `/__w/_temp/${filename}`, - runnerPath: entryPointPath + runnerPath: entryPointPath, + id: id, } } -export function generateContainerName(image: string): string { +export function generateContainerName(service: ServiceContainerInfo): string { + const image = service.image const nameWithTag = image.split('/').pop() - const name = nameWithTag?.split(':').at(0) + let name = nameWithTag?.split(':').at(0) if (!name) { throw new Error(`Image definition '${image}' is invalid`) } + if (service.createOptions) { + const optionsArr = service.createOptions.split(/[ ]+/) + for (let i = 0; i < optionsArr.length; i++) { + if (optionsArr[i] === '--name') { + if (i + 1 >= optionsArr.length) { + throw new Error(`Invalid create options: ${service.createOptions} (missing a value after --name)`) + } + name = optionsArr[++i] + core.debug(`Overriding service container name with: ${name}`) + } + } + } + return name }