mirror of
https://code.forgejo.org/actions/cache.git
synced 2025-04-23 03:45:31 +00:00
Initial commit
This commit is contained in:
parent
551cf17d91
commit
37c45447e4
20 changed files with 6242 additions and 0 deletions
126
src/cacheHttpClient.ts
Normal file
126
src/cacheHttpClient.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import * as core from "@actions/core";
|
||||
import * as fs from "fs";
|
||||
|
||||
import { BearerCredentialHandler } from "typed-rest-client/Handlers";
|
||||
import { HttpClient } from "typed-rest-client/HttpClient";
|
||||
import { IHttpClientResponse } from "typed-rest-client/Interfaces";
|
||||
import { RestClient, IRequestOptions } from "typed-rest-client/RestClient";
|
||||
|
||||
import { ArtifactCacheEntry } from "./contracts";
|
||||
|
||||
export async function getCacheEntry(
|
||||
keys: string[]
|
||||
): Promise<ArtifactCacheEntry> {
|
||||
const cacheUrl = getCacheUrl();
|
||||
const token = process.env["ACTIONS_RUNTIME_TOKEN"] || "";
|
||||
const bearerCredentialHandler = new BearerCredentialHandler(token);
|
||||
|
||||
const resource = `_apis/artifactcache/cache?keys=${encodeURIComponent(
|
||||
keys.join(",")
|
||||
)}`;
|
||||
|
||||
const restClient = new RestClient("actions/cache", cacheUrl, [
|
||||
bearerCredentialHandler
|
||||
]);
|
||||
|
||||
const response = await restClient.get<ArtifactCacheEntry>(
|
||||
resource,
|
||||
getRequestOptions()
|
||||
);
|
||||
if (response.statusCode === 204) {
|
||||
throw new Error(
|
||||
`Cache not found for input keys: ${JSON.stringify(keys)}.`
|
||||
);
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error(`Cache service responded with ${response.statusCode}`);
|
||||
}
|
||||
const cacheResult = response.result;
|
||||
core.debug(`Cache Result:`);
|
||||
core.debug(JSON.stringify(cacheResult));
|
||||
if (!cacheResult || !cacheResult.archiveLocation) {
|
||||
throw new Error("Cache not found.");
|
||||
}
|
||||
|
||||
return cacheResult;
|
||||
}
|
||||
|
||||
export async function downloadCache(
|
||||
cacheEntry: ArtifactCacheEntry,
|
||||
archivePath: string
|
||||
): Promise<void> {
|
||||
const stream = fs.createWriteStream(archivePath);
|
||||
const httpClient = new HttpClient("actions/cache");
|
||||
const downloadResponse = await httpClient.get(cacheEntry.archiveLocation!);
|
||||
await pipeResponseToStream(downloadResponse, stream);
|
||||
}
|
||||
|
||||
async function pipeResponseToStream(
|
||||
response: IHttpClientResponse,
|
||||
stream: NodeJS.WritableStream
|
||||
): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
response.message.pipe(stream).on("close", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCache(stream: NodeJS.ReadableStream, key: string) {
|
||||
const cacheUrl = getCacheUrl();
|
||||
const token = process.env["ACTIONS_RUNTIME_TOKEN"] || "";
|
||||
const bearerCredentialHandler = new BearerCredentialHandler(token);
|
||||
|
||||
const resource = `_apis/artifactcache/cache/${encodeURIComponent(key)}`;
|
||||
const postUrl = cacheUrl + resource;
|
||||
|
||||
const restClient = new RestClient("actions/cache", undefined, [
|
||||
bearerCredentialHandler
|
||||
]);
|
||||
|
||||
const requestOptions = getRequestOptions();
|
||||
requestOptions.additionalHeaders = {
|
||||
"Content-Type": "application/octet-stream"
|
||||
};
|
||||
|
||||
const response = await restClient.uploadStream<void>(
|
||||
"POST",
|
||||
postUrl,
|
||||
stream,
|
||||
requestOptions
|
||||
);
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error(`Cache service responded with ${response.statusCode}`);
|
||||
}
|
||||
|
||||
core.info("Cache saved successfully");
|
||||
}
|
||||
|
||||
function getRequestOptions(): IRequestOptions {
|
||||
const requestOptions: IRequestOptions = {
|
||||
acceptHeader: createAcceptHeader("application/json", "5.2-preview.1")
|
||||
};
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
function createAcceptHeader(type: string, apiVersion: string): string {
|
||||
return `${type};api-version=${apiVersion}`;
|
||||
}
|
||||
|
||||
function getCacheUrl(): string {
|
||||
// Ideally we just use ACTIONS_CACHE_URL
|
||||
let cacheUrl: string = (
|
||||
process.env["ACTIONS_CACHE_URL"] ||
|
||||
process.env["ACTIONS_RUNTIME_URL"] ||
|
||||
""
|
||||
).replace("pipelines", "artifactcache");
|
||||
if (!cacheUrl) {
|
||||
throw new Error(
|
||||
"Cache Service Url not found, unable to restore cache."
|
||||
);
|
||||
}
|
||||
|
||||
core.debug(`Cache Url: ${cacheUrl}`);
|
||||
return cacheUrl;
|
||||
}
|
14
src/constants.ts
Normal file
14
src/constants.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export namespace Inputs {
|
||||
export const Key = "key";
|
||||
export const Path = "path";
|
||||
export const RestoreKeys = "restore-keys";
|
||||
}
|
||||
|
||||
export namespace Outputs {
|
||||
export const CacheHit = "cache-hit";
|
||||
}
|
||||
|
||||
export namespace State {
|
||||
export const CacheKey = "CACHE_KEY";
|
||||
export const CacheResult = "CACHE_RESULT";
|
||||
}
|
6
src/contracts.d.ts
vendored
Normal file
6
src/contracts.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface ArtifactCacheEntry {
|
||||
cacheKey?: string;
|
||||
scope?: string;
|
||||
creationTime?: string;
|
||||
archiveLocation?: string;
|
||||
}
|
115
src/restore.ts
Normal file
115
src/restore.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import * as core from "@actions/core";
|
||||
import { exec } from "@actions/exec";
|
||||
import * as io from "@actions/io";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import * as cacheHttpClient from "./cacheHttpClient";
|
||||
import { Inputs, State } from "./constants";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Validate inputs, this can cause task failure
|
||||
let cachePath = utils.resolvePath(
|
||||
core.getInput(Inputs.Path, { required: true })
|
||||
);
|
||||
core.debug(`Cache Path: ${cachePath}`);
|
||||
|
||||
const primaryKey = core.getInput(Inputs.Key, { required: true });
|
||||
core.saveState(State.CacheKey, primaryKey);
|
||||
|
||||
const restoreKeys = core.getInput(Inputs.RestoreKeys).split("\n");
|
||||
const keys = [primaryKey, ...restoreKeys];
|
||||
|
||||
core.debug("Resolved Keys:");
|
||||
core.debug(JSON.stringify(keys));
|
||||
|
||||
if (keys.length > 10) {
|
||||
core.setFailed(
|
||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 512) {
|
||||
core.setFailed(
|
||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const regex = /^[^,]*$/;
|
||||
if (!regex.test(key)) {
|
||||
core.setFailed(
|
||||
`Key Validation Error: ${key} cannot contain commas.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let archivePath = path.join(
|
||||
await utils.createTempDirectory(),
|
||||
"cache.tgz"
|
||||
);
|
||||
core.debug(`Archive Path: ${archivePath}`);
|
||||
|
||||
const cacheEntry = await cacheHttpClient.getCacheEntry(keys);
|
||||
|
||||
// Store the cache result
|
||||
utils.setCacheState(cacheEntry);
|
||||
|
||||
// Download the cache from the cache entry
|
||||
await cacheHttpClient.downloadCache(cacheEntry, archivePath);
|
||||
|
||||
io.mkdirP(cachePath);
|
||||
|
||||
// http://man7.org/linux/man-pages/man1/tar.1.html
|
||||
// tar [-options] <name of the tar archive> [files or directories which to add into archive]
|
||||
const args = ["-xz"];
|
||||
|
||||
const IS_WINDOWS = process.platform === "win32";
|
||||
if (IS_WINDOWS) {
|
||||
args.push("--force-local");
|
||||
archivePath = archivePath.replace(/\\/g, "/");
|
||||
cachePath = cachePath.replace(/\\/g, "/");
|
||||
}
|
||||
args.push(...["-f", archivePath, "-C", cachePath]);
|
||||
|
||||
const tarPath = await io.which("tar", true);
|
||||
core.debug(`Tar Path: ${tarPath}`);
|
||||
|
||||
const archiveFileSize = fs.statSync(archivePath).size;
|
||||
core.debug(`File Size: ${archiveFileSize}`);
|
||||
|
||||
await exec(`"${tarPath}"`, args);
|
||||
|
||||
const isExactKeyMatch = utils.isExactKeyMatch(
|
||||
primaryKey,
|
||||
cacheEntry
|
||||
);
|
||||
utils.setCacheHitOutput(isExactKeyMatch);
|
||||
|
||||
core.info(
|
||||
`Cache restored from key:${cacheEntry && cacheEntry.cacheKey}`
|
||||
);
|
||||
|
||||
try {
|
||||
core.info("Cache Checksum:");
|
||||
await exec(`md5sum`, [`${archivePath}`]);
|
||||
} catch (error) {
|
||||
core.debug(`Failed to checkum with ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(error.message);
|
||||
utils.setCacheHitOutput(false);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
export default run;
|
83
src/save.ts
Normal file
83
src/save.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import * as core from "@actions/core";
|
||||
import { exec } from "@actions/exec";
|
||||
|
||||
import * as io from "@actions/io";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import * as cacheHttpClient from "./cacheHttpClient";
|
||||
import { Inputs, State } from "./constants";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const state = utils.getCacheState();
|
||||
|
||||
// Inputs are re-evaluted before the post action, so we want the original key used for restore
|
||||
const primaryKey = core.getState(State.CacheKey);
|
||||
if (!primaryKey) {
|
||||
core.warning(`Error retrieving key from state.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (utils.isExactKeyMatch(primaryKey, state)) {
|
||||
core.info(
|
||||
`Cache hit occurred on the primary key ${primaryKey}, not saving cache.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cachePath = utils.resolvePath(
|
||||
core.getInput(Inputs.Path, { required: true })
|
||||
);
|
||||
core.debug(`Cache Path: ${cachePath}`);
|
||||
|
||||
let archivePath = path.join(
|
||||
await utils.createTempDirectory(),
|
||||
"cache.tgz"
|
||||
);
|
||||
core.debug(`Archive Path: ${archivePath}`);
|
||||
|
||||
// http://man7.org/linux/man-pages/man1/tar.1.html
|
||||
// tar [-options] <name of the tar archive> [files or directories which to add into archive]
|
||||
const args = ["-cz"];
|
||||
const IS_WINDOWS = process.platform === "win32";
|
||||
if (IS_WINDOWS) {
|
||||
args.push("--force-local");
|
||||
archivePath = archivePath.replace(/\\/g, "/");
|
||||
cachePath = cachePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
args.push(...["-f", archivePath, "-C", cachePath, "."]);
|
||||
|
||||
const tarPath = await io.which("tar", true);
|
||||
core.debug(`Tar Path: ${tarPath}`);
|
||||
await exec(`"${tarPath}"`, args);
|
||||
|
||||
const fileSizeLimit = 200 * 1024 * 1024; // 200MB
|
||||
const archiveFileSize = fs.statSync(archivePath).size;
|
||||
core.debug(`File Size: ${archiveFileSize}`);
|
||||
if (archiveFileSize > fileSizeLimit) {
|
||||
core.warning(
|
||||
`Cache size of ${archiveFileSize} bytes is over the 200MB limit, not saving cache.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(archivePath);
|
||||
await cacheHttpClient.saveCache(stream, primaryKey);
|
||||
|
||||
try {
|
||||
core.info("Cache Checksum:");
|
||||
await exec(`md5sum`, [`${archivePath}`]);
|
||||
} catch (error) {
|
||||
core.debug(`Failed to checkum with ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
export default run;
|
81
src/utils/actionUtils.ts
Normal file
81
src/utils/actionUtils.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as core from "@actions/core";
|
||||
import * as io from "@actions/io";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as uuidV4 from "uuid/v4";
|
||||
|
||||
import { Outputs, State } from "../constants";
|
||||
import { ArtifactCacheEntry } from "../contracts";
|
||||
|
||||
// From https://github.com/actions/toolkit/blob/master/packages/tool-cache/src/tool-cache.ts#L23
|
||||
export async function createTempDirectory(): Promise<string> {
|
||||
const IS_WINDOWS = process.platform === "win32";
|
||||
|
||||
let tempDirectory: string = process.env["RUNNER_TEMP"] || "";
|
||||
|
||||
if (!tempDirectory) {
|
||||
let baseLocation: string;
|
||||
if (IS_WINDOWS) {
|
||||
// On Windows use the USERPROFILE env variable
|
||||
baseLocation = process.env["USERPROFILE"] || "C:\\";
|
||||
} else {
|
||||
if (process.platform === "darwin") {
|
||||
baseLocation = "/Users";
|
||||
} else {
|
||||
baseLocation = "/home";
|
||||
}
|
||||
}
|
||||
tempDirectory = path.join(baseLocation, "actions", "temp");
|
||||
}
|
||||
const dest = path.join(tempDirectory, uuidV4.default());
|
||||
await io.mkdirP(dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function isExactKeyMatch(
|
||||
key: string,
|
||||
cacheResult?: ArtifactCacheEntry
|
||||
): boolean {
|
||||
return !!(
|
||||
cacheResult &&
|
||||
cacheResult.cacheKey &&
|
||||
cacheResult.cacheKey.localeCompare(key, undefined, {
|
||||
sensitivity: "accent"
|
||||
}) === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function setOutputAndState(
|
||||
key: string,
|
||||
cacheResult?: ArtifactCacheEntry
|
||||
) {
|
||||
setCacheHitOutput(isExactKeyMatch(key, cacheResult));
|
||||
// Store the cache result if it exists
|
||||
cacheResult && setCacheState(cacheResult);
|
||||
}
|
||||
|
||||
export function getCacheState(): ArtifactCacheEntry | undefined {
|
||||
const stateData = core.getState(State.CacheResult);
|
||||
core.debug(`State: ${stateData}`);
|
||||
return (stateData && JSON.parse(stateData)) as ArtifactCacheEntry;
|
||||
}
|
||||
|
||||
export function setCacheState(state: ArtifactCacheEntry) {
|
||||
core.saveState(State.CacheResult, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function setCacheHitOutput(isCacheHit: boolean) {
|
||||
core.setOutput(Outputs.CacheHit, isCacheHit.toString());
|
||||
}
|
||||
|
||||
export function resolvePath(filePath: string): string {
|
||||
if (filePath[0] === "~") {
|
||||
const home = os.homedir();
|
||||
if (!home) {
|
||||
throw new Error("Unable to resole `~` to HOME");
|
||||
}
|
||||
return path.join(home, filePath.slice(1));
|
||||
}
|
||||
|
||||
return path.resolve(filePath);
|
||||
}
|
7
src/utils/testUtils.ts
Normal file
7
src/utils/testUtils.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
function getInputName(name: string): string {
|
||||
return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function setInput(name: string, value: string) {
|
||||
process.env[getInputName(name)] = value;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue