Compare commits

..

26 Commits

Author SHA1 Message Date
Ferenc Hammerl
56f935a374 Merge branch 'main' of https://github.com/actions/runner-container-hooks into fhammerl+nikola-jokic/kaniko 2023-01-04 11:27:54 +01:00
Nikola Jokic
7271e71008 managed to execute docker hub push and execute that image 2022-10-24 13:13:16 +02:00
Nikola Jokic
e33f331739 included secretName 2022-10-21 16:56:30 +02:00
Nikola Jokic
11de25a121 refactored the api to accept remote registry, not complete yet 2022-10-21 16:03:14 +02:00
Nikola Jokic
4e674e284a moved from random string generation to uuidv4() 2022-10-18 12:14:28 +02:00
Nikola Jokic
f841b42f55 run script step path repaired 2022-10-04 16:01:55 +02:00
Nikola Jokic
66566368e0 added backoff if NotFound on getPodPhase 2022-10-04 15:14:48 +02:00
Nikola Jokic
79262ba5fb format applied 2022-09-30 12:10:12 +02:00
Nikola Jokic
0cb9e396ea fixed env variable name in test 2022-09-30 12:06:40 +02:00
Nikola Jokic
b696059824 checked out k8s and docker package-lock from main 2022-09-30 11:05:16 +02:00
Nikola Jokic
365a99a4de Removed exposing git token for kaniko, removed testing comments, added
wait for kaniko
2022-09-29 16:03:15 +02:00
Nikola Jokic
02f00d0fd5 removed unnecessary permissions 2022-09-29 13:27:23 +02:00
Nikola Jokic
5e916d49cc upgraded package lock on hooklib 2022-09-29 11:03:48 +02:00
Nikola Jokic
a29f87c874 repaired lock files on docker and k8s 2022-09-29 10:52:41 +02:00
Nikola Jokic
6de86a9ef4 repaired lock to version 2 2022-09-29 10:48:20 +02:00
Nikola Jokic
31a2cda987 added ACTIONS_ prefix and added cleanup kaniko pod 2022-09-29 10:46:03 +02:00
Nikola Jokic
67d3f481f5 extracted creating a registry to test, written basic test expecting not to throw an exception 2022-09-28 16:01:07 +02:00
Nikola Jokic
5b7b738864 formatted kaniko.ts 2022-09-27 12:40:45 +02:00
Ferenc Hammerl
a99346d1ab Actually run built image 2022-09-26 13:57:51 +00:00
Ferenc Hammerl
3d102fd372 Mount volume with ahrdcoded path 2022-09-26 11:50:02 +00:00
Nikola Jokic
4de51ee6a5 random handle and random image name 2022-09-21 17:23:16 +02:00
Nikola Jokic
c8e272367f Merge branch 'main' into nikola-jokic/kaniko 2022-09-21 15:32:21 +02:00
Nikola Jokic
c4aa97c974 included generation of random handle/image 2022-09-21 15:29:39 +02:00
Nikola Jokic
f400db92cc Fixed invocation of registry. Basic run works hardcoded
Console logs are left in place and should be deleted
2022-09-21 13:54:25 +02:00
Nikola Jokic
5f0dc3f3b6 created base resource deffinitions for registry and kaniko 2022-09-21 10:39:04 +02:00
Nikola Jokic
6ef042836f fixing defaulting to docker hub on private registry, and b64 encoding 2022-07-29 13:27:17 +02:00
21 changed files with 1408 additions and 367 deletions

View File

@@ -73,8 +73,6 @@
"contextName": "redis",
"image": "redis",
"createOptions": "--cpus 1",
"entrypoint": null,
"entryPointArgs": [],
"environmentVariables": {},
"userMountVolumes": [
{

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "hooks",
"version": "0.3.1",
"version": "0.1.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "hooks",
"version": "0.3.1",
"version": "0.1.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^27.5.1",
@@ -1800,9 +1800,9 @@
"dev": true
},
"node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -3926,9 +3926,9 @@
"dev": true
},
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"requires": {
"minimist": "^1.2.0"

View File

@@ -1,6 +1,6 @@
{
"name": "hooks",
"version": "0.3.1",
"version": "0.2.0",
"description": "Three projects are included - k8s: a kubernetes hook implementation that spins up pods dynamically to run a job - docker: A hook implementation of the runner's docker implementation - A hook lib, which contains shared typescript definitions and utilities that the other packages consume",
"main": "",
"directories": {

View File

@@ -3779,9 +3779,9 @@
"peer": true
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"dev": true,
"bin": {
"json5": "lib/cli.js"
@@ -4903,9 +4903,9 @@
}
},
"node_modules/tsconfig-paths/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -8176,9 +8176,9 @@
"peer": true
},
"json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"dev": true
},
"kleur": {
@@ -8985,9 +8985,9 @@
},
"dependencies": {
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"requires": {
"minimist": "^1.2.0"

View File

@@ -16,14 +16,15 @@ import {
import { checkEnvironment } from './utils'
async function run(): Promise<void> {
const input = await getInputFromStdin()
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
try {
checkEnvironment()
const input = await getInputFromStdin()
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
switch (command) {
case Command.PrepareJob:
await prepareJob(args as PrepareJobArgs, responseFile)

View File

@@ -1742,9 +1742,9 @@
"dev": true
},
"node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -3789,9 +3789,9 @@
"dev": true
},
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true,
"requires": {
"minimist": "^1.2.0"

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"@actions/core": "^1.9.1",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.2",
"@kubernetes/client-node": "^0.18.1",
"@kubernetes/client-node": "^0.16.3",
"hooklib": "file:../hooklib"
},
"devDependencies": {

View File

@@ -14,7 +14,6 @@ import {
containerVolumes,
DEFAULT_CONTAINER_ENTRY_POINT,
DEFAULT_CONTAINER_ENTRY_POINT_ARGS,
generateContainerName,
PodPhase
} from '../k8s/utils'
import { JOB_CONTAINER_NAME } from './constants'
@@ -32,14 +31,14 @@ export async function prepareJob(
let container: k8s.V1Container | undefined = undefined
if (args.container?.image) {
core.debug(`Using image '${args.container.image}' for job image`)
container = createContainerSpec(args.container, JOB_CONTAINER_NAME, true)
container = createPodSpec(args.container, JOB_CONTAINER_NAME, true)
}
let services: k8s.V1Container[] = []
if (args.services?.length) {
services = args.services.map(service => {
core.debug(`Adding service '${service.image}' to pod definition`)
return createContainerSpec(service, generateContainerName(service.image))
return createPodSpec(service, service.image.split(':')[0])
})
}
if (!container && !services?.length) {
@@ -125,11 +124,13 @@ function generateResponseFile(
)
if (serviceContainers?.length) {
response.context['services'] = serviceContainers.map(c => {
if (!c.ports) {
return
}
const ctxPorts: ContextPorts = {}
if (c.ports?.length) {
for (const port of c.ports) {
ctxPorts[port.containerPort] = port.hostPort
}
for (const port of c.ports) {
ctxPorts[port.containerPort] = port.hostPort
}
return {
@@ -152,7 +153,7 @@ async function copyExternalsToRoot(): Promise<void> {
}
}
export function createContainerSpec(
function createPodSpec(
container,
name: string,
jobContainer = false
@@ -165,20 +166,14 @@ export function createContainerSpec(
const podContainer = {
name,
image: container.image,
command: [container.entryPoint],
args: container.entryPointArgs,
ports: containerPorts(container)
} as k8s.V1Container
if (container.workingDirectory) {
podContainer.workingDir = container.workingDirectory
}
if (container.entryPoint) {
podContainer.command = [container.entryPoint]
}
if (container.entryPointArgs?.length > 0) {
podContainer.args = container.entryPointArgs
}
podContainer.env = []
for (const [key, value] of Object.entries(
container['environmentVariables']

View File

@@ -8,7 +8,8 @@ import {
getPodLogs,
getPodStatus,
waitForJobToComplete,
waitForPodPhases
waitForPodPhases,
containerBuild
} from '../k8s'
import {
containerVolumes,
@@ -23,7 +24,8 @@ export async function runContainerStep(
stepContainer: RunContainerStepArgs
): Promise<number> {
if (stepContainer.dockerfile) {
throw new Error('Building container actions is not currently supported')
const imageUrl = await containerBuild(stepContainer)
stepContainer.image = imageUrl
}
let secretName: string | undefined = undefined

View File

@@ -9,13 +9,15 @@ import {
import { isAuthPermissionsOK, namespace, requiredPermissions } from './k8s'
async function run(): Promise<void> {
try {
const input = await getInputFromStdin()
const input = await getInputFromStdin()
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
const args = input['args']
const command = input['command']
const responseFile = input['responseFile']
const state = input['state']
let exitCode = 0
try {
if (!(await isAuthPermissionsOK())) {
throw new Error(
`The Service account needs the following permissions ${JSON.stringify(
@@ -23,28 +25,28 @@ async function run(): Promise<void> {
)} on the pod resource in the '${namespace()}' namespace. Please contact your self hosted runner administrator.`
)
}
let exitCode = 0
switch (command) {
case Command.PrepareJob:
await prepareJob(args as prepareJobArgs, responseFile)
return process.exit(0)
break
case Command.CleanupJob:
await cleanupJob()
return process.exit(0)
break
case Command.RunScriptStep:
await runScriptStep(args, state, null)
return process.exit(0)
break
case Command.RunContainerStep:
exitCode = await runContainerStep(args)
return process.exit(exitCode)
break
case Command.runContainerStep:
default:
throw new Error(`Command not recognized: ${command}`)
}
} catch (error) {
core.error(error as Error)
process.exit(1)
exitCode = 1
}
process.exitCode = exitCode
}
void run()

View File

@@ -1,6 +1,6 @@
import * as core from '@actions/core'
import * as k8s from '@kubernetes/client-node'
import { ContainerInfo, Registry } from 'hooklib'
import { RunContainerStepArgs, ContainerInfo, Registry } from 'hooklib'
import * as stream from 'stream'
import {
getJobPodName,
@@ -10,15 +10,25 @@ import {
getVolumeClaimName,
RunnerInstanceLabel
} from '../hooks/constants'
import { kanikoPod } from './kaniko'
import { v4 as uuidv4 } from 'uuid'
import { PodPhase } from './utils'
import {
namespace,
kc,
k8sApi,
k8sBatchV1Api,
k8sAuthorizationV1Api,
localRegistryNodePort,
localRegistryHost,
localRegistryPort,
remoteRegistryHost,
remoteRegistryHandle,
remoteRegistrySecretName,
isLocalRegistrySet
} from './settings'
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)
export * from './settings'
export const POD_VOLUME_NAME = 'work'
@@ -46,12 +56,6 @@ export const requiredPermissions = [
verbs: ['get', 'list', 'create', 'delete'],
resource: 'jobs',
subresource: ''
},
{
group: '',
verbs: ['create', 'delete', 'get', 'list'],
resource: 'secrets',
subresource: ''
}
]
@@ -325,8 +329,20 @@ export async function waitForPodPhases(
const backOffManager = new BackOffManager(maxTimeSeconds)
let phase: PodPhase = PodPhase.UNKNOWN
try {
while (true) {
phase = await getPodPhase(podName)
let retryCount = 0
while (retryCount < 3) {
try {
phase = await getPodPhase(podName)
} catch (err) {
const e = err as k8s.HttpError
if (e?.body?.reason === 'NotFound') {
retryCount++
await backOffManager.backOff()
continue
} else {
throw err
}
}
if (awaitingPhases.has(phase)) {
return
}
@@ -338,6 +354,7 @@ export async function waitForPodPhases(
}
await backOffManager.backOff()
}
throw new Error(`Failed to get pod phase after ${retryCount} attempts`)
} catch (error) {
throw new Error(`Pod ${podName} is unhealthy with phase status ${phase}`)
}
@@ -464,6 +481,42 @@ export async function isPodContainerAlpine(
return isAlpine
}
export async function containerBuild(
args: RunContainerStepArgs
): Promise<string> {
let kanikoRegistry = ''
let pullRegistry = ''
let secretName: string | undefined = undefined
if (isLocalRegistrySet()) {
const host = `${localRegistryHost()}.${namespace()}.svc.cluster.local`
const port = localRegistryPort()
const uri = `${generateBuildHandle()}/${generateBuildImage()}`
kanikoRegistry = `${host}:${port}/${uri}`
pullRegistry = `localhost:${localRegistryNodePort()}/${uri}`
} else {
const uri = `${remoteRegistryHandle()}/${generateBuildImage()}`
if (remoteRegistryHost()) {
kanikoRegistry = `${remoteRegistryHost()}/${uri}`
} else {
kanikoRegistry = uri
}
pullRegistry = kanikoRegistry
secretName = remoteRegistrySecretName()
}
const pod = kanikoPod(args.dockerfile, kanikoRegistry, secretName)
if (!pod.metadata?.name) {
throw new Error('kaniko pod name is not set')
}
await k8sApi.createNamespacedPod(namespace(), pod)
await waitForPodPhases(
pod.metadata.name,
new Set([PodPhase.SUCCEEDED]),
new Set([PodPhase.PENDING, PodPhase.UNKNOWN, PodPhase.RUNNING])
)
return pullRegistry
}
async function getCurrentNodeName(): Promise<string> {
const resp = await k8sApi.readNamespacedPod(getRunnerPodName(), namespace())
@@ -473,19 +526,6 @@ async function getCurrentNodeName(): Promise<string> {
}
return nodeName
}
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
@@ -517,9 +557,6 @@ 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) {
@@ -554,3 +591,11 @@ export function containerPorts(
}
return ports
}
function generateBuildImage(): string {
return `${uuidv4()}:${uuidv4()}`
}
function generateBuildHandle(): string {
return uuidv4()
}

View File

@@ -0,0 +1,95 @@
import * as k8s from '@kubernetes/client-node'
import * as path from 'path'
import {
getRunnerPodName,
getVolumeClaimName,
MAX_POD_NAME_LENGTH,
RunnerInstanceLabel
} from '../hooks/constants'
import { POD_VOLUME_NAME } from '.'
export const KANIKO_MOUNT_PATH = '/mnt/kaniko'
function getKanikoName(): string {
return `${getRunnerPodName().substring(
0,
MAX_POD_NAME_LENGTH - '-kaniko'.length
)}-kaniko`
}
export function kanikoPod(
dockerfile: string,
destination: string,
secretName?: string
): k8s.V1Pod {
const pod = new k8s.V1Pod()
pod.apiVersion = 'v1'
pod.kind = 'Pod'
pod.metadata = new k8s.V1ObjectMeta()
pod.metadata.name = getKanikoName()
const instanceLabel = new RunnerInstanceLabel()
pod.metadata.labels = {
[instanceLabel.key]: instanceLabel.value
}
const spec = new k8s.V1PodSpec()
const c = new k8s.V1Container()
c.image = 'gcr.io/kaniko-project/executor:latest'
c.name = 'kaniko'
c.imagePullPolicy = 'Always'
const prefix = (process.env.RUNNER_WORKSPACE as string).split('_work')[0]
const subPath = path
.dirname(dockerfile)
.substring(prefix.length + '_work/'.length)
c.volumeMounts = [
{
name: POD_VOLUME_NAME,
mountPath: KANIKO_MOUNT_PATH,
subPath,
readOnly: true
}
]
c.args = [
`--dockerfile=${path.basename(dockerfile)}`,
`--context=dir://${KANIKO_MOUNT_PATH}`,
`--destination=${destination}`
]
spec.containers = [c]
spec.dnsPolicy = 'ClusterFirst'
spec.restartPolicy = 'Never'
pod.spec = spec
const claimName: string = getVolumeClaimName()
pod.spec.volumes = [
{
name: POD_VOLUME_NAME,
persistentVolumeClaim: { claimName }
}
]
if (secretName) {
const volumeName = 'docker-registry'
pod.spec.volumes.push({
name: volumeName,
projected: {
sources: [
{
secret: {
name: secretName,
items: [
{
key: '.dockerconfigjson',
path: 'config.json'
}
]
}
}
]
}
})
c.volumeMounts.push({
name: volumeName,
mountPath: '/kaniko/.docker/'
})
}
return pod
}

View File

@@ -0,0 +1,73 @@
import * as k8s from '@kubernetes/client-node'
export const kc = new k8s.KubeConfig()
kc.loadFromDefault()
export const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
export const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api)
export const k8sAuthorizationV1Api = kc.makeApiClient(k8s.AuthorizationV1Api)
export const POD_VOLUME_NAME = 'work'
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
}
export function isLocalRegistrySet(): boolean {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_HOST'
return !!process.env[name]
}
export function localRegistryHost(): string {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_HOST'
if (process.env[name]) {
return process.env[name]
}
throw new Error(`environment variable ${name} is not set`)
}
export function localRegistryPort(): number {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_PORT'
if (process.env[name]) {
return parseInt(process.env[name])
}
throw new Error(`environment variable ${name} is not set`)
}
export function localRegistryNodePort(): number {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_NODE_PORT'
if (process.env[name]) {
return parseInt(process.env[name])
}
throw new Error(`environment variable ${name} is not set`)
}
export function remoteRegistryHost(): string {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_REMOTE_REGISTRY_HOST'
return process.env[name] || ''
}
export function remoteRegistryHandle(): string {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_REMOTE_REGISTRY_HANDLE'
if (process.env[name]) {
return process.env[name]
}
throw new Error(`environment variable ${name} is not set`)
}
export function remoteRegistrySecretName(): string {
const name = 'ACTIONS_RUNNER_CONTAINER_HOOKS_REMOTE_REGISTRY_SECRET_NAME'
if (process.env[name]) {
return process.env[name]
}
throw new Error(`environment variable ${name} is not set`)
}

View File

@@ -111,13 +111,11 @@ export function writeEntryPointScript(
if (environmentVariables && Object.entries(environmentVariables).length) {
const envBuffer: string[] = []
for (const [key, value] of Object.entries(environmentVariables)) {
if (key.includes(`=`) || key.includes(`'`) || key.includes(`"`)) {
throw new Error(
`environment key ${key} is invalid - the key must not contain =, ' or "`
)
}
envBuffer.push(
`"${key}=${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
`"${key}=${value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/=/g, '\\=')}"`
)
}
environmentPrefix = `env ${envBuffer.join(' ')} `
@@ -139,17 +137,6 @@ exec ${environmentPrefix} ${entryPoint} ${
}
}
export function generateContainerName(image: string): string {
const nameWithTag = image.split('/').pop()
const name = nameWithTag?.split(':').at(0)
if (!name) {
throw new Error(`Image definition '${image}' is invalid`)
}
return name
}
export enum PodPhase {
PENDING = 'Pending',
RUNNING = 'Running',

View File

@@ -1,10 +1,6 @@
import * as fs from 'fs'
import { containerPorts, POD_VOLUME_NAME } from '../src/k8s'
import {
containerVolumes,
generateContainerName,
writeEntryPointScript
} from '../src/k8s/utils'
import { containerVolumes, writeEntryPointScript } from '../src/k8s/utils'
import { TestHelper } from './test-setup'
let testHelper: TestHelper
@@ -225,32 +221,4 @@ describe('k8s utils', () => {
expect(() => containerPorts({ portMappings: ['1/tcp/udp'] })).toThrow()
})
})
describe('generate container name', () => {
it('should return the container name from image string', () => {
expect(
generateContainerName('public.ecr.aws/localstack/localstack')
).toEqual('localstack')
expect(
generateContainerName(
'public.ecr.aws/url/with/multiple/slashes/postgres:latest'
)
).toEqual('postgres')
expect(generateContainerName('postgres')).toEqual('postgres')
expect(generateContainerName('postgres:latest')).toEqual('postgres')
expect(generateContainerName('localstack/localstack')).toEqual(
'localstack'
)
expect(generateContainerName('localstack/localstack:latest')).toEqual(
'localstack'
)
})
it('should throw on invalid image string', () => {
expect(() =>
generateContainerName('localstack/localstack/:latest')
).toThrow()
expect(() => generateContainerName(':latest')).toThrow()
})
})
})

View File

@@ -1,10 +1,8 @@
import * as fs from 'fs'
import * as path from 'path'
import { cleanupJob } from '../src/hooks'
import { createContainerSpec, prepareJob } from '../src/hooks/prepare-job'
import { prepareJob } from '../src/hooks/prepare-job'
import { TestHelper } from './test-setup'
import { generateContainerName } from '../src/k8s/utils'
import { V1Container } from '@kubernetes/client-node'
jest.useRealTimers()
@@ -73,27 +71,4 @@ describe('Prepare job', () => {
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).rejects.toThrow()
})
it('should not set command + args for service container if not passed in args', async () => {
const services = prepareJobData.args.services.map(service => {
return createContainerSpec(service, generateContainerName(service.image))
}) as [V1Container]
expect(services[0].command).toBe(undefined)
expect(services[0].args).toBe(undefined)
})
test.each([undefined, null, []])(
'should not throw exception when portMapping=%p',
async pm => {
prepareJobData.args.services.forEach(s => {
s.portMappings = pm
})
await prepareJob(prepareJobData.args, prepareJobOutputFilePath)
const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)
expect(() => content.context.services[0].image).not.toThrow()
}
)
})

View File

@@ -3,11 +3,10 @@ import { TestHelper } from './test-setup'
jest.useRealTimers()
let testHelper: TestHelper
describe('Run container step with image', () => {
let testHelper: TestHelper
let runContainerStepData: any
let runContainerStepData: any
describe('Run container step', () => {
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
@@ -39,3 +38,33 @@ describe('Run container step', () => {
).resolves.not.toThrow()
})
})
describe('run container step with docker build', () => {
let testHelper: TestHelper
let runContainerStepData: any
beforeEach(async () => {
testHelper = new TestHelper()
await testHelper.initialize()
runContainerStepData = testHelper.getRunContainerStepDefinition()
})
afterEach(async () => {
await testHelper.cleanup()
})
it('should build container and execute docker action', async () => {
const { registryName, localRegistryPort, nodePort } =
await testHelper.createContainerRegistry()
process.env.ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_HOST =
registryName
process.env.ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_PORT =
localRegistryPort.toString()
process.env.ACTIONS_RUNNER_CONTAINER_HOOKS_LOCAL_REGISTRY_NODE_PORT =
nodePort.toString()
const actionPath = testHelper.initializeDockerAction()
const data = JSON.parse(JSON.stringify(runContainerStepData))
data.args.dockerfile = `${actionPath}/Dockerfile`
await expect(runContainerStep(data.args)).resolves.not.toThrow()
})
})

View File

@@ -59,6 +59,7 @@ describe('Run script step', () => {
it('should shold have env variables available', async () => {
runScriptStepDefinition.args.entryPoint = 'bash'
runScriptStepDefinition.args.workingDirectory = '/' // set to '/' so that cd does not throw
runScriptStepDefinition.args.entryPointArgs = [
'-c',
"'if [[ -z $NODE_ENV ]]; then exit 1; fi'"

View File

@@ -2,7 +2,10 @@ import * as k8s from '@kubernetes/client-node'
import * as fs from 'fs'
import { HookData } from 'hooklib/lib'
import * as path from 'path'
import internal from 'stream'
import { v4 as uuidv4 } from 'uuid'
import { waitForPodPhases } from '../src/k8s'
import { PodPhase } from '../src/k8s/utils'
const kc = new k8s.KubeConfig()
@@ -10,6 +13,7 @@ kc.loadFromDefault()
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
const k8sStorageApi = kc.makeApiClient(k8s.StorageV1Api)
const k8sAppsV1 = kc.makeApiClient(k8s.AppsV1Api)
export class TestHelper {
private tempDirPath: string
@@ -74,10 +78,19 @@ export class TestHelper {
0
)
.catch(e => {})
await k8sApi
.deleteNamespacedPod(
`${this.podName}-kaniko`,
'default',
undefined,
undefined,
0
)
.catch(e => {})
}
public createFile(fileName?: string): string {
public createFile(fileName?: string, content = ''): string {
const filePath = `${this.tempDirPath}/${fileName || uuidv4()}`
fs.writeFileSync(filePath, '')
fs.writeFileSync(filePath, content)
return filePath
}
@@ -193,4 +206,237 @@ export class TestHelper {
runContainerStep.args.registry = null
return runContainerStep
}
public async createContainerRegistry(): Promise<{
registryName: string
localRegistryPort: number
nodePort: number
}> {
const registryName = 'docker-registry'
const localRegistryPort = 5000
const nodePort = 31500
const cm = registryConfigMap(registryName, localRegistryPort)
const secret = registrySecret(registryName)
const ss = registryStatefulSet(registryName, localRegistryPort)
const svc = registryService(registryName, localRegistryPort, nodePort)
const namespace =
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] || 'default'
await Promise.all([
k8sApi.createNamespacedConfigMap(namespace, cm),
k8sApi.createNamespacedSecret(namespace, secret)
])
await k8sAppsV1.createNamespacedStatefulSet(namespace, ss)
await waitForPodPhases(
`${registryName}-0`,
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
)
await k8sApi.createNamespacedService(namespace, svc)
return {
registryName,
localRegistryPort,
nodePort
}
}
public initializeDockerAction(): string {
const actionPath = `${this.tempDirPath}/_work/_actions/example-handle/example-repo/example-branch/mock-directory`
fs.mkdirSync(actionPath, { recursive: true })
this.writeDockerfile(actionPath)
this.writeEntrypoint(actionPath)
return actionPath
}
private writeDockerfile(actionPath: string) {
const content = `FROM ubuntu:latest
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]`
fs.writeFileSync(`${actionPath}/Dockerfile`, content)
}
private writeEntrypoint(actionPath) {
const content = `#!/bin/sh -l
echo "Hello $1"
time=$(date)
echo "::set-output name=time::$time"`
const entryPointPath = `${actionPath}/entrypoint.sh`
fs.writeFileSync(entryPointPath, content)
fs.chmodSync(entryPointPath, 0o755)
}
}
function registryConfigMap(name: string, port: number): k8s.V1ConfigMap {
const REGISTRY_CONFIG_MAP_YAML = `
storage:
filesystem:
rootdirectory: /var/lib/registry
maxthreads: 100
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3
http:
addr: :${port}
headers:
X-Content-Type-Options:
- nosniff
log:
fields:
service: registry
storage:
cache:
blobdescriptor: inmemory
version: 0.1
`.trim()
const cm = new k8s.V1ConfigMap()
cm.apiVersion = 'v1'
cm.data = {
'config.yaml': REGISTRY_CONFIG_MAP_YAML
}
cm.kind = 'ConfigMap'
cm.metadata = new k8s.V1ObjectMeta()
cm.metadata.labels = { app: name }
cm.metadata.name = `${name}-config`
return cm
}
function registryStatefulSet(name: string, port: number): k8s.V1StatefulSet {
const ss = new k8s.V1StatefulSet()
ss.apiVersion = 'apps/v1'
ss.metadata = new k8s.V1ObjectMeta()
ss.metadata.name = name
const spec = new k8s.V1StatefulSetSpec()
spec.selector = new k8s.V1LabelSelector()
spec.selector.matchLabels = { app: 'docker-registry' }
spec.serviceName = 'registry'
spec.replicas = 1
const tmpl = new k8s.V1PodTemplateSpec()
tmpl.metadata = new k8s.V1ObjectMeta()
tmpl.metadata.labels = { app: name }
tmpl.spec = new k8s.V1PodSpec()
tmpl.spec.terminationGracePeriodSeconds = 5 // TODO: figure out for how long
const c = new k8s.V1Container()
c.command = ['/bin/registry', 'serve', '/etc/docker/registry/config.yaml']
c.env = [
{
name: 'REGISTRY_HTTP_SECRET',
valueFrom: {
secretKeyRef: {
key: 'haSharedSecret',
name: `${name}-secret`
}
}
},
{
name: 'REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY',
value: '/var/lib/registry'
}
]
c.image = 'registry:2.6.2'
c.name = name
c.imagePullPolicy = 'IfNotPresent'
c.ports = [
{
containerPort: port,
protocol: 'TCP'
}
]
c.volumeMounts = [
{
mountPath: '/etc/docker/registry',
name: 'docker-registry-config'
}
]
c.livenessProbe = new k8s.V1Probe()
c.livenessProbe.failureThreshold = 3
c.livenessProbe.periodSeconds = 10
c.livenessProbe.successThreshold = 1
c.livenessProbe.timeoutSeconds = 1
c.livenessProbe.httpGet = new k8s.V1HTTPGetAction()
c.livenessProbe.httpGet.path = '/'
c.livenessProbe.httpGet.port = port
c.livenessProbe.httpGet.scheme = 'HTTP'
c.readinessProbe = new k8s.V1Probe()
c.readinessProbe.failureThreshold = 3
c.readinessProbe.periodSeconds = 10
c.readinessProbe.successThreshold = 1
c.readinessProbe.timeoutSeconds = 1
c.readinessProbe.httpGet = new k8s.V1HTTPGetAction()
c.readinessProbe.httpGet.path = '/'
c.readinessProbe.httpGet.port = port
c.readinessProbe.httpGet.scheme = 'HTTP'
tmpl.spec.containers = [c]
tmpl.spec.volumes = [
{
name: `${name}-config`,
configMap: {
name: `${name}-config`
}
}
]
spec.template = tmpl
ss.spec = spec
return ss
}
function registryService(
name: string,
port: number,
nodePort: number
): k8s.V1Service {
const svc = new k8s.V1Service()
svc.apiVersion = 'v1'
svc.kind = 'Service'
svc.metadata = new k8s.V1ObjectMeta()
svc.metadata.name = name
svc.metadata.labels = {
app: name
}
const spec = new k8s.V1ServiceSpec()
spec.externalTrafficPolicy = 'Cluster'
spec.ports = [
{
name: 'registry',
nodePort: nodePort,
port: port,
protocol: 'TCP',
targetPort: port
}
]
spec.selector = {
app: name
}
spec.sessionAffinity = 'None'
spec.type = 'NodePort'
svc.spec = spec
return svc
}
function registrySecret(name: string): k8s.V1Secret {
const secret = new k8s.V1Secret()
secret.apiVersion = 'v1'
secret.data = { haSharedSecret: 'U29tZVZlcnlTdHJpbmdTZWNyZXQK' }
secret.kind = 'Secret'
secret.metadata = new k8s.V1ObjectMeta()
secret.metadata.labels = {
app: name,
chart: `${name}-1.4.3`
}
secret.metadata.name = `${name}-secret`
secret.type = 'Opaque'
return secret
}

View File

@@ -1,6 +1,9 @@
<!-- ## Features -->
## Features
- Always use the Docker related ENVs from the host machine instead of ENVs from the runner job [#40]
- Use user defined entrypoints for service containers (instead of `tail -f /dev/null`)
## Bugs
- Fixed substring issue with /github/workspace and /github/file_commands [#35]
- Fixed issue related to setting hostPort and containerPort when formatting is not recognized by k8s default [#38]
- Ensure the response file contains ports object for service containers in k8s [#70]
<!-- ## Misc -->
<!-- ## Misc