Skip to content
Open
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
37 changes: 28 additions & 9 deletions packages/k8s/src/hooks/prepare-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,30 @@ export async function prepareJob(
}

let services: k8s.V1Container[] = []
let serviceNames: string[] = []
if (args.services?.length) {
const occurrences = new Map<string, number>()
for (const s of args.services) {
const base = generateContainerName(s.image)
occurrences.set(base, (occurrences.get(base) || 0) + 1)
}

const indices = new Map<string, number>()
services = args.services.map(service => {
return createContainerSpec(
service,
generateContainerName(service.image),
false,
extension
)
const base = generateContainerName(service.image)
const total = occurrences.get(base) || 0
const idx = indices.get(base) || 0

let name: string
if (total > 1) {
name = `${base}-${idx}`
} else {
name = base
}

indices.set(base, idx + 1)
serviceNames.push(name)
return createContainerSpec(service, name, false, extension)
})
Comment on lines 62 to 85
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conflict resolution logic doesn't account for cases where a generated name (e.g., 'redis-0') might collide with another service's base name (e.g., a service using image 'redis-0:latest'). This could still result in duplicate container names. Consider using a two-pass approach: first detect all conflicts (including potential conflicts with suffixed names), then generate unique names that avoid all collisions. Alternatively, use a different separator or suffix format that's less likely to collide with valid image names (e.g., '--0' or '.0').

Copilot uses AI. Check for mistakes.
}

Expand Down Expand Up @@ -153,14 +169,15 @@ 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, isAlpine, serviceNames)
}

function generateResponseFile(
responseFile: string,
args: PrepareJobArgs,
appPod: k8s.V1Pod,
isAlpine: boolean
isAlpine: boolean,
serviceNames?: string[]
): void {
if (!appPod.metadata?.name) {
throw new Error('app pod must have metadata.name specified')
Expand Down Expand Up @@ -193,7 +210,9 @@ function generateResponseFile(

if (args.services?.length) {
const serviceContainerNames =
args.services?.map(s => generateContainerName(s.image)) || []
serviceNames && serviceNames.length
? serviceNames
: args.services?.map(s => generateContainerName(s.image)) || []

response.context['services'] = appPod?.spec?.containers
?.filter(c => serviceContainerNames.includes(c.name))
Expand Down
33 changes: 33 additions & 0 deletions packages/k8s/tests/prepare-job-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,37 @@ describe('Prepare job', () => {
'ghcr.io/actions/actions-runner:latest'
)
})

it('should create unique service container names when images collide', async () => {
// Use fixed, non-colliding high ports. (Kubernetes hostPort must be unique per node.)
prepareJobData.args.container.portMappings = ['31080:8080']

// make two services with the same image
const svc = JSON.parse(JSON.stringify(prepareJobData.args.services[0]))
const svc2 = JSON.parse(JSON.stringify(svc))
// Ensure unique host ports so the pod spec is valid even with two services.
// (Kubernetes hostPort must be unique per node.)
svc.portMappings = ['31081:80', '31082:8080']
svc2.portMappings = ['31083:80', '31084:8080']
prepareJobData.args.services = [svc, svc2]
// ensure registries are null as TestHelper expects
prepareJobData.args.services.forEach((s: any) => (s.registry = null))

await expect(
prepareJob(prepareJobData.args, prepareJobOutputFilePath)
).resolves.not.toThrow()

const content = JSON.parse(
fs.readFileSync(prepareJobOutputFilePath).toString()
)

expect(content.context.services).toBeTruthy()
expect(content.context.services.length).toBe(2)

const got = await getPodByName(content.state.jobPod)
const names = (got.spec?.containers || []).map(c => c.name)

// when images collide, names should be suffixed with -0, -1
expect(names).toEqual(expect.arrayContaining(['redis-0', 'redis-1']))
})
})
Loading