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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ The `helm-version` input supports semver-compatible version ranges. This is usef

Refer to the [action metadata file](https://github.com/Azure/k8s-bake/blob/master/action.yml) for details about all the inputs.

## Output location

The baked manifest is written to a `.k8s-bake` directory inside `GITHUB_WORKSPACE`, and its full path is exposed as the `manifestsBundle` output. The manifest is removed at the end of the job, along with the directory if nothing else is in it.

Always consume the result through the output variable rather than hardcoding a path:

```yaml
manifests: ${{ steps.bake.outputs.manifestsBundle }}
```

If your workflow checks for an unmodified checkout part way through the job, add the directory to your `.gitignore`:

```gitignore
.k8s-bake/
```

> **Note.** Earlier versions wrote to `RUNNER_TEMP`, which sits outside `GITHUB_WORKSPACE`. `k8s-deploy` v7 rejects manifests that resolve outside the workspace, so bake output could no longer be deployed ([#286](https://github.com/Azure/k8s-bake/issues/286)). Workflows that read `manifestsBundle` need no changes.

## End to end workflow for building container images and deploying to a Kubernetes cluster

```yaml
Expand Down
1 change: 1 addition & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ branding:
runs:
using: 'node24'
main: 'lib/index.js'
post: 'lib/cleanup.js'
16 changes: 13 additions & 3 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import {build} from 'esbuild'

await build({
entryPoints: ['src/run.ts'],
const shared = {
bundle: true,
platform: 'node',
target: 'node24',
format: 'esm',
outfile: 'lib/index.js',
banner: {
js: "import {createRequire} from 'node:module'; const require = createRequire(import.meta.url);"
}
}

await build({
...shared,
entryPoints: ['src/run.ts'],
outfile: 'lib/index.js'
})

await build({
...shared,
entryPoints: ['src/post.ts'],
outfile: 'lib/cleanup.js'
})
23 changes: 16 additions & 7 deletions src/bake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ import * as core from '@actions/core'
import {ExecOptions} from '@actions/exec'

describe('Test all functions in run file', () => {
afterEach(() => vi.restoreAllMocks())
// These exercise the RUNNER_TEMP fallback, which only applies without a
// workspace. CI runs inside Actions where one is set, so clear it.
let savedWorkspace: string | undefined
beforeEach(() => {
savedWorkspace = process.env['GITHUB_WORKSPACE']
delete process.env['GITHUB_WORKSPACE']
})
afterEach(() => {
if (savedWorkspace === undefined) delete process.env['GITHUB_WORKSPACE']
else process.env['GITHUB_WORKSPACE'] = savedWorkspace
vi.restoreAllMocks()
})

test("KustomizeRenderEngine() - throw error if kubectl doesn't meet required version", async () => {
vi.spyOn(kubectlUtil, 'getKubectlPath').mockResolvedValue('pathToKubectl')
Expand Down Expand Up @@ -198,7 +209,7 @@ describe('Test all functions in run file', () => {
)
})

test('KomposeRenderEngine() - throw error if unable to find temp directory', async () => {
test('KomposeRenderEngine() - throw error if no output location can be determined', async () => {
vi.spyOn(core, 'getInput').mockReturnValue('pathToKompose')
vi.spyOn(ioUtil, 'exists').mockResolvedValue(true)
vi.spyOn(komposeUtil, 'getKomposePath').mockResolvedValue('pathToKompose')
Expand All @@ -208,7 +219,7 @@ describe('Test all functions in run file', () => {
vi.spyOn(console, 'log').mockImplementation(() => {})

await expect(new KomposeRenderEngine().bake(false)).rejects.toThrow(
'Unable to create temp directory.'
'Unable to determine an output directory'
)
expect(komposeUtil.getKomposePath).toHaveBeenCalled()
})
Expand Down Expand Up @@ -265,12 +276,10 @@ describe('Test all functions in run file', () => {
vi.spyOn(core, 'setFailed').mockImplementation(() => {})

await expect(run()).rejects.toThrow(
'Failed to run bake action. Error: Error: Unable to create temp directory.'
'Unable to determine an output directory'
)
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to run bake action. Error: Error: Unable to create temp directory.'
)
expect.stringContaining('Unable to determine an output directory')
)
})

Expand Down
49 changes: 38 additions & 11 deletions src/bake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,46 @@ import {getHelmPath, NameValuePair} from './helm-util.js'
import {getKubectlPath} from './kubectl-util.js'
import {getKomposePath} from './kompose-util.js'

// Prefers $GITHUB_WORKSPACE/.k8s-bake/, falling back to $RUNNER_TEMP when there
// is no workspace. k8s-deploy v7 rejects manifests resolving outside the
// workspace, so RUNNER_TEMP output can no longer be deployed (#286).
export function getBakedManifestPath(): string {
const fileName =
utilities.BAKED_MANIFEST_PREFIX +
utilities.getCurrentTime().toString() +
'.yaml'

const workspace = process.env['GITHUB_WORKSPACE']
if (!!workspace) {
const outputDirectory = path.join(
workspace,
utilities.BAKE_OUTPUT_DIRNAME
)
fs.mkdirSync(outputDirectory, {recursive: true})
// Filename, not path: mkdirSync succeeds whether or not the directory
// already existed, so bake owns only the file it writes.
core.saveState(utilities.CLEANUP_STATE_KEY, fileName)
return path.join(outputDirectory, fileName)
}

const tempDirectory = process.env['RUNNER_TEMP']
if (!!tempDirectory) {
core.warning(
'GITHUB_WORKSPACE is not set; writing the baked manifest to RUNNER_TEMP. ' +
'k8s-deploy v7 and newer reject manifests outside the workspace.'
)
return path.join(tempDirectory, fileName)
}

throw Error(
'Unable to determine an output directory. Run in an environment where ' +
'GITHUB_WORKSPACE or RUNNER_TEMP is set.'
)
}

abstract class RenderEngine {
public bake!: (isSilent: boolean) => Promise<any>
protected getTemplatePath = () => {
const tempDirectory = process.env['RUNNER_TEMP']
if (!!tempDirectory) {
return path.join(
tempDirectory,
'baked-template-' + utilities.getCurrentTime().toString() + '.yaml'
)
} else {
throw Error('Unable to create temp directory.')
}
}
protected getTemplatePath = () => getBakedManifestPath()
}

export class HelmRenderEngine extends RenderEngine {
Expand Down
89 changes: 89 additions & 0 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as core from '@actions/core'
import fs from 'fs'
import path from 'path'
import {
BAKE_OUTPUT_DIRNAME,
BAKED_MANIFEST_PATTERN,
CLEANUP_STATE_KEY
} from './constants.js'

// Removes the manifest bake wrote into the workspace. Deletes exactly the file
// the main step recorded, then the directory only if that leaves it empty.
//
// The parent is recomputed from GITHUB_WORKSPACE rather than taken from state,
// and the recorded name must be a bare filename matching the generated pattern,
// so state cannot steer the delete outside the directory.
export function cleanup() {
const recorded = core.getState(CLEANUP_STATE_KEY)
if (!recorded) {
core.debug('No baked manifest recorded; nothing to clean up.')
return
}
if (
path.basename(recorded) !== recorded ||
!BAKED_MANIFEST_PATTERN.test(recorded)
) {
core.warning(
`Ignoring unexpected recorded manifest name "${recorded}"; skipping cleanup.`
)
return
}

const workspace = process.env['GITHUB_WORKSPACE']
if (!workspace) {
core.debug('GITHUB_WORKSPACE is not set; nothing to clean up.')
return
}

let directory: string
try {
const resolvedWorkspace = fs.realpathSync(path.resolve(workspace))
const candidate = path.join(resolvedWorkspace, BAKE_OUTPUT_DIRNAME)
if (!fs.existsSync(candidate)) {
core.debug(`No baked manifest directory at ${candidate}.`)
return
}

// Rejects a symlink planted at .k8s-bake pointing elsewhere.
directory = fs.realpathSync(candidate)
if (path.relative(resolvedWorkspace, directory) !== BAKE_OUTPUT_DIRNAME) {
core.warning(
`Refusing to clean ${candidate}: it resolves to ${directory}, outside the expected location.`
)
return
}
if (!fs.statSync(directory).isDirectory()) {
core.warning(`Refusing to clean ${directory}: not a directory.`)
return
}
} catch (err) {
core.warning(`Skipping cleanup of the baked manifest directory: ${err}`)
return
}

const file = path.join(directory, recorded)
try {
// lstat, not stat: never follow a symlink named like a manifest.
if (fs.existsSync(file)) {
if (fs.lstatSync(file).isFile()) {
fs.unlinkSync(file)
core.debug(`Removed baked manifest ${file}`)
} else {
core.warning(`Refusing to remove ${file}: not a regular file.`)
}
}
} catch (err) {
core.warning(`Failed to remove baked manifest ${file}: ${err}`)
}

// Non-recursive: fails rather than deleting anything bake did not write.
try {
fs.rmdirSync(directory)
core.debug(`Removed baked manifest directory ${directory}`)
} catch {
core.debug(`Leaving ${directory} in place; it is not empty.`)
}
}
15 changes: 15 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

// Kept in a dedicated module so the post-job cleanup entry point does not have
// to bundle utilities.ts and its dependencies just to read these.

export const BAKE_OUTPUT_DIRNAME = '.k8s-bake'

export const BAKED_MANIFEST_PREFIX = 'baked-template-'
export const BAKED_MANIFEST_PATTERN = /^baked-template-\d+\.yaml$/

// Filename the main step generated, read back by the post-job step. Stored as a
// basename and re-validated on read, since step state travels as a STATE_*
// environment variable.
export const CLEANUP_STATE_KEY = 'bakedManifestFile'
Loading