Cache feature flags on disk

This will allow feature flags to be shared across steps in the same job,
avoiding an error we saw earlier where the init action had the flag
enabled, but the analyze step had it disabled.

This uses the runner's temp folder to cache the flags file, which will
stick around until the job completes.
This commit is contained in:
Andrew Eisenberg 2022-11-21 11:14:38 -08:00
parent 4fddc51e4f
commit c29fca48a1
12 changed files with 255 additions and 49 deletions

View file

@ -217,7 +217,12 @@ async function run() {
const gitHubVersion = await getGitHubVersion();
const features = new Features(gitHubVersion, repositoryNwo, logger);
const features = new Features(
gitHubVersion,
repositoryNwo,
actionsUtil.getTemporaryDirectory(),
logger
);
await runAutobuildIfLegacyGoWorkflow(config, logger);

View file

@ -1,3 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import test from "ava";
import {
@ -5,6 +8,7 @@ import {
featureConfig,
FeatureEnablement,
Features,
FEATURE_FLAGS_FILE_NAME,
} from "./feature-flags";
import { getRunnerLogger } from "./logging";
import { parseRepositoryNwo } from "./repository";
@ -42,7 +46,7 @@ for (const variant of ALL_FEATURES_DISABLED_VARIANTS) {
test(`All features are disabled if running against ${variant.description}`, async (t) => {
await withTmpDir(async (tmpDir) => {
const loggedMessages = [];
const featureEnablement = setUpTests(
const featureEnablement = setUpFeatureFlagTests(
tmpDir,
getRecordingLogger(loggedMessages),
variant.gitHubVersion
@ -72,7 +76,7 @@ for (const variant of ALL_FEATURES_DISABLED_VARIANTS) {
test("API response missing", async (t) => {
await withTmpDir(async (tmpDir) => {
const loggedMessages: LoggedMessage[] = [];
const featureEnablement = setUpTests(
const featureEnablement = setUpFeatureFlagTests(
tmpDir,
getRecordingLogger(loggedMessages)
);
@ -94,7 +98,7 @@ test("API response missing", async (t) => {
test("Features are disabled if they're not returned in API response", async (t) => {
await withTmpDir(async (tmpDir) => {
const loggedMessages: LoggedMessage[] = [];
const featureEnablement = setUpTests(
const featureEnablement = setUpFeatureFlagTests(
tmpDir,
getRecordingLogger(loggedMessages)
);
@ -116,7 +120,7 @@ test("Features are disabled if they're not returned in API response", async (t)
test("Feature flags exception is propagated if the API request errors", async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
mockFeatureFlagApiEndpoint(500, {});
@ -137,7 +141,7 @@ test("Feature flags exception is propagated if the API request errors", async (t
for (const feature of Object.keys(featureConfig)) {
test(`Only feature '${feature}' is enabled if enabled in the API response. Other features disabled`, async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
// set all features to false except the one we're testing
const expectedFeatureEnablement: { [feature: string]: boolean } = {};
@ -162,7 +166,7 @@ for (const feature of Object.keys(featureConfig)) {
test(`Only feature '${feature}' is enabled if the associated environment variable is true. Others disabled.`, async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
const expectedFeatureEnablement = initializeFeatures(false);
mockFeatureFlagApiEndpoint(200, expectedFeatureEnablement);
@ -188,7 +192,7 @@ for (const feature of Object.keys(featureConfig)) {
test(`Feature '${feature}' is disabled if the associated environment variable is false, even if enabled in API`, async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
const expectedFeatureEnablement = initializeFeatures(true);
mockFeatureFlagApiEndpoint(200, expectedFeatureEnablement);
@ -215,7 +219,7 @@ for (const feature of Object.keys(featureConfig)) {
if (featureConfig[feature].minimumVersion !== undefined) {
test(`Getting feature '${feature} should throw if no codeql is provided`, async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
const expectedFeatureEnablement = initializeFeatures(true);
mockFeatureFlagApiEndpoint(200, expectedFeatureEnablement);
@ -233,7 +237,7 @@ for (const feature of Object.keys(featureConfig)) {
if (featureConfig[feature].minimumVersion !== undefined) {
test(`Feature '${feature}' is disabled if the minimum CLI version is below ${featureConfig[feature].minimumVersion}`, async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpTests(tmpDir);
const featureEnablement = setUpFeatureFlagTests(tmpDir);
const expectedFeatureEnablement = initializeFeatures(true);
mockFeatureFlagApiEndpoint(200, expectedFeatureEnablement);
@ -285,6 +289,57 @@ test("At least one feature has a minimum version specified", (t) => {
);
});
test("Feature flags are saved to disk", async (t) => {
await withTmpDir(async (tmpDir) => {
const featureEnablement = setUpFeatureFlagTests(tmpDir);
const expectedFeatureEnablement = initializeFeatures(true);
mockFeatureFlagApiEndpoint(200, expectedFeatureEnablement);
const cachedFeatureFlags = path.join(tmpDir, FEATURE_FLAGS_FILE_NAME);
t.false(
fs.existsSync(cachedFeatureFlags),
"Feature flag cached file should not exist before getting feature flags"
);
t.true(
await featureEnablement.getValue(
Feature.CliConfigFileEnabled,
includeCodeQlIfRequired(Feature.CliConfigFileEnabled)
),
"Feature flag should be enabled initially"
);
t.true(
fs.existsSync(cachedFeatureFlags),
"Feature flag cached file should exist after getting feature flags"
);
const actualFeatureEnablement = JSON.parse(
fs.readFileSync(cachedFeatureFlags, "utf8")
);
t.deepEqual(actualFeatureEnablement, expectedFeatureEnablement);
// now test that we actually use the feature flag cache instead of the server
actualFeatureEnablement[Feature.CliConfigFileEnabled] = false;
fs.writeFileSync(
cachedFeatureFlags,
JSON.stringify(actualFeatureEnablement)
);
// delete the in memory cache so that we are forced to use the cached file
(featureEnablement as any).gitHubFeatureFlags.cachedApiResponse = undefined;
t.false(
await featureEnablement.getValue(
Feature.CliConfigFileEnabled,
includeCodeQlIfRequired(Feature.CliConfigFileEnabled)
),
"Feature flag should be enabled after reading from cached file"
);
});
});
function assertAllFeaturesUndefinedInApi(t, loggedMessages: LoggedMessage[]) {
for (const feature of Object.keys(featureConfig)) {
t.assert(
@ -305,14 +360,14 @@ function initializeFeatures(initialValue: boolean) {
}, {});
}
function setUpTests(
function setUpFeatureFlagTests(
tmpDir: string,
logger = getRunnerLogger(true),
gitHubVersion = { type: GitHubVariant.DOTCOM } as util.GitHubVersion
): FeatureEnablement {
setupActionsVars(tmpDir, tmpDir);
return new Features(gitHubVersion, testRepositoryNwo, logger);
return new Features(gitHubVersion, testRepositoryNwo, tmpDir, logger);
}
function includeCodeQlIfRequired(feature: string) {

View file

@ -1,3 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import { getApiClient } from "./api-client";
import { CodeQL } from "./codeql";
import { Logger } from "./logging";
@ -55,6 +58,8 @@ export const featureConfig: Record<
*/
type GitHubFeatureFlagsApiResponse = Partial<Record<Feature, boolean>>;
export const FEATURE_FLAGS_FILE_NAME = "feature-flags.json";
/**
* Determines the enablement status of a number of features.
* If feature enablement is not able to be determined locally, a request to the
@ -66,11 +71,13 @@ export class Features implements FeatureEnablement {
constructor(
gitHubVersion: util.GitHubVersion,
repositoryNwo: RepositoryNwo,
tempDir: string,
logger: Logger
) {
this.gitHubFeatureFlags = new GitHubFeatureFlags(
gitHubVersion,
repositoryNwo,
path.join(tempDir, FEATURE_FLAGS_FILE_NAME),
logger
);
}
@ -120,7 +127,6 @@ export class Features implements FeatureEnablement {
if (envVar === "true") {
return true;
}
// Ask the GitHub API if the feature is enabled.
return await this.gitHubFeatureFlags.getValue(feature);
}
@ -130,15 +136,16 @@ class GitHubFeatureFlags implements FeatureEnablement {
private cachedApiResponse: GitHubFeatureFlagsApiResponse | undefined;
constructor(
private gitHubVersion: util.GitHubVersion,
private repositoryNwo: RepositoryNwo,
private logger: Logger
private readonly gitHubVersion: util.GitHubVersion,
private readonly repositoryNwo: RepositoryNwo,
private readonly featureFlagsFile: string,
private readonly logger: Logger
) {
/**/
}
async getValue(feature: Feature): Promise<boolean> {
const response = await this.getApiResponse();
const response = await this.getAllFeatures();
if (response === undefined) {
this.logger.debug(
`No feature flags API response for ${feature}, considering it disabled.`
@ -155,11 +162,63 @@ class GitHubFeatureFlags implements FeatureEnablement {
return !!featureEnablement;
}
private async getApiResponse(): Promise<GitHubFeatureFlagsApiResponse> {
const apiResponse =
this.cachedApiResponse || (await this.loadApiResponse());
this.cachedApiResponse = apiResponse;
return apiResponse;
private async getAllFeatures(): Promise<GitHubFeatureFlagsApiResponse> {
// if we have an in memory cache, use that
if (this.cachedApiResponse !== undefined) {
return this.cachedApiResponse;
}
// if a previous step has written a feature flags file to disk, use that
const fileFlags = await this.readLocalFlags();
if (fileFlags !== undefined) {
this.cachedApiResponse = fileFlags;
return fileFlags;
}
// if not, request flags from the server
let remoteFlags = await this.loadApiResponse();
if (remoteFlags === undefined) {
remoteFlags = {};
}
// cache the response in memory
this.cachedApiResponse = remoteFlags;
// and cache them to disk so future workflow steps can use them
await this.writeLocalFlags(remoteFlags);
return remoteFlags;
}
private async readLocalFlags(): Promise<
GitHubFeatureFlagsApiResponse | undefined
> {
try {
if (fs.existsSync(this.featureFlagsFile)) {
this.logger.debug(
`Loading feature flags from ${this.featureFlagsFile}`
);
return JSON.parse(fs.readFileSync(this.featureFlagsFile, "utf8"));
}
} catch (e) {
this.logger.warning(
`Error reading cached feature flags file ${this.featureFlagsFile}: ${e}. Requesting from GitHub instead.`
);
}
return undefined;
}
private async writeLocalFlags(
flags: GitHubFeatureFlagsApiResponse
): Promise<void> {
try {
this.logger.debug(`Writing feature flags to ${this.featureFlagsFile}`);
fs.writeFileSync(this.featureFlagsFile, JSON.stringify(flags));
} catch (e) {
this.logger.warning(
`Error writing cached feature flags file ${this.featureFlagsFile}: ${e}.`
);
}
}
private async loadApiResponse() {

View file

@ -158,7 +158,12 @@ async function run() {
getRequiredEnvParam("GITHUB_REPOSITORY")
);
const features = new Features(gitHubVersion, repositoryNwo, logger);
const features = new Features(
gitHubVersion,
repositoryNwo,
getTemporaryDirectory(),
logger
);
try {
const workflowErrors = await validateWorkflow();