Skip to content

Feature: Adds support for renaming with legacyIds property #335

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Dec 28, 2022
2 changes: 2 additions & 0 deletions src/spec-configuration/containerFeaturesConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface Feature {
included: boolean; // set programmatically
customizations?: VSCodeCustomizations;
installsAfter?: string[];
legacyIds?: string[];
currentId?: string; // set programmatically
}

export type FeatureOption = {
Expand Down
32 changes: 28 additions & 4 deletions src/spec-configuration/containerFeaturesOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,36 @@ export function computeInstallationOrder(features: FeatureSet[]) {
feature,
before: new Set(),
after: new Set(),
})).reduce((map, feature) => map.set(feature.feature.sourceInformation.userFeatureId, feature), new Map<string, FeatureNode>());
})).reduce((map, feature) => map.set(feature.feature.sourceInformation.userFeatureId.split(':')[0], feature), new Map<string, FeatureNode>());

let nodes = [...nodesById.values()];

// Currently legacyIds only contain an id, hence append `registry/namespace` to it.
nodes = nodes.map(node => {
if (node.feature.sourceInformation.type === 'oci' && node.feature.features[0].legacyIds && node.feature.features[0].legacyIds.length > 0) {
const featureRef = node.feature.sourceInformation.featureRef;
if (featureRef) {
node.feature.features[0].legacyIds = node.feature.features[0].legacyIds.map(legacyId => `${featureRef.registry}/${featureRef.namespace}/` + legacyId);
node.feature.features[0].currentId = `${featureRef.registry}/${featureRef.namespace}/${node.feature.features[0].currentId}`;
}
}
return node;
});

