diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..148a2fa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +tab_width = 4 +indent_size = 4 +end_of_line = lf +indent_style = space +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +tab_width = 2 +indent_size = 2 diff --git a/.github/install_latest_buildah.sh b/.github/install_latest_buildah.sh new file mode 100644 index 0000000..b779f78 --- /dev/null +++ b/.github/install_latest_buildah.sh @@ -0,0 +1,3 @@ +sudo apt-key add - < Release.key +sudo apt-get update -qq +sudo apt-get -qq -y install buildah diff --git a/.github/workflows/check-lowercase.yaml b/.github/workflows/check-lowercase.yaml new file mode 100644 index 0000000..3618ec7 --- /dev/null +++ b/.github/workflows/check-lowercase.yaml @@ -0,0 +1,66 @@ +# This workflow will perform a test whenever there +# is some change in code done to ensure that the changes +# are not buggy and we are getting the desired output. +name: Check Case Normalization +on: + push: + pull_request: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' # every day at midnight + +env: + IMAGE_NAME: ImageCaseTest + IMAGE_TAGS: v1 TagCaseTest + +jobs: + build: + name: Build image using Buildah + runs-on: container + strategy: + fail-fast: false + matrix: + install_latest: [ false ] + + steps: + + # Checkout buildah action github repository + - name: Checkout Buildah action + uses: actions/checkout@v4 + with: + path: "buildah-build" + + - name: Install latest buildah + if: matrix.install_latest + run: | + bash buildah-build/.github/install_latest_buildah.sh + + - name: Create Dockerfile + run: | + cat > Containerfile< Containerfile< Containerfile< Containerfile< Containerfile< +
+[![tag badge](https://img.shields.io/github/v/tag/redhat-actions/buildah-build)](https://github.com/redhat-actions/buildah-build/tags) +[![license badge](https://img.shields.io/github/license/redhat-actions/buildah-build)](./LICENSE) +[![size badge](https://img.shields.io/github/size/redhat-actions/buildah-build/dist/index.js)](./dist) + +Buildah Build is a Gitea/Forgejo Action for building Docker, Podman and Kubernetes-compatible images quickly and easily. + +[Buildah](https://github.com/containers/buildah/tree/master/docs) only works on Linux. + +This code is a heavily stripped down and adapted version for Forgejo of the [Redhat version](https://github.com/redhat-actions/buildah-build). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e1ba1c8 --- /dev/null +++ b/action.yml @@ -0,0 +1,94 @@ +name: 'Buildah Build' +description: 'Build a container image, with or without a Containerfile' +author: 'Phil and Red Hat' +branding: + icon: circle + color: red +inputs: + image: + description: 'The name (reference) of the image to build' + required: false + tags: + description: 'The tags of the image to build. For multiple tags, seperate by whitespace. For example, "latest v1".' + required: false + default: latest + labels: + description: 'The labels of the image to build. Seperate by newline. For example, "io.containers.capabilities=sys_admin,mknod".' + required: false + base-image: + description: 'The base image to use to create a new container image' + required: false + containerfiles: + description: 'List of Containerfile paths (eg: ./Containerfile)' + required: false + dockerfiles: + description: 'Alias for "containerfiles". "containerfiles" takes precedence if both are set.' + required: false + context: + description: 'Path of the directory to use as context (default: .)' + required: false + default: '.' + content: + description: 'List of files/directories to copy inside the base image' + required: false + entrypoint: + description: 'The entry point to set for containers based on image' + required: false + layers: + description: 'Set to true to cache intermediate layers during build process' + required: false + port: + description: 'The port to expose when running containers based on image' + required: false + workdir: + description: 'The working directory to use within the container' + required: false + envs: + description: 'List of environment variables to be set when running containers based on image' + required: false + build-args: + description: 'List of --build-args to pass to buildah' + required: false + oci: + description: 'Set to true to build using the OCI image format instead of the Docker image format' + default: 'false' + required: false + arch: + description: + 'Label the image with this ARCH, instead of defaulting to the host architecture' + required: false + archs: + description: | + 'Same as input 'arch', use this for multiple architectures. + Seperate them by a comma' + required: false + platform: + description: | + Label the image with this PLATFORM, instead of defaulting to the host platform. + Only supported for containerfile builds. + required: false + platforms: + description: | + 'Same as input 'platform', use this for multiple platforms. + Seperate them by a comma' + required: false + extra-args: + description: | + Extra args to be passed to buildah bud and buildah from. + Separate arguments by newline. Do not use quotes - @actions/exec will do the quoting for you. + required: false + tls-verify: + description: | + Require HTTPS and verify certificates when accessing the registry. Defaults to true. + required: false + default: 'true' +outputs: + image: + description: 'Name of the image built' + tags: + description: 'List of the tags that were created, separated by spaces' + image-with-tag: + description: 'Name of the image tagged with the first tag present' +runs: + using: 'node20' + main: 'dist/index.js' diff --git a/package.json b/package.json new file mode 100644 index 0000000..85bbff1 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "buildah-build", + "version": "1.0", + "engines": { + "node": "20" + }, + "description": "Action for building OCI-compatible images using buildah", + "repository": { + "type": "git", + "url": "https://philome.mooo.com/code/Philome/buildah-build" + }, + "main": "dist/index.js", + "scripts": { + "compile": "tsc -p .", + "bundle": "ncc build src/index.ts --source-map --minify", + "clean": "rm -rf out/ dist/", + "lint": "eslint . --max-warnings=0", + "generate-ios": "npx action-io-generator -w -o ./src/generated/inputs-outputs.ts" + }, + "keywords": [], + "author": "Phil, based on Red Hat", + "license": "MIT", + "dependencies": { + "@actions/core": "1.10.1", + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3", + "ini": "4.1.1" + }, + "devDependencies": { + "@redhat-actions/action-io-generator": "1.5.0", + "@redhat-actions/eslint-config": "1.3.2", + "@redhat-actions/tsconfig": "1.2.0", + "@types/ini": "1.3.31", + "@types/node": "^20.0", + "@typescript-eslint/eslint-plugin": "6.7.3", + "@typescript-eslint/parser": "6.7.3", + "@vercel/ncc": "0.38.0", + "eslint": "8.50.0", + "typescript": "5.2.2" + } +} diff --git a/src/buildah.ts b/src/buildah.ts new file mode 100644 index 0000000..b93e4e1 --- /dev/null +++ b/src/buildah.ts @@ -0,0 +1,311 @@ +/*************************************************************************************************** + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + **************************************************************************************************/ + +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import * as path from "path"; +import CommandResult from "./types"; +import { isStorageDriverOverlay, findFuseOverlayfsPath, getFullImageName } from "./utils"; + +export interface BuildahConfigSettings { + entrypoint?: string[]; + envs?: string[]; + port?: string; + workingdir?: string; + arch?: string; + labels?: string[]; +} + +interface Buildah { + buildUsingDocker( + image: string, context: string, containerFiles: string[], buildArgs: string[], + useOCI: boolean, labels: string[], layers: string, + extraArgs: string[], tlsVerify: boolean, arch?: string, platform?: string, + ): Promise; + from(baseImage: string, tlsVerify: boolean, extraArgs: string[]): Promise; + config(container: string, setting: BuildahConfigSettings): Promise; + copy(container: string, contentToCopy: string[]): Promise; + commit(container: string, newImageName: string, useOCI: boolean): Promise; + manifestCreate(manifest: string): Promise; + manifestAdd(manifest: string, imageName: string, tags: string[]): Promise; +} + +export class BuildahCli implements Buildah { + private readonly executable: string; + + public storageOptsEnv = ""; + + constructor(executable: string) { + this.executable = executable; + } + + // Checks for storage driver if found "overlay", + // then checks if "fuse-overlayfs" is installed. + // If yes, add mount program to use "fuse-overlayfs" + async setStorageOptsEnv(): Promise { + if (await isStorageDriverOverlay()) { + const fuseOverlayfsPath = await findFuseOverlayfsPath(); + if (fuseOverlayfsPath) { + core.info(`Overriding storage mount_program with "fuse-overlayfs" in environment`); + this.storageOptsEnv = `overlay.mount_program=${fuseOverlayfsPath}`; + } + else { + core.warning(`"fuse-overlayfs" is not found. Install it before running this action. ` + + `For more detail see https://github.com/redhat-actions/buildah-build/issues/45`); + } + } + else { + core.info("Storage driver is not 'overlay', so not overriding storage configuration"); + } + } + + private static getImageFormatOption(useOCI: boolean): string[] { + return [ "--format", useOCI ? "oci" : "docker" ]; + } + + async buildUsingDocker( + image: string, + context: string, + containerFiles: string[], + buildArgs: string[], + useOCI: boolean, + labels: string[], + layers: string, + extraArgs: string[], + tlsVerify: boolean, + arch?: string, + platform?: string + ): Promise { + const args: string[] = [ "bud" ]; + if (arch) { + args.push("--arch"); + args.push(arch); + } + if (platform) { + args.push("--platform"); + args.push(platform); + } + containerFiles.forEach((file) => { + args.push("-f"); + args.push(file); + }); + labels.forEach((label) => { + args.push("--label"); + args.push(label); + }); + buildArgs.forEach((buildArg) => { + args.push("--build-arg"); + args.push(buildArg); + }); + args.push(...BuildahCli.getImageFormatOption(useOCI)); + args.push(`--tls-verify=${tlsVerify}`); + if (layers) { + args.push(`--layers=${layers}`); + } + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + args.push("-t"); + args.push(image); + args.push(context); + return this.execute(args); + } + + async from(baseImage: string, tlsVerify: boolean, extraArgs: string[]): Promise { + const args: string[] = [ "from" ]; + args.push(`--tls-verify=${tlsVerify}`); + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + args.push(baseImage); + return this.execute(args); + } + + async copy(container: string, contentToCopy: string[], contentPath?: string): Promise { + if (contentToCopy.length === 0) { + return undefined; + } + + core.debug("copy"); + core.debug(container); + core.debug("content: " + contentToCopy.join(" ")); + if (contentToCopy.length > 0) { + const args: string[] = [ "copy", container ].concat(contentToCopy); + if (contentPath) { + args.push(contentPath); + } + return this.execute(args); + } + + return undefined; + } + + async config(container: string, settings: BuildahConfigSettings): Promise { + core.debug("config"); + core.debug(container); + const args: string[] = [ "config" ]; + if (settings.entrypoint) { + args.push("--entrypoint"); + args.push(BuildahCli.convertArrayToStringArg(settings.entrypoint)); + } + if (settings.port) { + args.push("--port"); + args.push(settings.port); + } + if (settings.envs) { + settings.envs.forEach((env) => { + args.push("--env"); + args.push(env); + }); + } + if (settings.arch) { + args.push("--arch"); + args.push(settings.arch); + } + if (settings.workingdir) { + args.push("--workingdir"); + args.push(settings.workingdir); + } + if (settings.labels) { + settings.labels.forEach((label) => { + args.push("--label"); + args.push(label); + }); + } + args.push(container); + return this.execute(args); + } + + async commit(container: string, newImageName: string, useOCI: boolean): Promise { + core.debug("commit"); + core.debug(container); + core.debug(newImageName); + const args: string[] = [ + "commit", ...BuildahCli.getImageFormatOption(useOCI), + "--squash", container, newImageName, + ]; + return this.execute(args); + } + + async tag(imageName: string, tags: string[]): Promise { + const args: string[] = [ "tag" ]; + const builtImage = []; + for (const tag of tags) { + args.push(getFullImageName(imageName, tag)); + builtImage.push(getFullImageName(imageName, tag)); + } + core.info(`Tagging the built image with tags ${tags.toString()}`); + await this.execute(args); + core.info(`✅ Successfully built image${builtImage.length !== 1 ? "s" : ""} "${builtImage.join(", ")}"`); + } + + // Unfortunately buildah doesn't support the exists command yet + // https://github.com/containers/buildah/issues/4217 + + // async manifestExists(manifest: string): Promise { + // const args: string[] = [ "manifest", "exists" ]; + // args.push(manifest); + // const execOptions: exec.ExecOptions = {ignoreReturnCode: true}; + // core.info(`Checking if manifest ${manifest} exists`); + // const {exitCode} = await this.execute(args, execOptions); + // return exitCode ? false : true; + // } + + async manifestRm(manifest: string): Promise { + const execOptions: exec.ExecOptions = { ignoreReturnCode: true }; + const args: string[] = [ "manifest", "rm" ]; + args.push(manifest); + core.info(`Removing existing manifest ${manifest}`); + await this.execute(args, execOptions); + } + + async manifestCreate(manifest: string): Promise { + const args: string[] = [ "manifest", "create" ]; + args.push(manifest); + core.info(`Creating manifest ${manifest}`); + await this.execute(args); + } + + async manifestAdd(manifest: string, image: string): Promise { + const args: string[] = [ "manifest", "add" ]; + args.push(manifest); + args.push(image); + core.info(`Adding image "${image}" to the manifest.`); + await this.execute(args); + } + + private static convertArrayToStringArg(args: string[]): string { + let arrayAsString = "["; + args.forEach((arg) => { + arrayAsString += `"${arg}",`; + }); + return `${arrayAsString.slice(0, -1)}]`; + } + + async execute( + args: string[], + execOptions: exec.ExecOptions & { group?: boolean } = {}, + ): Promise { + // ghCore.info(`${EXECUTABLE} ${args.join(" ")}`) + + let stdout = ""; + let stderr = ""; + + const finalExecOptions = { ...execOptions }; + finalExecOptions.ignoreReturnCode = true; // the return code is processed below + + finalExecOptions.listeners = { + stdline: (line): void => { + stdout += line + "\n"; + }, + errline: (line):void => { + stderr += line + "\n"; + }, + }; + + if (execOptions.group) { + const groupName = [ this.executable, ...args ].join(" "); + core.startGroup(groupName); + } + + // To solve https://github.com/redhat-actions/buildah-build/issues/45 + const execEnv: { [key: string] : string } = {}; + Object.entries(process.env).forEach(([ key, value ]) => { + if (value != null) { + execEnv[key] = value; + } + }); + + if (this.storageOptsEnv) { + execEnv.STORAGE_OPTS = this.storageOptsEnv; + } + + finalExecOptions.env = execEnv; + + try { + const exitCode = await exec.exec(this.executable, args, finalExecOptions); + + if (execOptions.ignoreReturnCode !== true && exitCode !== 0) { + // Throwing the stderr as part of the Error makes the stderr + // show up in the action outline, which saves some clicking when debugging. + let error = `${path.basename(this.executable)} exited with code ${exitCode}`; + if (stderr) { + error += `\n${stderr}`; + } + throw new Error(error); + } + + return { + exitCode, output: stdout, error: stderr, + }; + } + + finally { + if (execOptions.group) { + core.endGroup(); + } + } + } +} diff --git a/src/generated/inputs-outputs.ts b/src/generated/inputs-outputs.ts new file mode 100644 index 0000000..14db811 --- /dev/null +++ b/src/generated/inputs-outputs.ts @@ -0,0 +1,154 @@ +// This file was auto-generated by action-io-generator. Do not edit by hand! +export enum Inputs { + /** + * Label the image with this ARCH, instead of defaulting to the host architecture + * Required: false + * Default: None. + */ + ARCH = "arch", + /** + * 'Same as input 'arch', use this for multiple architectures. + * Seperate them by a comma' + * Required: false + * Default: None. + */ + ARCHS = "archs", + /** + * The base image to use to create a new container image + * Required: false + * Default: None. + */ + BASE_IMAGE = "base-image", + /** + * List of --build-args to pass to buildah + * Required: false + * Default: None. + */ + BUILD_ARGS = "build-args", + /** + * List of Containerfile paths (eg: ./Containerfile) + * Required: false + * Default: None. + */ + CONTAINERFILES = "containerfiles", + /** + * List of files/directories to copy inside the base image + * Required: false + * Default: None. + */ + CONTENT = "content", + /** + * Path of the directory to use as context (default: .) + * Required: false + * Default: "." + */ + CONTEXT = "context", + /** + * Alias for "containerfiles". "containerfiles" takes precedence if both are set. + * Required: false + * Default: None. + */ + DOCKERFILES = "dockerfiles", + /** + * The entry point to set for containers based on image + * Required: false + * Default: None. + */ + ENTRYPOINT = "entrypoint", + /** + * List of environment variables to be set when running containers based on image + * Required: false + * Default: None. + */ + ENVS = "envs", + /** + * Extra args to be passed to buildah bud and buildah from. + * Separate arguments by newline. Do not use quotes - @actions/exec will do the quoting for you. + * Required: false + * Default: None. + */ + EXTRA_ARGS = "extra-args", + /** + * The name (reference) of the image to build + * Required: false + * Default: None. + */ + IMAGE = "image", + /** + * The labels of the image to build. Seperate by newline. For example, "io.containers.capabilities=sys_admin,mknod". + * Required: false + * Default: None. + */ + LABELS = "labels", + /** + * Set to true to cache intermediate layers during build process + * Required: false + * Default: None. + */ + LAYERS = "layers", + /** + * Set to true to build using the OCI image format instead of the Docker image format + * Required: false + * Default: "false" + */ + OCI = "oci", + /** + * Label the image with this PLATFORM, instead of defaulting to the host platform. + * Only supported for containerfile builds. + * Required: false + * Default: None. + */ + PLATFORM = "platform", + /** + * 'Same as input 'platform', use this for multiple platforms. + * Seperate them by a comma' + * Required: false + * Default: None. + */ + PLATFORMS = "platforms", + /** + * The port to expose when running containers based on image + * Required: false + * Default: None. + */ + PORT = "port", + /** + * The tags of the image to build. For multiple tags, seperate by whitespace. For example, "latest v1". + * Required: false + * Default: "latest" + */ + TAGS = "tags", + /** + * Require HTTPS and verify certificates when accessing the registry. Defaults to true. + * Required: false + * Default: "true" + */ + TLS_VERIFY = "tls-verify", + /** + * The working directory to use within the container + * Required: false + * Default: None. + */ + WORKDIR = "workdir", +} + +export enum Outputs { + /** + * Name of the image built + * Required: false + * Default: None. + */ + IMAGE = "image", + /** + * Name of the image tagged with the first tag present + * Required: false + * Default: None. + */ + IMAGE_WITH_TAG = "image-with-tag", + /** + * List of the tags that were created, separated by spaces + * Required: false + * Default: None. + */ + TAGS = "tags", +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5f5cd4e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,311 @@ +/*************************************************************************************************** + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + **************************************************************************************************/ + +import * as core from "@actions/core"; +import * as io from "@actions/io"; +import * as path from "path"; +import { Inputs, Outputs } from "./generated/inputs-outputs"; +import { BuildahCli, BuildahConfigSettings } from "./buildah"; +import { + getArch, getPlatform, getContainerfiles, getInputList, splitByNewline, + isFullImageName, getFullImageName, removeIllegalCharacters, +} from "./utils"; + +export async function run(): Promise { + //if (process.env.RUNNER_OS !== "Linux") { + //throw new Error("buildah, and therefore this action, only works on Linux. Please use a Linux runner."); + //} + + // get buildah cli + const buildahPath = await io.which("buildah", true); + const cli: BuildahCli = new BuildahCli(buildahPath); + + // print buildah version + await cli.execute([ "version" ], { group: true }); + + // Check if fuse-overlayfs exists and find the storage driver + await cli.setStorageOptsEnv(); + + const DEFAULT_TAG = "latest"; + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const containerFiles = getContainerfiles(); + const image = core.getInput(Inputs.IMAGE); + const tags = core.getInput(Inputs.TAGS); + const tagsList: string[] = tags.trim().split(/\s+/); + const labels = core.getInput(Inputs.LABELS); + const labelsList: string[] = labels ? splitByNewline(labels) : []; + + const normalizedTagsList: string[] = []; + let isNormalized = false; + for (const tag of tagsList) { + normalizedTagsList.push(tag.toLowerCase()); + if (tag.toLowerCase() !== tag) { + isNormalized = true; + } + } + const normalizedImage = image.toLowerCase(); + if (isNormalized || image !== normalizedImage) { + core.warning(`Reference to image and/or tag must be lowercase.` + + ` Reference has been converted to be compliant with standard.`); + } + + // info message if user doesn't provides any tag + if (tagsList.length === 0) { + core.info(`Input "${Inputs.TAGS}" is not provided, using default tag "${DEFAULT_TAG}"`); + tagsList.push(DEFAULT_TAG); + } + + const inputExtraArgsStr = core.getInput(Inputs.EXTRA_ARGS); + let buildahExtraArgs: string[] = []; + if (inputExtraArgsStr) { + // transform the array of lines into an array of arguments + // by splitting over lines, then over spaces, then trimming. + const lines = splitByNewline(inputExtraArgsStr); + buildahExtraArgs = lines.flatMap((line) => line.split(" ")).map((arg) => arg.trim()); + } + + // check if all tags provided are in `image:tag` format + const isFullImageNameTag = isFullImageName(normalizedTagsList[0]); + if (normalizedTagsList.some((tag) => isFullImageName(tag) !== isFullImageNameTag)) { + throw new Error(`Input "${Inputs.TAGS}" cannot have a mix of full name and non full name tags. Refer to https://github.com/redhat-actions/buildah-build#image-tag-inputs`); + } + if (!isFullImageNameTag && !normalizedImage) { + throw new Error(`Input "${Inputs.IMAGE}" must be provided when not using full image name tags. Refer to https://github.com/redhat-actions/buildah-build#image-tag-inputs`); + } + + const newImage = getFullImageName(normalizedImage, normalizedTagsList[0]); + const useOCI = core.getInput(Inputs.OCI) === "true"; + + const archs = getArch(); + const platforms = getPlatform(); + + if ((archs.length > 0) && (platforms.length > 0)) { + throw new Error("The --platform option may not be used in combination with the --arch option."); + } + + const builtImage = []; + if (containerFiles.length !== 0) { + builtImage.push(...await doBuildUsingContainerFiles( + cli, + newImage, + workspace, + containerFiles, + useOCI, + archs, + platforms, + labelsList, + buildahExtraArgs + )); + } + else { + if (platforms.length > 0) { + throw new Error("The --platform option is not supported for builds without containerfiles."); + } + builtImage.push(...await doBuildFromScratch(cli, newImage, useOCI, archs, labelsList, buildahExtraArgs)); + } + + if ((archs.length > 1) || (platforms.length > 1)) { + core.info(`Creating manifest with tag${normalizedTagsList.length !== 1 ? "s" : ""} ` + + `"${normalizedTagsList.join(", ")}"`); + const builtManifest = []; + for (const tag of normalizedTagsList) { + const manifestName = getFullImageName(normalizedImage, tag); + // Force-remove existing manifest to prevent errors on recurring build on the same machine + await cli.manifestRm(manifestName); + await cli.manifestCreate(manifestName); + builtManifest.push(manifestName); + + for (const arch of archs) { + const tagSuffix = removeIllegalCharacters(arch); + await cli.manifestAdd(manifestName, `${newImage}-${tagSuffix}`); + } + + for (const platform of platforms) { + const tagSuffix = removeIllegalCharacters(platform); + await cli.manifestAdd(manifestName, `${newImage}-${tagSuffix}`); + } + } + + core.info(`✅ Successfully built image${builtImage.length !== 1 ? "s" : ""} "${builtImage.join(", ")}" ` + + `and manifest${builtManifest.length !== 1 ? "s" : ""} "${builtManifest.join(", ")}"`); + } + else if (normalizedTagsList.length > 1) { + await cli.tag(normalizedImage, normalizedTagsList); + } + else if (normalizedTagsList.length === 1) { + core.info(`✅ Successfully built image "${getFullImageName(normalizedImage, normalizedTagsList[0])}"`); + } + + core.setOutput(Outputs.IMAGE, normalizedImage); + core.setOutput(Outputs.TAGS, tags); + core.setOutput(Outputs.IMAGE_WITH_TAG, newImage); +} + +async function doBuildUsingContainerFiles( + cli: BuildahCli, + newImage: string, + workspace: string, + containerFiles: string[], + useOCI: boolean, + archs: string[], + platforms: string[], + labels: string[], + extraArgs: string[] +): Promise { + if (containerFiles.length === 1) { + core.info(`Performing build from Containerfile`); + } + else { + core.info(`Performing build from ${containerFiles.length} Containerfiles`); + } + + const context = path.join(workspace, core.getInput(Inputs.CONTEXT)); + const buildArgs = getInputList(Inputs.BUILD_ARGS); + const containerFileAbsPaths = containerFiles.map((file) => path.join(workspace, file)); + const layers = core.getInput(Inputs.LAYERS); + const tlsVerify = core.getInput(Inputs.TLS_VERIFY) === "true"; + + const builtImage = []; + // since multi arch image can not have same tag + // therefore, appending arch/platform in the tag + if (archs.length > 0 || platforms.length > 0) { + for (const arch of archs) { + // handling it seperately as, there is no need of + // tagSuffix if only one image has to be built + let tagSuffix = ""; + if (archs.length > 1) { + tagSuffix = `-${removeIllegalCharacters(arch)}`; + } + await cli.buildUsingDocker( + `${newImage}${tagSuffix}`, + context, + containerFileAbsPaths, + buildArgs, + useOCI, + labels, + layers, + extraArgs, + tlsVerify, + arch + ); + builtImage.push(`${newImage}${tagSuffix}`); + } + + for (const platform of platforms) { + let tagSuffix = ""; + if (platforms.length > 1) { + tagSuffix = `-${removeIllegalCharacters(platform)}`; + } + await cli.buildUsingDocker( + `${newImage}${tagSuffix}`, + context, + containerFileAbsPaths, + buildArgs, + useOCI, + labels, + layers, + extraArgs, + tlsVerify, + undefined, + platform + ); + builtImage.push(`${newImage}${tagSuffix}`); + } + } + + else if (archs.length === 1 || platforms.length === 1) { + await cli.buildUsingDocker( + newImage, + context, + containerFileAbsPaths, + buildArgs, + useOCI, + labels, + layers, + extraArgs, + tlsVerify, + archs[0], + platforms[0] + ); + builtImage.push(newImage); + } + else { + await cli.buildUsingDocker( + newImage, + context, + containerFileAbsPaths, + buildArgs, + useOCI, + labels, + layers, + extraArgs, + tlsVerify + ); + builtImage.push(newImage); + } + + return builtImage; +} + +async function doBuildFromScratch( + cli: BuildahCli, + newImage: string, + useOCI: boolean, + archs: string[], + labels: string[], + extraArgs: string[] +): Promise { + core.info(`Performing build from scratch`); + + const baseImage = core.getInput(Inputs.BASE_IMAGE, { required: true }); + const content = getInputList(Inputs.CONTENT); + const entrypoint = getInputList(Inputs.ENTRYPOINT); + const port = core.getInput(Inputs.PORT); + const workingDir = core.getInput(Inputs.WORKDIR); + const envs = getInputList(Inputs.ENVS); + const tlsVerify = core.getInput(Inputs.TLS_VERIFY) === "true"; + + const container = await cli.from(baseImage, tlsVerify, extraArgs); + const containerId = container.output.replace("\n", ""); + + const builtImage = []; + if (archs.length > 0) { + for (const arch of archs) { + let tagSuffix = ""; + if (archs.length > 1) { + tagSuffix = `-${removeIllegalCharacters(arch)}`; + } + const newImageConfig: BuildahConfigSettings = { + entrypoint, + port, + workingdir: workingDir, + envs, + arch, + labels, + }; + await cli.config(containerId, newImageConfig); + await cli.copy(containerId, content); + await cli.commit(containerId, `${newImage}${tagSuffix}`, useOCI); + builtImage.push(`${newImage}${tagSuffix}`); + } + } + else { + const newImageConfig: BuildahConfigSettings = { + entrypoint, + port, + workingdir: workingDir, + envs, + labels, + }; + await cli.config(containerId, newImageConfig); + await cli.copy(containerId, content); + await cli.commit(containerId, newImage, useOCI); + builtImage.push(newImage); + } + + return builtImage; +} + +run().catch(core.setFailed); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..af85e5f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,12 @@ +/*************************************************************************************************** + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + **************************************************************************************************/ + +type CommandResult = { + exitCode: number + output: string + error: string +}; + +export default CommandResult; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..16d71a3 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,173 @@ +/*************************************************************************************************** + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + **************************************************************************************************/ + +import * as ini from "ini"; +import { promises as fs } from "fs"; +import * as core from "@actions/core"; +import * as path from "path"; +import * as io from "@actions/io"; +import * as os from "os"; +import { Inputs } from "./generated/inputs-outputs"; + +async function findStorageDriver(filePaths: string[]): Promise { + let storageDriver = ""; + for (const filePath of filePaths) { + core.debug(`Checking if the storage file exists at ${filePath}`); + if (await fileExists(filePath)) { + core.debug(`Storage file exists at ${filePath}`); + const fileContent = ini.parse(await fs.readFile(filePath, "utf-8")); + if (fileContent.storage.driver) { + storageDriver = fileContent.storage.driver; + } + } + } + return storageDriver; +} + +export async function isStorageDriverOverlay(): Promise { + let xdgConfigHome = path.join(os.homedir(), ".config"); + if (process.env.XDG_CONFIG_HOME) { + xdgConfigHome = process.env.XDG_CONFIG_HOME; + } + const filePaths: string[] = [ + "/etc/containers/storage.conf", + path.join(xdgConfigHome, "containers/storage.conf"), + ]; + const storageDriver = await findStorageDriver(filePaths); + return (storageDriver === "overlay"); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } + catch (err) { + return false; + } +} + +export async function findFuseOverlayfsPath(): Promise { + let fuseOverlayfsPath; + try { + fuseOverlayfsPath = await io.which("fuse-overlayfs"); + } + catch (err) { + if (err instanceof Error) { + core.debug(err.message); + } + } + + return fuseOverlayfsPath; +} + +export function splitByNewline(s: string): string[] { + return s.split(/\r?\n/); +} + +export function getArch(): string[] { + const archs = getCommaSeperatedInput(Inputs.ARCHS); + + const arch = core.getInput(Inputs.ARCH); + + if (arch && archs.length > 0) { + core.warning( + `Both "${Inputs.ARCH}" and "${Inputs.ARCHS}" inputs are set. ` + + `Please use "${Inputs.ARCH}" if you want to provide multiple ` + + `ARCH else use ${Inputs.ARCH}". "${Inputs.ARCHS}" takes preference.` + ); + } + + if (archs.length > 0) { + return archs; + } + else if (arch) { + return [ arch ]; + } + return []; +} + +export function getPlatform(): string[] { + const platform = core.getInput(Inputs.PLATFORM); + const platforms = getCommaSeperatedInput(Inputs.PLATFORMS); + + if (platform && platforms.length > 0) { + core.warning( + `Both "${Inputs.PLATFORM}" and "${Inputs.PLATFORMS}" inputs are set. ` + + `Please use "${Inputs.PLATFORMS}" if you want to provide multiple ` + + `PLATFORM else use ${Inputs.PLATFORM}". "${Inputs.PLATFORMS}" takes preference.` + ); + } + + if (platforms.length > 0) { + core.debug("return platforms"); + return platforms; + } + else if (platform) { + core.debug("return platform"); + return [ platform ]; + } + core.debug("return empty"); + return []; +} + +export function getContainerfiles(): string[] { + // 'containerfile' should be used over 'dockerfile', + // see https://github.com/redhat-actions/buildah-build/issues/57 + const containerfiles = getInputList(Inputs.CONTAINERFILES); + const dockerfiles = getInputList(Inputs.DOCKERFILES); + + if (containerfiles.length !== 0 && dockerfiles.length !== 0) { + core.warning( + `Both "${Inputs.CONTAINERFILES}" and "${Inputs.DOCKERFILES}" inputs are set. ` + + `Please use only one of these two inputs, as they are aliases of one another. ` + + `"${Inputs.CONTAINERFILES}" takes precedence.` + ); + } + + return containerfiles.length !== 0 ? containerfiles : dockerfiles; +} + +export function getInputList(name: string): string[] { + const items = core.getInput(name); + if (!items) { + return []; + } + const splitItems = splitByNewline(items); + return splitItems + .reduce( + (acc, line) => acc.concat(line).map((item) => item.trim()), + [], + ); +} + +export function getCommaSeperatedInput(name: string): string[] { + const items = core.getInput(name); + if (items.length === 0) { + core.debug("empty"); + return []; + } + const splitItems = items.split(","); + return splitItems + .reduce( + (acc, line) => acc.concat(line).map((item) => item.trim()), + [], + ); +} + +export function isFullImageName(image: string): boolean { + return image.indexOf(":") > 0; +} + +export function getFullImageName(image: string, tag: string): string { + if (isFullImageName(tag)) { + return tag; + } + return `${image}:${tag}`; +} + +export function removeIllegalCharacters(item: string): string { + return item.replace(/[^a-zA-Z0-9 ]/g, ""); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e0606de --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@redhat-actions/tsconfig", + "compilerOptions": { + "rootDir": "src/", + "outDir": "out/" + }, + "include": [ + "src/" + ], +} \ No newline at end of file