Skip to content

Commit 4967462

Browse files
authored
ability to test private Features in CI (auto-skipping locally if secret is unset)
1 parent f034f44 commit 4967462

3 files changed

Lines changed: 87 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
3+
"features": {
4+
"privatedevcontainercli.azurecr.io/features/rabbit:1": {}
5+
}
6+
}

src/test/container-features/registryCompatibilityOCI.test.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,26 @@ import { devContainerDown, devContainerUp, shellExec } from '../testUtils';
99

1010
const pkg = require('../../../package.json');
1111

12+
enum AuthStrategy {
13+
Anonymous,
14+
DockerConfigAuthFile,
15+
// PlatformCredentialHelper,
16+
// RefreshToken,
17+
}
18+
1219
interface TestPlan {
1320
name: string;
1421
configName: string;
1522
testFeatureId: string;
1623
testCommand?: string;
1724
testCommandResult?: RegExp;
25+
// Optionally tell the test to set up with a specfic auth strategy.
26+
// If not set, the test will run with anonymous.
27+
// NOTE:
28+
// These will be skipped unless the environment has the relevant 'authCredentialEnvSecret' set in the environment.
29+
useAuthStrategy?: AuthStrategy;
30+
// Format: registry|username|passwordOrToken
31+
authCredentialEnvSecret?: string;
1832
}
1933

2034
const defaultTestPlan = {
@@ -32,11 +46,54 @@ const registryCompatibilityTestPlan: TestPlan[] = [
3246
name: 'Anonymous access of GHCR',
3347
configName: 'github-anonymous',
3448
testFeatureId: 'ghcr.io/devcontainers/feature-starter/color',
49+
},
50+
// https://learn.microsoft.com/en-us/azure/container-registry/container-registry-repository-scoped-permissions
51+
{
52+
name: 'Authenticated access of Azure Container Registry with registry scoped token',
53+
configName: 'azure-registry-scoped',
54+
testFeatureId: 'privatedevcontainercli.azurecr.io/features/rabbit',
55+
useAuthStrategy: AuthStrategy.DockerConfigAuthFile,
56+
authCredentialEnvSecret: 'FEATURES_TEST__AZURE_REGISTRY_SCOPED_CREDENTIAL',
57+
testCommand: 'rabbit',
58+
testCommandResult: /rabbit-is-the-best-animal/,
3559
}
3660
];
3761