const nodes = [...nodesById.values()];
for (const later of nodes) {
for (const firstId of later.feature.features[0].installsAfter || []) {
const first = nodesById.get(firstId);
let first = nodesById.get(firstId);

// Check for legacyIds (back compat)
if (!first) {
first = nodes.find(node => node.feature.features[0].legacyIds?.includes(firstId));
}

// Check for currentId (forward compat)
if (!first) {
first = nodes.find(node => node.feature.features[0].currentId === firstId);
}

// soft dependencies
if (first) {
later.after.add(first);
Expand Down Expand Up @@ -100,7 +124,7 @@ export function computeInstallationOrder(features: FeatureSet[]) {

const missing = new Set(nodesById.keys());
for (const feature of orderedFeatures) {
missing.delete(feature.sourceInformation.userFeatureId);
missing.delete(feature.sourceInformation.userFeatureId.split(':')[0]);
}

if (missing.size !== 0) {
Expand Down
18 changes: 18 additions & 0 deletions src/spec-node/collectionCommonUtils/packageCommandImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Log, LogLevel } from '../../spec-utils/log';
import path from 'path';
import { DevContainerConfig, isDockerFileConfig } from '../../spec-configuration/configuration';
import { Template } from '../../spec-configuration/containerTemplatesConfiguration';
import { Feature } from '../../spec-configuration/containerFeaturesConfiguration';

export interface SourceInformation {
source: string;
Expand Down Expand Up @@ -86,6 +87,8 @@ export async function packageSingleFeatureOrTemplate(args: PackageCommandInput,
if (!(await addsAdditionalTemplateProps(tmpSrcDir, jsonPath, output))) {
return;
}
} else if (collectionType === 'feature') {
await addsAdditionalFeatureProps(jsonPath, output);
}

const metadata = jsonc.parse(await readLocalFile(jsonPath, 'utf-8'));
Expand Down Expand Up @@ -139,6 +142,19 @@ async function addsAdditionalTemplateProps(srcFolder: string, devcontainerTempla
return true;
}

// Programmatically adds 'currentId' if 'legacyIds' exist.
async function addsAdditionalFeatureProps(devcontainerFeatureJsonPath: string, output: Log): Promise<void> {
const devcontainerFeatureJsonString: Buffer = await readLocalFile(devcontainerFeatureJsonPath);
let featureData: Feature = jsonc.parse(devcontainerFeatureJsonString.toString());

if (featureData.legacyIds && featureData.legacyIds.length > 0) {
featureData.currentId = featureData.id;
output.write(`Programmatically adding currentId:${featureData.currentId}...`, LogLevel.Trace);

await writeLocalFile(devcontainerFeatureJsonPath, JSON.stringify(featureData, null, 4));
}
}

async function getDevcontainerFilePath(srcFolder: string): Promise<string | undefined> {
const devcontainerFile = path.join(srcFolder, '.devcontainer.json');
const devcontainerFileWithinDevcontainerFolder = path.join(srcFolder, '.devcontainer/devcontainer.json');
Expand Down Expand Up @@ -185,6 +201,8 @@ export async function packageCollection(args: PackageCommandInput, collectionTyp
output.write(`Feature '${c}' is missing an install.sh`, LogLevel.Error);
return;
}

await addsAdditionalFeatureProps(jsonPath, output);
} else if (collectionType === 'template') {
if (!(await addsAdditionalTemplateProps(tmpSrcDir, jsonPath, output))) {
return;
Expand Down
6 changes: 3 additions & 3 deletions src/spec-node/collectionCommonUtils/publishCommandImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import * as semver from 'semver';
import { Log, LogLevel } from '../../spec-utils/log';
import { CommonParams, getPublishedVersions, OCICollectionRef, OCIRef } from '../../spec-configuration/containerCollectionsOCI';
import { getArchiveName, OCICollectionFileName } from './packageCommandImpl';
import { OCICollectionFileName } from './packageCommandImpl';
import { pushCollectionMetadata, pushOCIFeatureOrTemplate } from '../../spec-configuration/containerCollectionsOCIPush';

let semanticVersions: string[] = [];
Expand Down Expand Up @@ -39,7 +39,7 @@ export function getSemanticVersions(version: string, publishedVersions: string[]
return semanticVersions;
}

export async function doPublishCommand(params: CommonParams, version: string, ociRef: OCIRef, outputDir: string, collectionType: string) {
export async function doPublishCommand(params: CommonParams, version: string, ociRef: OCIRef, outputDir: string, collectionType: string, archiveName: string) {
const { output } = params;

output.write(`Fetching published versions...`, LogLevel.Info);
Expand All @@ -53,7 +53,7 @@ export async function doPublishCommand(params: CommonParams, version: string, oc

if (!!semanticVersions) {
output.write(`Publishing versions: ${semanticVersions.toString()}...`, LogLevel.Info);
const pathToTgz = path.join(outputDir, getArchiveName(ociRef.id, collectionType));
const pathToTgz = path.join(outputDir, archiveName);
const digest = await pushOCIFeatureOrTemplate(params, ociRef, pathToTgz, semanticVersions, collectionType);
if (!digest) {
output.write(`(!) ERR: Failed to publish ${collectionType}: '${ociRef.resource}'`, LogLevel.Error);
Expand Down
41 changes: 38 additions & 3 deletions src/spec-node/featuresCLI/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { doFeaturesPackageCommand } from './packageCommandImpl';
import { getCLIHost } from '../../spec-common/cliHost';
import { loadNativeModule } from '../../spec-common/commonUtils';
import { PackageCommandInput } from '../collectionCommonUtils/package';
import { OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl';
import { getArchiveName, OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl';
import { publishOptions } from '../collectionCommonUtils/publish';
import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI';
import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl';
Expand Down Expand Up @@ -86,17 +86,52 @@ async function featuresPublish({
process.exit(1);
}

const publishResult = await doPublishCommand(params, f.version, featureRef, outputDir, collectionType);
const archiveName = getArchiveName(f.id, collectionType);
const publishResult = await doPublishCommand(params, f.version, featureRef, outputDir, collectionType, archiveName);
if (!publishResult) {
output.write(`(!) ERR: Failed to publish '${resource}'`, LogLevel.Error);
process.exit(1);
}

const thisResult = (publishResult?.digest && publishResult?.publishedVersions.length > 0) ? {
const isPublished = (publishResult?.digest && publishResult?.publishedVersions.length > 0);
let thisResult = isPublished ? {
...publishResult,
version: f.version,
} : {};

if (isPublished && f.legacyIds) {
output.write(`Processing legacyIds for '${f.id}'...`, LogLevel.Info);

let publishedLegacyIds: string[] = [];
for await (const legacyId of f.legacyIds) {
output.write(`Processing legacyId: '${legacyId}'...`, LogLevel.Info);
let legacyResource = `${registry}/${namespace}/${legacyId}`;
const legacyFeatureRef = getRef(output, legacyResource);

if (!legacyFeatureRef) {
output.write(`(!) Could not parse provided Feature identifier: '${legacyResource}'`, LogLevel.Error);
process.exit(1);
}

const publishResult = await doPublishCommand(params, f.version, legacyFeatureRef, outputDir, collectionType, archiveName);
if (!publishResult) {
output.write(`(!) ERR: Failed to publish '${legacyResource}'`, LogLevel.Error);
process.exit(1);
}

if (publishResult?.digest && publishResult?.publishedVersions.length > 0) {
publishedLegacyIds.push(legacyId);
}
}

if (publishedLegacyIds.length > 0) {
thisResult = {
...thisResult,
publishedLegacyIds,
};
}
}

result = {
...result,
[f.id]: thisResult,
Expand Down
5 changes: 3 additions & 2 deletions src/spec-node/templatesCLI/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { publishOptions } from '../collectionCommonUtils/publish';
import { getCLIHost } from '../../spec-common/cliHost';
import { loadNativeModule } from '../../spec-common/commonUtils';
import { PackageCommandInput } from '../collectionCommonUtils/package';
import { OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl';
import { getArchiveName, OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl';
import { packageTemplates } from './packageImpl';
import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI';
import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl';
Expand Down Expand Up @@ -87,7 +87,8 @@ async function templatesPublish({
process.exit(1);
}

const publishResult = await doPublishCommand(params, t.version, templateRef, outputDir, collectionType);
const archiveName = getArchiveName(t.id, collectionType);
const publishResult = await doPublishCommand(params, t.version, templateRef, outputDir, collectionType, archiveName);
if (!publishResult) {
output.write(`(!) ERR: Failed to publish '${resource}'`, LogLevel.Error);
process.exit(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:jammy",
"features": {
"ghcr.io/codspace/features/fruit:1": {},
"ghcr.io/codspace/features/hello:1.0.7": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/codspace/features/flower:1": {},
"ghcr.io/codspace/features/color:1": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/codspace/features/hello:1": {},
"ghcr.io/codspace/features/new-color:1": {}
}
}
113 changes: 113 additions & 0 deletions src/test/container-features/containerFeaturesOCIPush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface PublishResult {
publishedVersions: string[];
digest: string;
version: string;
publishedLegacyIds?: string[];
}

describe('Test OCI Push against reference registry', async function () {
Expand Down Expand Up @@ -93,6 +94,7 @@ registry`;
'latest',
]);
assert.strictEqual(color.version, '1.0.0');
assert.isUndefined(color.publishedLegacyIds);

const hello = result['hello'];
assert.isDefined(hello);
Expand All @@ -104,6 +106,7 @@ registry`;
'latest',
]);
assert.strictEqual(hello.version, '1.0.0');
assert.isUndefined(hello.publishedLegacyIds);
}

// --- See that the Features can be queried from the Dev Container CLI.
Expand Down Expand Up @@ -186,6 +189,116 @@ registry`;
assert.strictEqual(hello.version, '1.0.1');
}
});

it('Publish Features to registry with legacyIds', async () => {
const collectionFolder = `${__dirname}/example-v2-features-sets/renaming-feature`;
let success = false;

let publishResult: ExecResult | undefined = undefined;
let infoTagsResult: ExecResult | undefined = undefined;
let infoManifestResult: ExecResult | undefined = undefined;

try {
publishResult = await shellExec(`${cli} features publish --log-level trace -r localhost:5000 -n octocat/features2 ${collectionFolder}/src`, { env: { ...process.env, 'DEVCONTAINERS_OCI_AUTH': 'localhost:5000|myuser|mypass' } });
success = true;

} catch (error) {
assert.fail('features publish sub-command should not throw');
}

assert.isTrue(success);
assert.isDefined(publishResult);

{
const result: { [featureId: string]: PublishResult } = JSON.parse(publishResult.stdout);
assert.equal(Object.keys(result).length, 2);

const newColor = result['new-color'];
assert.isDefined(newColor);
assert.isDefined(newColor.digest);
assert.deepEqual(newColor.publishedVersions, [
'1',
'1.0',
'1.0.1',
'latest',
]);
assert.strictEqual(newColor.version, '1.0.1');
assert.deepEqual(newColor.publishedLegacyIds, [
'color',
'old-color'
]);

const hello = result['hello'];
assert.isDefined(hello);
assert.isDefined(hello.digest);
assert.deepEqual(hello.publishedVersions, [
'1',
'1.0',
'1.0.0',
'latest',
]);
assert.strictEqual(hello.version, '1.0.0');
assert.isUndefined(hello.publishedLegacyIds);
}

// --- See that the manifest of legacyIds and ID are equal
success = false; // Reset success flag.
try {
infoManifestResult = await shellExec(`${cli} features info manifest localhost:5000/octocat/features2/new-color --log-level trace`, { env: { ...process.env, 'DEVCONTAINERS_OCI_AUTH': 'localhost:5000|myuser|mypass' } });
success = true;

} catch (error) {
assert.fail('features info tags sub-command should not throw');
}

assert.isTrue(success);
assert.isDefined(infoManifestResult);
const manifest = infoManifestResult.stdout;

success = false; // Reset success flag.
try {
infoManifestResult = await shellExec(`${cli} features info manifest localhost:5000/octocat/features2/color --log-level trace`, { env: { ...process.env, 'DEVCONTAINERS_OCI_AUTH': 'localhost:5000|myuser|mypass' } });
success = true;

} catch (error) {
assert.fail('features info tags sub-command should not throw');
}

assert.isTrue(success);
assert.isDefined(infoManifestResult);
const legacyManifest = infoManifestResult.stdout;
assert.deepEqual(manifest, legacyManifest);

success = false; // Reset success flag.
try {
infoManifestResult = await shellExec(`${cli} features info manifest localhost:5000/octocat/features2/old-color --log-level trace`, { env: { ...process.env, 'DEVCONTAINERS_OCI_AUTH': 'localhost:5000|myuser|mypass' } });
success = true;

} catch (error) {
assert.fail('features info tags sub-command should not throw');
}

assert.isTrue(success);
assert.isDefined(infoManifestResult);
const legacyManifest2 = infoManifestResult.stdout;
assert.deepEqual(manifest, legacyManifest2);

// --- Simple Feature
success = false; // Reset success flag.
try {
infoTagsResult = await shellExec(`${cli} features info tags localhost:5000/octocat/features2/hello --output-format json --log-level trace`, { env: { ...process.env, 'DEVCONTAINERS_OCI_AUTH': 'localhost:5000|myuser|mypass' } });
success = true;

} catch (error) {
assert.fail('features info tags sub-command should not throw');
}

assert.isTrue(success);
assert.isDefined(infoTagsResult);
const tags = JSON.parse(infoTagsResult.stdout);
const publishedVersions: string[] = tags['publishedVersions'];
assert.equal(publishedVersions.length, 4);
});
});

// NOTE:
Expand Down
Loading