62+
function constructAuthFromStrategy(tmpFolder: string, authStrategy: AuthStrategy, authCredentialEnvSecret?: string): string | undefined {
63+
const generateAuthFolder = () => {
64+
const randomChars = Math.random().toString(36).substring(2, 6);
65+
const tmpAuthFolder = path.join(tmpFolder, randomChars, 'auth');
66+
shellExec(`mkdir -p ${tmpAuthFolder}`);
67+
return tmpAuthFolder;
68+
};
69+
70+
switch (authStrategy) {
71+
case AuthStrategy.Anonymous:
72+
return;
73+
case AuthStrategy.DockerConfigAuthFile:
74+
if (!authCredentialEnvSecret) {
75+
return;
76+
}
77+
const split = process.env[authCredentialEnvSecret]?.split('|');
78+
if (!split || split.length !== 3) {
79+
return;
80+
}
81+
const tmpAuthFolder = generateAuthFolder();
82+
83+
const registry = split?.[0];
84+
const username = split?.[1];
85+
const passwordOrToken = split?.[2];
86+
const encodedAuth = Buffer.from(`${username}:${passwordOrToken}`).toString('base64');
87+
88+
shellExec(`echo '{"auths":{"${registry}":{"auth": "${encodedAuth}"}}}' > ${tmpAuthFolder}/config.json`);
89+
return tmpAuthFolder;
90+
default:
91+
return;
92+
}
93+
}
94+
3895
describe('Registry Compatibility', function () {
39-
this.timeout('1200s');
96+
this.timeout('120s');
4097
const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp'));
4198
const cli = `npx --prefix ${tmp} devcontainer`;
4299

@@ -46,14 +103,19 @@ describe('Registry Compatibility', function () {
46103
await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`);
47104
});
48105

49-
registryCompatibilityTestPlan.forEach(({ name, configName, testFeatureId, testCommand, testCommandResult }) => {
106+
registryCompatibilityTestPlan.forEach(({ name, configName, testFeatureId, testCommand, testCommandResult, useAuthStrategy, authCredentialEnvSecret }) => {
50107
this.timeout('120s');
51-
describe(name, () => {
52-
describe('devcontainer up', () => {
108+
describe(name, async function () {
109+
((authCredentialEnvSecret && !process.env[authCredentialEnvSecret]) ? describe.skip : describe)('devcontainer up', async function () {
110+
111+
const authFolder = constructAuthFromStrategy(tmp, useAuthStrategy ?? AuthStrategy.Anonymous, authCredentialEnvSecret);
112+
53113
let containerId: string | null = null;
54114
const testFolder = `${__dirname}/configs/registry-compatibility/${configName}`;
55115

56-
before(async () => containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId);
116+
before(async () => containerId = (await devContainerUp(cli, testFolder, {
117+
'logLevel': 'trace', prefix: authFolder ? `DOCKER_CONFIG=${authFolder}` : undefined
118+
})).containerId);
57119
after(async () => await devContainerDown({ containerId }));
58120

59121
const cmd = testCommand ?? defaultTestPlan.testCommand;
@@ -66,12 +128,20 @@ describe('Registry Compatibility', function () {
66128
});
67129
});
68130

69-
describe(`devcontainer features info manifest`, async () => {
70-
it('fetches manifest', async () => {
131+
((authCredentialEnvSecret && !process.env[authCredentialEnvSecret]) ? describe.skip : describe)(`devcontainer features info manifest`, async function () {
132+
133+
const authFolder = constructAuthFromStrategy(tmp, useAuthStrategy ?? AuthStrategy.Anonymous, authCredentialEnvSecret);
134+
135+
it('fetches manifest', async function () {
71136
let infoManifestResult: { stdout: string; stderr: string } | null = null;
72137
let success = false;
73138
try {
74-
infoManifestResult = await shellExec(`${cli} features info manifest ${testFeatureId} --log-level trace`);
139+
if (authFolder) {
140+
infoManifestResult = await shellExec(`DOCKER_CONFIG=${authFolder} ${cli} features info manifest ${testFeatureId} --log-level trace`);
141+
142+
} else {
143+
infoManifestResult = await shellExec(`${cli} features info manifest ${testFeatureId} --log-level trace`);
144+
}
75145
success = true;
76146
} catch (error) {
77147
assert.fail('features info tags sub-command should not throw');

src/test/testUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,14 @@ export async function shellPtyExec(command: string, options: { stdin?: string }
8181
}).then(res => ({ code: 0, ...res }), error => error);
8282
}
8383

84-
export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; extraArgs?: string }): Promise<UpResult> {
84+
export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; extraArgs?: string; prefix?: string }): Promise<UpResult> {
8585
const buildkitOption = (options?.useBuildKit ?? false) ? '' : ' --buildkit=never';
8686
const userDataFolderOption = (options?.userDataFolder ?? false) ? ` --user-data-folder=${options?.userDataFolder}` : '';
8787
const logLevelOption = (options?.logLevel ?? false) ? ` --log-level ${options?.logLevel}` : '';
8888
const extraArgs = (options?.extraArgs ?? false) ? ` ${options?.extraArgs}` : '';
89+
const prefix = (options?.prefix ?? false) ? `${options?.prefix} ` : '';
8990
const shellExecOptions = { cwd: options?.cwd };
90-
const res = await shellExec(`${cli} up --workspace-folder ${workspaceFolder}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions);
91+
const res = await shellExec(`${prefix}${cli} up --workspace-folder ${workspaceFolder}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions);
9192
const response = JSON.parse(res.stdout);
9293
assert.equal(response.outcome, 'success');
9394
const { outcome, containerId, composeProjectName } = response as UpResult;

0 commit comments

Comments
 (0)