From 02371b986429f583d7cfdf47e5d040fd4a235016 Mon Sep 17 00:00:00 2001 From: gdlol <.> Date: Wed, 30 Sep 2026 12:31:39 +0000 Subject: [PATCH] dotnet completion --- .config/cspell/cspell.json | 1 + .devcontainer/features/src/features/NOTES.md | 22 ++ .devcontainer/features/src/features/README.md | 22 ++ features/Features/FeatureException.cs | 3 + features/Features/Features.csproj | 6 + features/Features/OptionsFile.cs | 23 ++ features/Features/OptionsSchema.cs | 27 +++ features/dotnet/Install/Completions.cs | 83 +++++++ features/dotnet/Install/Install.csproj | 3 + features/dotnet/Install/Permissions.cs | 7 + features/dotnet/Install/Program.cs | 10 +- features/dotnet/Install/Registration.cs | 8 +- features/dotnet/NOTES.md | 34 +++ features/dotnet/Options/Options.cs | 15 ++ features/dotnet/Options/Options.csproj | 11 + features/dotnet/Options/Program.cs | 6 + features/dotnet/README.md | 57 +++++ features/dotnet/config.json | 2 +- features/dotnet/feature.json | 13 ++ scripts/git.ts | 15 ++ scripts/lint.ts | 2 + scripts/tasks/build.ts | 14 +- scripts/tasks/features/dotnet/registry.ts | 2 +- scripts/tasks/features/dotnet/renovate.ts | 2 +- scripts/tasks/features/dotnet/tagPublished.ts | 2 +- scripts/tasks/features/dotnet/tags.ts | 2 +- scripts/tasks/imageFeatures.ts | 206 ++++++++++++++++++ scripts/tasks/publish.ts | 2 +- scripts/verify/dotnet/lifecycle.ts | 35 ++- scripts/verify/dotnet/options-malformed.json | 1 + scripts/verify/dotnet/options-off.json | 1 + scripts/verify/dotnet/scenarios.ts | 122 ++++++++++- .../test-image-options-malformed.Dockerfile | 8 + .../dotnet/test-image-options-off.Dockerfile | 8 + scripts/verify/dotnet/verify.ts | 30 ++- 35 files changed, 773 insertions(+), 32 deletions(-) create mode 100644 features/Features/FeatureException.cs create mode 100644 features/Features/Features.csproj create mode 100644 features/Features/OptionsFile.cs create mode 100644 features/Features/OptionsSchema.cs create mode 100644 features/dotnet/Install/Completions.cs create mode 100644 features/dotnet/Install/Permissions.cs create mode 100644 features/dotnet/NOTES.md create mode 100644 features/dotnet/Options/Options.cs create mode 100644 features/dotnet/Options/Options.csproj create mode 100644 features/dotnet/Options/Program.cs create mode 100644 features/dotnet/README.md create mode 100644 features/dotnet/feature.json create mode 100644 scripts/git.ts create mode 100644 scripts/tasks/imageFeatures.ts create mode 100644 scripts/verify/dotnet/options-malformed.json create mode 100644 scripts/verify/dotnet/options-off.json create mode 100644 scripts/verify/dotnet/test-image-options-malformed.Dockerfile create mode 100644 scripts/verify/dotnet/test-image-options-off.Dockerfile diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json index abf8471..1ef570c 100644 --- a/.config/cspell/cspell.json +++ b/.config/cspell/cspell.json @@ -18,6 +18,7 @@ "globalconfig", "hasha", "mktemp", + "libicu", "msbuild", "NOLOGO", "packagejson", diff --git a/.devcontainer/features/src/features/NOTES.md b/.devcontainer/features/src/features/NOTES.md index 67c479a..a18bc88 100644 --- a/.devcontainer/features/src/features/NOTES.md +++ b/.devcontainer/features/src/features/NOTES.md @@ -24,6 +24,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co } ``` +A feature stages artifacts at `/features//`, and it's consumed by copying `--from= /features//` into +`/`: + +```dockerfile +FROM some-feature + +FROM base-image + +COPY --from=some-feature /features/some-feature/ /opt/devcontainer-config/features/some-feature/ +``` + ### Feature convention | File | Kind | Required | Description | @@ -37,6 +48,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co | `postAttach` | executable | false | Corresponds to `postAttachCommand` | | `entrypoint` | executable | false | Corresponds to `entrypoint` | +### Options + +A feature may declare options in `feature.json`. Options can be specified by staging `options.json` to the feature +payload path: + +```dockerfile +COPY feature/some-feature/options.json /opt/devcontainer-config/features/some-feature/options.json +``` + +The feature's install reads that file itself; an absent file applies the declared defaults. + ### Environment Every executable receives these environment variables: diff --git a/.devcontainer/features/src/features/README.md b/.devcontainer/features/src/features/README.md index e24aa8b..5d6150d 100644 --- a/.devcontainer/features/src/features/README.md +++ b/.devcontainer/features/src/features/README.md @@ -42,6 +42,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co } ``` +A feature stages artifacts at `/features//`, and it's consumed by copying `--from= /features//` into +`/`: + +```dockerfile +FROM some-feature + +FROM base-image + +COPY --from=some-feature /features/some-feature/ /opt/devcontainer-config/features/some-feature/ +``` + ### Feature convention | File | Kind | Required | Description | @@ -55,6 +66,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co | `postAttach` | executable | false | Corresponds to `postAttachCommand` | | `entrypoint` | executable | false | Corresponds to `entrypoint` | +### Options + +A feature may declare options in `feature.json`. Options can be specified by staging `options.json` to the feature +payload path: + +```dockerfile +COPY feature/some-feature/options.json /opt/devcontainer-config/features/some-feature/options.json +``` + +The feature's install reads that file itself; an absent file applies the declared defaults. + ### Environment Every executable receives these environment variables: diff --git a/features/Features/FeatureException.cs b/features/Features/FeatureException.cs new file mode 100644 index 0000000..725176f --- /dev/null +++ b/features/Features/FeatureException.cs @@ -0,0 +1,3 @@ +namespace DevcontainerConfig.Features; + +public sealed class FeatureException(string message) : Exception(message); diff --git a/features/Features/Features.csproj b/features/Features/Features.csproj new file mode 100644 index 0000000..23a8302 --- /dev/null +++ b/features/Features/Features.csproj @@ -0,0 +1,6 @@ + + + $(DefaultTargetFramework) + DevcontainerConfig.Features + + diff --git a/features/Features/OptionsFile.cs b/features/Features/OptionsFile.cs new file mode 100644 index 0000000..2ba36cd --- /dev/null +++ b/features/Features/OptionsFile.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace DevcontainerConfig.Features; + +public static class OptionsFile +{ + private const string FileName = "options.json"; + + public static T Read(string stagedDir, JsonTypeInfo typeInfo) + { + string path = Path.Join(stagedDir, FileName); + string json = File.Exists(path) ? File.ReadAllText(path) : "{}"; + try + { + return JsonSerializer.Deserialize(json, typeInfo) ?? throw new FeatureException($"{path} is null."); + } + catch (JsonException e) + { + throw new FeatureException($"{path}: {e.Message}"); + } + } +} diff --git a/features/Features/OptionsSchema.cs b/features/Features/OptionsSchema.cs new file mode 100644 index 0000000..a3abd57 --- /dev/null +++ b/features/Features/OptionsSchema.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization.Metadata; + +namespace DevcontainerConfig.Features; + +public static class OptionsSchema +{ + private static readonly JsonSchemaExporterOptions ExporterOptions = new() + { + TransformSchemaNode = (context, node) => + { + if ( + context.PropertyInfo?.AttributeProvider?.GetCustomAttributes(typeof(DescriptionAttribute), false) + is [DescriptionAttribute description] + ) + { + ((JsonObject)node)["description"] = description.Description; + } + return node; + }, + }; + + public static JsonNode Of(JsonTypeInfo typeInfo) => + JsonSchemaExporter.GetJsonSchemaAsNode(typeInfo, ExporterOptions); +} diff --git a/features/dotnet/Install/Completions.cs b/features/dotnet/Install/Completions.cs new file mode 100644 index 0000000..318332d --- /dev/null +++ b/features/dotnet/Install/Completions.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; + +namespace DevcontainerConfig.Dotnet.Install; + +internal static class Completions +{ + private const int MinimumSdkMajor = 10; + + private static readonly (string Shell, string Path)[] Targets = + [ + ("bash", "/usr/share/bash-completion/completions/dotnet"), + ("zsh", "/usr/share/zsh/site-functions/_dotnet"), + ("fish", "/usr/share/fish/vendor_completions.d/dotnet.fish"), + ]; + + internal static async Task GenerateAsync(string root, IEnumerable sources) + { + if (!sources.Any(source => source.Component == "sdk" && source.Version.Major >= MinimumSdkMajor)) + { + return; + } + + string temp = Directory.CreateTempSubdirectory("dotnet-completions-").FullName; + try + { + foreach ((string shell, string path) in Targets) + { + string script = await RunAsync(root, temp, shell); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, script); + File.SetUnixFileMode(path, Permissions.Mode0644); + Console.WriteLine($"install: wrote {path}"); + } + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + + private static async Task RunAsync(string root, string temp, string shell) + { + string home = Path.Join(temp, "home"); + Directory.CreateDirectory(home); + ProcessStartInfo startInfo = new(Path.Join(root, "dotnet")) + { + WorkingDirectory = temp, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("completions"); + startInfo.ArgumentList.Add("script"); + startInfo.ArgumentList.Add(shell); + startInfo.Environment["HOME"] = home; + startInfo.Environment["TMPDIR"] = temp; + startInfo.Environment["DOTNET_ROOT"] = root; + startInfo.Environment["DOTNET_CLI_HOME"] = Path.Join(temp, "cli"); + startInfo.Environment["XDG_CONFIG_HOME"] = Path.Join(temp, "config"); + startInfo.Environment["XDG_DATA_HOME"] = Path.Join(temp, "data"); + startInfo.Environment["XDG_CACHE_HOME"] = Path.Join(temp, "cache"); + startInfo.Environment["XDG_STATE_HOME"] = Path.Join(temp, "state"); + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "false"; + startInfo.Environment["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "1"; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + + using Process process = + Process.Start(startInfo) ?? throw new InstallException($"cannot run {startInfo.FileName}."); + Task output = process.StandardOutput.ReadToEndAsync(); + Task error = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + string script = await output; + string detail = (await error).Trim(); + if (process.ExitCode != 0) + { + throw new InstallException( + $"dotnet completions script {shell} exited with code {process.ExitCode}" + + (detail.Length == 0 ? "." : $": {detail}") + ); + } + return script; + } +} diff --git a/features/dotnet/Install/Install.csproj b/features/dotnet/Install/Install.csproj index a988d9b..eb8f886 100644 --- a/features/dotnet/Install/Install.csproj +++ b/features/dotnet/Install/Install.csproj @@ -4,10 +4,13 @@ install Exe DevcontainerConfig.Dotnet.Install + false + + diff --git a/features/dotnet/Install/Permissions.cs b/features/dotnet/Install/Permissions.cs new file mode 100644 index 0000000..0925d7b --- /dev/null +++ b/features/dotnet/Install/Permissions.cs @@ -0,0 +1,7 @@ +namespace DevcontainerConfig.Dotnet.Install; + +internal static class Permissions +{ + internal const UnixFileMode Mode0644 = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; +} diff --git a/features/dotnet/Install/Program.cs b/features/dotnet/Install/Program.cs index 896b85b..04329bc 100644 --- a/features/dotnet/Install/Program.cs +++ b/features/dotnet/Install/Program.cs @@ -1,5 +1,7 @@ using System.Runtime.Versioning; +using DevcontainerConfig.Dotnet; using DevcontainerConfig.Dotnet.Install; +using DevcontainerConfig.Features; [assembly: SupportedOSPlatform("linux")] @@ -10,11 +12,17 @@ try { - foreach (Source source in Scan.Sources(stagedDir)) + Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions); + List sources = Scan.Sources(stagedDir); + foreach (Source source in sources) { Merge.Into(source, root); } Registration.Register(root); + if (options.TabCompletions) + { + await Completions.GenerateAsync(root, sources); + } return 0; } catch (Exception e) diff --git a/features/dotnet/Install/Registration.cs b/features/dotnet/Install/Registration.cs index add032b..e7c2e15 100644 --- a/features/dotnet/Install/Registration.cs +++ b/features/dotnet/Install/Registration.cs @@ -6,9 +6,6 @@ internal static class Registration private const string LinkPath = "/usr/local/bin/dotnet"; private const string ProfileDir = "/etc/profile.d"; - private const UnixFileMode Mode0644 = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; - internal static void Register(string root) { WriteInstallLocation(root); @@ -21,7 +18,7 @@ private static void WriteInstallLocation(string root) Directory.CreateDirectory(LocationDir); string path = Path.Join(LocationDir, "install_location"); File.WriteAllText(path, $"{root}\n"); - File.SetUnixFileMode(path, Mode0644); + File.SetUnixFileMode(path, Permissions.Mode0644); Console.WriteLine($"install: registered {root} in {path}"); } @@ -59,10 +56,11 @@ private static void WriteProfileD(string root) export DOTNET_CLI_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/dotnet/cli" export NUGET_PACKAGES="${XDG_DATA_HOME:-$HOME/.local/share}/NuGet/global-packages" export DOTNET_CLI_TELEMETRY_OPTOUT=true + export DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK=true export PATH="$PATH:$DOTNET_CLI_HOME/.dotnet/tools" """ ); - File.SetUnixFileMode(path, Mode0644); + File.SetUnixFileMode(path, Permissions.Mode0644); Console.WriteLine($"install: wrote {path}"); } } diff --git a/features/dotnet/NOTES.md b/features/dotnet/NOTES.md new file mode 100644 index 0000000..1346c6c --- /dev/null +++ b/features/dotnet/NOTES.md @@ -0,0 +1,34 @@ +## Examples + +Installing latest stable .NET SDK: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +Installing additional versions: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet +FROM ghcr.io/devcontainer-config/features/dotnet/sdk:lts AS dotnet-lts + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +Installing additional runtimes: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet +FROM ghcr.io/devcontainer-config/features/dotnet/runtime:lts AS dotnet-runtime-lts +FROM ghcr.io/devcontainer-config/features/dotnet/aspnet:lts AS dotnet-aspnet-lts + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-runtime-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-aspnet-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` diff --git a/features/dotnet/Options/Options.cs b/features/dotnet/Options/Options.cs new file mode 100644 index 0000000..881147a --- /dev/null +++ b/features/dotnet/Options/Options.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace DevcontainerConfig.Dotnet; + +public sealed class Options(bool tabCompletions = true) +{ + [JsonPropertyName("tabCompletions")] + [Description("Generate shell tab completions for the dotnet CLI. Requires SDK 10 or newer.")] + public bool TabCompletions { get; } = tabCompletions; +} + +[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] +[JsonSerializable(typeof(Options), TypeInfoPropertyName = "FeatureOptions")] +public sealed partial class OptionsContext : JsonSerializerContext; diff --git a/features/dotnet/Options/Options.csproj b/features/dotnet/Options/Options.csproj new file mode 100644 index 0000000..0a71eb3 --- /dev/null +++ b/features/dotnet/Options/Options.csproj @@ -0,0 +1,11 @@ + + + $(DefaultTargetFramework) + Exe + DevcontainerConfig.Dotnet + + + + + + diff --git a/features/dotnet/Options/Program.cs b/features/dotnet/Options/Program.cs new file mode 100644 index 0000000..2d38df8 --- /dev/null +++ b/features/dotnet/Options/Program.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Nodes; +using DevcontainerConfig.Dotnet; +using DevcontainerConfig.Features; + +JsonNode schema = OptionsSchema.Of(OptionsContext.Default.FeatureOptions); +Console.WriteLine(schema.ToJsonString()); diff --git a/features/dotnet/README.md b/features/dotnet/README.md new file mode 100644 index 0000000..d529d4c --- /dev/null +++ b/features/dotnet/README.md @@ -0,0 +1,57 @@ +# Dotnet (dotnet) + +Installs .NET SDK and/or the shared runtimes. + +## Example Usage + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +## Options + +| Options Id | Description | Type | Default Value | +| -------------- | ---------------------------------------------------------------------------- | ------- | ------------- | +| tabCompletions | Generate shell tab completions for the dotnet CLI. Requires SDK 10 or newer. | boolean | true | + +## Examples + +Installing latest stable .NET SDK: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +Installing additional versions: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet +FROM ghcr.io/devcontainer-config/features/dotnet/sdk:lts AS dotnet-lts + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +Installing additional runtimes: + +```dockerfile +FROM ghcr.io/devcontainer-config/features/dotnet/sdk AS dotnet +FROM ghcr.io/devcontainer-config/features/dotnet/runtime:lts AS dotnet-runtime-lts +FROM ghcr.io/devcontainer-config/features/dotnet/aspnet:lts AS dotnet-aspnet-lts + +FROM base-image +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-runtime-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY --from=dotnet-aspnet-lts /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +``` + +--- + +_Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/dotnet/feature.json). Add additional notes to a `NOTES.md`._ diff --git a/features/dotnet/config.json b/features/dotnet/config.json index f2577a9..ed7f42c 100644 --- a/features/dotnet/config.json +++ b/features/dotnet/config.json @@ -1,5 +1,5 @@ { - "revision": 1, + "revision": 2, "components": { "sdk": { "versions": { diff --git a/features/dotnet/feature.json b/features/dotnet/feature.json new file mode 100644 index 0000000..d8ea4c8 --- /dev/null +++ b/features/dotnet/feature.json @@ -0,0 +1,13 @@ +{ + "id": "dotnet", + "name": "Dotnet", + "description": "Installs .NET SDK and/or the shared runtimes.", + "image": "ghcr.io/devcontainer-config/features/dotnet/sdk", + "options": { + "tabCompletions": { + "type": "boolean", + "default": true, + "description": "Generate shell tab completions for the dotnet CLI. Requires SDK 10 or newer." + } + } +} diff --git a/scripts/git.ts b/scripts/git.ts new file mode 100644 index 0000000..55bb834 --- /dev/null +++ b/scripts/git.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; + +import gitUrlParse from "git-url-parse"; +import git from "isomorphic-git"; + +import { projectRoot } from "@/scripts/project.js"; + +export const getRemoteInfo = async () => { + const url = (await git.listRemotes({ fs, dir: projectRoot })).at(0)?.url; + if (!url) { + throw new Error("Git remote not found"); + } + const { owner, name: repo } = gitUrlParse(url); + return { owner, repo }; +}; diff --git a/scripts/lint.ts b/scripts/lint.ts index 0122957..acf3509 100644 --- a/scripts/lint.ts +++ b/scripts/lint.ts @@ -2,6 +2,7 @@ import { csharpierCheck } from "@/scripts/tasks/csharpier.js"; import { spellCheck } from "@/scripts/tasks/cspell.js"; import { dotnetFormatCheck } from "@/scripts/tasks/dotnetFormat.js"; import { eslint } from "@/scripts/tasks/eslint.js"; +import { imageFeatures } from "@/scripts/tasks/imageFeatures.js"; import { prettierCheck } from "@/scripts/tasks/prettier.js"; import { syncpackLint } from "@/scripts/tasks/syncpack.js"; import { typeCheck } from "@/scripts/tasks/tsc.js"; @@ -12,4 +13,5 @@ await eslint(); await prettierCheck(); await dotnetFormatCheck(); await csharpierCheck(); +await imageFeatures.check(); await spellCheck(); diff --git a/scripts/tasks/build.ts b/scripts/tasks/build.ts index 703af66..b3951f8 100644 --- a/scripts/tasks/build.ts +++ b/scripts/tasks/build.ts @@ -5,14 +5,15 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { $ } from "execa"; -import gitUrlParse from "git-url-parse"; import git from "isomorphic-git"; import http from "isomorphic-git/http/node"; import * as prettier from "prettier"; import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; +import { getRemoteInfo } from "@/scripts/git.js"; import { projectRoot } from "@/scripts/project.js"; import { shellOptions } from "@/scripts/shell.js"; +import { imageFeatures } from "@/scripts/tasks/imageFeatures.js"; const formatFile = async (path: string) => { const text = await readFile(path, "utf-8"); @@ -20,15 +21,6 @@ const formatFile = async (path: string) => { await writeFile(path, formatted); }; -export const getRemoteInfo = async () => { - const url = (await git.listRemotes({ fs, dir: projectRoot })).at(0)?.url; - if (!url) { - throw new Error("Git remote not found"); - } - const { owner, name: repo } = gitUrlParse(url); - return { owner, repo }; -}; - export const build = async () => { const { owner, repo } = await getRemoteInfo(); @@ -63,4 +55,6 @@ export const build = async () => { } finally { await rm(tempPath, { recursive: true, force: true }); } + + await imageFeatures.write(); }; diff --git a/scripts/tasks/features/dotnet/registry.ts b/scripts/tasks/features/dotnet/registry.ts index 174a4cf..9b46943 100644 --- a/scripts/tasks/features/dotnet/registry.ts +++ b/scripts/tasks/features/dotnet/registry.ts @@ -1,6 +1,6 @@ import { $ } from "execa"; -import { getRemoteInfo } from "@/scripts/tasks/build.js"; +import { getRemoteInfo } from "@/scripts/git.js"; const ghcrHost = "ghcr.io"; diff --git a/scripts/tasks/features/dotnet/renovate.ts b/scripts/tasks/features/dotnet/renovate.ts index f79431d..386ef6a 100644 --- a/scripts/tasks/features/dotnet/renovate.ts +++ b/scripts/tasks/features/dotnet/renovate.ts @@ -6,8 +6,8 @@ import { Octokit } from "@octokit/rest"; import git from "isomorphic-git"; import http from "isomorphic-git/http/node"; +import { getRemoteInfo } from "@/scripts/git.js"; import { projectRoot } from "@/scripts/project.js"; -import { getRemoteInfo } from "@/scripts/tasks/build.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; import { components, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; diff --git a/scripts/tasks/features/dotnet/tagPublished.ts b/scripts/tasks/features/dotnet/tagPublished.ts index 31d28ad..9ad9d3d 100644 --- a/scripts/tasks/features/dotnet/tagPublished.ts +++ b/scripts/tasks/features/dotnet/tagPublished.ts @@ -4,8 +4,8 @@ import { parseArgs } from "node:util"; import { Octokit } from "@octokit/rest"; import git from "isomorphic-git"; +import { getRemoteInfo } from "@/scripts/git.js"; import { projectRoot } from "@/scripts/project.js"; -import { getRemoteInfo } from "@/scripts/tasks/build.js"; import { readConfig } from "./generateConfig.js"; import { parseComponent, parseVersion } from "./invocation.js"; diff --git a/scripts/tasks/features/dotnet/tags.ts b/scripts/tasks/features/dotnet/tags.ts index 20c2000..78d8a74 100644 --- a/scripts/tasks/features/dotnet/tags.ts +++ b/scripts/tasks/features/dotnet/tags.ts @@ -1,4 +1,4 @@ -import { getRemoteInfo } from "@/scripts/tasks/build.js"; +import { getRemoteInfo } from "@/scripts/git.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; diff --git a/scripts/tasks/imageFeatures.ts b/scripts/tasks/imageFeatures.ts new file mode 100644 index 0000000..c80d7e0 --- /dev/null +++ b/scripts/tasks/imageFeatures.ts @@ -0,0 +1,206 @@ +import { access, readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { execa } from "execa"; +import * as prettier from "prettier"; +import { z } from "zod"; + +import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; +import { getRemoteInfo } from "@/scripts/git.js"; +import { projectRoot } from "@/scripts/project.js"; + +const featuresPath = path.resolve(projectRoot, "features"); + +const optionSchema = z.strictObject({ + type: z.string(), + default: z.union([z.string(), z.number(), z.boolean()]), + description: z.string(), +}); + +const manifestSchema = z.strictObject({ + id: z.string(), + name: z.string().optional(), + description: z.string(), + image: z.string(), + options: z.record(z.string(), optionSchema).optional(), +}); + +const generatedSchema = z.object({ + properties: z.record(z.string(), optionSchema), +}); + +type Manifest = z.infer; +type Options = NonNullable; + +const readmeTemplate = `# #{Name} + +#{Description} + +## Example Usage + +\`\`\`dockerfile +FROM #{Image} AS #{Id} + +FROM base-image +COPY --from=#{Id} /features/#{Id}/ /opt/devcontainer-config/features/#{Id}/ +\`\`\` + +#{Options} + +#{Notes} + +--- + +_Note: This file was auto-generated from the [feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +const readFileOrFail = async (filePath: string, message: string): Promise => { + try { + return await readFile(filePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(message, { cause: error }); + } + throw error; + } +}; + +const exists = async (candidate: string): Promise => { + try { + await access(candidate); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +}; + +const manifestOf = async (id: string): Promise => { + const manifestPath = path.join(featuresPath, id, "feature.json"); + const data: unknown = JSON.parse(await readFileOrFail(manifestPath, `${manifestPath} is missing.`)); + const result = manifestSchema.safeParse(data); + if (!result.success) { + throw new Error(`${manifestPath}: ${z.prettifyError(result.error)}`); + } + return result.data; +}; + +const optionsOf = async (id: string): Promise => { + const optionsPath = path.join(featuresPath, id, "Options"); + if (!(await exists(optionsPath))) { + return undefined; + } + const csprojPath = path.join(optionsPath, "Options.csproj"); + const { stdout, stderr, exitCode } = await execa("dotnet", ["run", "--project", csprojPath], { + cwd: projectRoot, + env: { DOTNET_NOLOGO: "1", DOTNET_CLI_TELEMETRY_OPTOUT: "1" }, + reject: false, + }); + const captured = [stdout, stderr].filter((text) => text !== "").join("\n"); + if (exitCode !== 0) { + throw new Error(`dotnet run ${csprojPath} exited with ${exitCode}:\n${captured}`); + } + let schema: unknown; + try { + schema = JSON.parse(stdout); + } catch { + throw new Error(`${csprojPath} printed no JSON schema:\n${captured}`); + } + const result = generatedSchema.safeParse(schema); + if (!result.success) { + throw new Error(`${csprojPath}: ${z.prettifyError(result.error)}\n${captured}`); + } + return result.data.properties; +}; + +const optionsTable = (options: Options): string => { + const entries = Object.entries(options); + if (entries.length === 0) { + return ""; + } + return [ + "## Options", + "", + "| Options Id | Description | Type | Default Value |", + "| ---------- | ----------- | ---- | ------------- |", + ...entries.map( + ([optionId, option]) => `| ${optionId} | ${option.description} | ${option.type} | ${option.default} |`, + ), + ].join("\n"); +}; + +const render = async (manifest: Manifest, notes: string, readmePath: string): Promise => { + const { owner, repo } = await getRemoteInfo(); + const url = `https://github.com/${owner}/${repo}/blob/main/features/${manifest.id}/feature.json`; + const text = readmeTemplate + .replace("#{Name}", manifest.name === undefined ? manifest.id : `${manifest.name} (${manifest.id})`) + .replace("#{Description}", manifest.description) + .replace("#{Image}", manifest.image) + .replaceAll("#{Id}", manifest.id) + .replace("#{Options}", optionsTable(manifest.options ?? {})) + .replace("#{Notes}", notes) + .replace("#{RepoUrl}", url); + return prettier.format(text, { ...prettierOptions, filepath: readmePath }); +}; + +const list = async (): Promise => { + const entries = await readdir(featuresPath, { withFileTypes: true }); + const ids: string[] = []; + for (const entry of entries) { + if (entry.isDirectory() && (await exists(path.join(featuresPath, entry.name, "feature.json")))) { + ids.push(entry.name); + } + } + return ids.sort(); +}; + +interface Feature { + manifestPath: string; + manifestText: string; + readmePath: string; + readmeText: string; +} + +const feature = async (id: string): Promise => { + const featurePath = path.join(featuresPath, id); + const manifestPath = path.join(featurePath, "feature.json"); + const readmePath = path.join(featurePath, "README.md"); + const notesPath = path.join(featurePath, "NOTES.md"); + const manifest = await manifestOf(id); + manifest.options = await optionsOf(id); + const notes = await readFileOrFail(notesPath, `${notesPath} is missing.`); + return { + manifestPath, + manifestText: await prettier.format(JSON.stringify(manifest), { ...prettierOptions, filepath: manifestPath }), + readmePath, + readmeText: await render(manifest, notes, readmePath), + }; +}; + +const write = async (): Promise => { + for (const id of await list()) { + const generated = await feature(id); + await writeFile(generated.manifestPath, generated.manifestText); + await writeFile(generated.readmePath, generated.readmeText); + console.log(`image-features: wrote ${generated.manifestPath} and ${generated.readmePath}`); + } +}; + +const checkFile = async (filePath: string, expected: string): Promise => { + const actual = await readFileOrFail(filePath, `${filePath} is missing; run pnpm build.`); + if (actual !== expected) { + throw new Error(`${filePath} is stale; run pnpm build.`); + } +}; + +const check = async (): Promise => { + for (const id of await list()) { + const generated = await feature(id); + await checkFile(generated.manifestPath, generated.manifestText); + await checkFile(generated.readmePath, generated.readmeText); + } +}; + +export const imageFeatures = { write, check } as const; diff --git a/scripts/tasks/publish.ts b/scripts/tasks/publish.ts index a343a93..8adcaf9 100644 --- a/scripts/tasks/publish.ts +++ b/scripts/tasks/publish.ts @@ -2,8 +2,8 @@ import { Readable } from "node:stream"; import { $ } from "execa"; +import { getRemoteInfo } from "@/scripts/git.js"; import { $$, shellOptions } from "@/scripts/shell.js"; -import { getRemoteInfo } from "@/scripts/tasks/build.js"; export const publish = async (featuresPath: string) => { const { owner, repo } = await getRemoteInfo(); diff --git a/scripts/verify/dotnet/lifecycle.ts b/scripts/verify/dotnet/lifecycle.ts index e827e46..d6e8585 100644 --- a/scripts/verify/dotnet/lifecycle.ts +++ b/scripts/verify/dotnet/lifecycle.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { cp, mkdir, writeFile } from "node:fs/promises"; import path, { posix } from "node:path"; import { $ } from "execa"; @@ -13,7 +13,7 @@ const baseImage = "mcr.microsoft.com/devcontainers/base:debian"; const remoteUser = "verify"; const $$docker = $({ reject: false, stdin: "ignore", stderr: "ignore" }); -const $$exec = $({ reject: false, stdio: ["ignore", "pipe", "pipe"], verbose: "full", cwd: projectRoot }); +const $$capture = $({ reject: false, stdio: ["ignore", "pipe", "pipe"], verbose: "full", cwd: projectRoot }); export interface Workspace { path: string; @@ -42,11 +42,14 @@ export interface StartedCommand { export interface TestImage { tag: string; components: readonly ComponentImage[]; + dockerfile?: string; + files?: readonly string[]; } export interface WorkspaceConfig { image: string; remoteEnv?: Readonly>; + skipWorkloadIntegrityCheck?: boolean; } const workspaceAt = (root: string, name: string): Workspace => { @@ -97,7 +100,10 @@ const provision = async (components: readonly ComponentImage[], options: Provisi const testImageBuild = (image: TestImage): { dockerfile: string; args: string[] } => { const [only] = image.components; if (image.components.length === 1) { - return { dockerfile: "test-image.Dockerfile", args: [`DOTNET_IMAGE_REF=${only.ref}`] }; + return { dockerfile: image.dockerfile ?? "test-image.Dockerfile", args: [`DOTNET_IMAGE_REF=${only.ref}`] }; + } + if (image.dockerfile !== undefined) { + throw new Error(`${image.tag}: a variant dockerfile is only supported for single-component test images`); } return { dockerfile: "test-image-multi.Dockerfile", @@ -108,6 +114,9 @@ const testImageBuild = (image: TestImage): { dockerfile: string; args: string[] const buildTestImage = async (ctxPath: string, image: TestImage): Promise => { const { dockerfile, args } = testImageBuild(image); await mkdir(ctxPath, { recursive: true }); + for (const file of image.files ?? []) { + await cp(path.join(import.meta.dirname, file), path.join(ctxPath, file)); + } await project$$`docker buildx build ${[ "--network=none", "--load", @@ -129,7 +138,7 @@ const createWorkspace = async (workspace: Workspace, config: WorkspaceConfig): P runArgs: ["--network=none"], remoteEnv: { DOTNET_NOLOGO: "1", - DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK: "1", + ...(config.skipWorkloadIntegrityCheck === false ? {} : { DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK: "1" }), ...config.remoteEnv, }, }; @@ -146,12 +155,12 @@ const startContainer = async (workspace: Workspace): Promise => { const execInContainer = async (workspace: Workspace, command: Command): Promise => { const env = Object.entries(command.env ?? {}).flatMap(([name, value]) => ["--remote-env", `${name}=${value}`]); - const result = await $$exec`devcontainer exec ${["--workspace-folder", workspace.path]} ${env} ${command.command}`; + const result = await $$capture`devcontainer exec ${["--workspace-folder", workspace.path]} ${env} ${command.command}`; return { command: command.command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; }; const startInContainer = (workspace: Workspace, command: readonly string[]): StartedCommand => { - const child = $$exec`devcontainer exec ${["--workspace-folder", workspace.path]} ${command}`; + const child = $$capture`devcontainer exec ${["--workspace-folder", workspace.path]} ${command}`; const output: string[] = []; child.stdout?.on("data", (chunk: Buffer) => output.push(chunk.toString())); child.stderr?.on("data", (chunk: Buffer) => output.push(chunk.toString())); @@ -166,6 +175,19 @@ const startInContainer = (workspace: Workspace, command: readonly string[]): Sta }; }; +const expectBuildFailure = async (workspace: Workspace, message: string): Promise => { + const result = await $$capture`devcontainer build ${["--workspace-folder", workspace.path]}`; + if (result.exitCode === 0) { + throw new Error(`devcontainer build ${workspace.path}: expected a failing build`); + } + const output = `${result.stdout}\n${result.stderr}`; + if (!output.includes(message)) { + throw new Error( + `devcontainer build ${workspace.path}: expected ${JSON.stringify(message)} in the output:\n${output}`, + ); + } +}; + const removeContainer = async (workspace: Workspace): Promise => { const filter = `label=devcontainer.config_file=${path.join(workspace.path, ".devcontainer/devcontainer.json")}`; const { stdout } = await $$docker`docker ps -a ${[...["--filter", filter], ...["--format", "{{.ID}}"]]}`; @@ -192,6 +214,7 @@ export const lifecycle = { startContainer, execInContainer, startInContainer, + expectBuildFailure, removeContainer, removeImage, } as const; diff --git a/scripts/verify/dotnet/options-malformed.json b/scripts/verify/dotnet/options-malformed.json new file mode 100644 index 0000000..0369dd0 --- /dev/null +++ b/scripts/verify/dotnet/options-malformed.json @@ -0,0 +1 @@ +{ "tabCompletions": "off" } diff --git a/scripts/verify/dotnet/options-off.json b/scripts/verify/dotnet/options-off.json new file mode 100644 index 0000000..7283d6e --- /dev/null +++ b/scripts/verify/dotnet/options-off.json @@ -0,0 +1 @@ +{ "tabCompletions": false } diff --git a/scripts/verify/dotnet/scenarios.ts b/scripts/verify/dotnet/scenarios.ts index fff945c..f311a24 100644 --- a/scripts/verify/dotnet/scenarios.ts +++ b/scripts/verify/dotnet/scenarios.ts @@ -4,6 +4,7 @@ import type { Component } from "@/scripts/tasks/features/dotnet/generateConfig.j import type { Expectation } from "./assertions.js"; import type { TestImage, Workspace } from "./lifecycle.js"; +import { lifecycle } from "./lifecycle.js"; import type { Selection } from "./selection.js"; import type { Serve } from "./serve.js"; @@ -21,6 +22,7 @@ export interface Exec { export interface ScenarioContext { selection: Selection; + root: string; workspace: Workspace; } @@ -31,6 +33,20 @@ interface ScenarioDefinition { app?: AppName; } +type ScenarioVariantDefinition = { + name: string; + dockerfile?: string; + files?: readonly string[]; + skipWorkloadIntegrityCheck?: boolean; +} & ({ execs: readonly Exec[] } | { buildFailure: string }); + +export type ScenarioVariant = { + name: string; + workspace: Workspace; + testImage: TestImage; + skipWorkloadIntegrityCheck?: boolean; +} & ({ execs: readonly Exec[] } | { buildFailure: string }); + export interface Scenario extends ScenarioDefinition { name: ScenarioName; workspace: Workspace; @@ -38,6 +54,7 @@ export interface Scenario extends ScenarioDefinition { execs: readonly Exec[]; remoteEnv?: Readonly>; serve?: Serve; + variants: readonly ScenarioVariant[]; } export interface BuildStage { @@ -49,6 +66,7 @@ interface ScenarioBehavior { execs: readonly Exec[]; remoteEnv?: Readonly>; serve?: Serve; + variants?: readonly ScenarioVariantDefinition[]; } const testImagePrefix = "features-verify-dotnet"; @@ -60,6 +78,80 @@ const definitions: Record = { multi: { components: ["sdk", "runtime", "aspnet"], assets: [], directories: [] }, }; +const completionFiles = [ + "/usr/share/bash-completion/completions/dotnet", + "/usr/share/zsh/site-functions/_dotnet", + "/usr/share/fish/vendor_completions.d/dotnet.fish", +]; + +const completionsPresent: Exec = { + command: ["sh", "-c", `for file in ${completionFiles.join(" ")}; do test -s "$file" || exit 1; done; echo present`], + expect: { stdout: ["present"] }, +}; + +const completionsAbsent: Exec = { + command: ["sh", "-c", `for file in ${completionFiles.join(" ")}; do test ! -e "$file" || exit 1; done; echo absent`], + expect: { stdout: ["absent"] }, +}; + +const completionRegistration: Exec = { + command: [ + "bash", + "-lc", + "source /usr/share/bash-completion/bash_completion; _completion_loader dotnet; complete -p dotnet", + ], + expect: { stdout: ["complete -F _dotnet dotnet"] }, +}; + +const installResidue: Exec = { + command: [ + "sh", + "-c", + "test ! -e /opt/dotnet/metadata && test ! -e /root/.dotnet && test ! -e /root/.local/share/NuGet && test ! -e /root/.nuget && echo clean", + ], + expect: { stdout: ["clean"] }, +}; + +const tmpPreserved: Exec = { + command: ["sh", "-c", '[ "$(stat -c %a /tmp)" = 1777 ] && echo preserved'], + expect: { stdout: ["preserved"] }, +}; + +const sdkVariants: readonly ScenarioVariantDefinition[] = [ + { + name: "notice", + skipWorkloadIntegrityCheck: false, + execs: [ + { + command: [ + "bash", + "-lc", + "grep -qxF 'export DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK=true' /etc/profile.d/dotnet.sh", + ], + expect: { stdout: [] }, + }, + { + command: ["bash", "-lc", "dotnet nuget locals all --list > /dev/null"], + env: { XDG_DATA_HOME: "/tmp/notice-xdg" }, + expect: { stdout: [] }, + }, + ], + }, + { + name: "options-off", + dockerfile: "test-image-options-off.Dockerfile", + files: ["options-off.json"], + execs: [completionsAbsent], + }, + { + name: "options-malformed", + dockerfile: "test-image-options-malformed.Dockerfile", + files: ["options-malformed.json"], + buildFailure: + "install: /opt/devcontainer-config/features/dotnet/options.json: The JSON value could not be converted to DevcontainerConfig.Dotnet.Options. Path: $.tabCompletions | LineNumber: 0 | BytePositionInLine: 25.", + }, +]; + const sdkBehavior = ({ selection, workspace }: ScenarioContext): ScenarioBehavior => ({ execs: [ { command: ["dotnet", "--version"], expect: { stdout: [selection.components.sdk.version] } }, @@ -100,11 +192,16 @@ const sdkBehavior = ({ selection, workspace }: ScenarioContext): ScenarioBehavio ], }, }, + completionsPresent, + completionRegistration, + installResidue, + tmpPreserved, ], + variants: sdkVariants, }); const runtimeBehavior = (): ScenarioBehavior => ({ - execs: [{ command: ["dotnet", "app/console.dll"], expect: { stdout: ["console-ok"] } }], + execs: [{ command: ["dotnet", "app/console.dll"], expect: { stdout: ["console-ok"] } }, completionsAbsent], }); const aspnetBehavior = ({ workspace }: ScenarioContext): ScenarioBehavior => ({ @@ -145,13 +242,31 @@ const behaviors: Record ScenarioBeha const appOutput = (app: AppName): string => `out/${app}`; -const testImageOf = (name: ScenarioName, selection: Selection): TestImage => { +const testImageOf = (name: ScenarioName, selection: Selection, variant?: string): TestImage => { const components = definitions[name].components.map((component) => selection.components[component]); const [label] = components; if (label === undefined) { throw new Error(`Scenario ${name} declares no components`); } - return { tag: `${testImagePrefix}:${name}-${label.version}`, components }; + const image = variant === undefined ? name : `${name}-${variant}`; + return { tag: `${testImagePrefix}:${image}-${label.version}`, components }; +}; + +const materializeVariant = ( + name: ScenarioName, + definition: ScenarioVariantDefinition, + context: ScenarioContext, +): ScenarioVariant => { + const image = testImageOf(name, context.selection, definition.name); + const variant = { + name: definition.name, + workspace: lifecycle.workspaceAt(context.root, `${name}-${definition.name}`), + testImage: { ...image, dockerfile: definition.dockerfile, files: definition.files }, + skipWorkloadIntegrityCheck: definition.skipWorkloadIntegrityCheck, + }; + return "buildFailure" in definition + ? { ...variant, buildFailure: definition.buildFailure } + : { ...variant, execs: definition.execs }; }; const createScenario = (name: ScenarioName, context: ScenarioContext): Scenario => { @@ -164,6 +279,7 @@ const createScenario = (name: ScenarioName, context: ScenarioContext): Scenario execs: behavior.execs, remoteEnv: behavior.remoteEnv, serve: behavior.serve, + variants: (behavior.variants ?? []).map((variant) => materializeVariant(name, variant, context)), }; }; diff --git a/scripts/verify/dotnet/test-image-options-malformed.Dockerfile b/scripts/verify/dotnet/test-image-options-malformed.Dockerfile new file mode 100644 index 0000000..9249fad --- /dev/null +++ b/scripts/verify/dotnet/test-image-options-malformed.Dockerfile @@ -0,0 +1,8 @@ +# check=skip=InvalidDefaultArgInFrom +ARG DOTNET_IMAGE_REF + +FROM ${DOTNET_IMAGE_REF} AS dotnet + +FROM mcr.microsoft.com/devcontainers/base:debian +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY options-malformed.json /opt/devcontainer-config/features/dotnet/options.json diff --git a/scripts/verify/dotnet/test-image-options-off.Dockerfile b/scripts/verify/dotnet/test-image-options-off.Dockerfile new file mode 100644 index 0000000..a65ed47 --- /dev/null +++ b/scripts/verify/dotnet/test-image-options-off.Dockerfile @@ -0,0 +1,8 @@ +# check=skip=InvalidDefaultArgInFrom +ARG DOTNET_IMAGE_REF + +FROM ${DOTNET_IMAGE_REF} AS dotnet + +FROM mcr.microsoft.com/devcontainers/base:debian +COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ +COPY options-off.json /opt/devcontainer-config/features/dotnet/options.json diff --git a/scripts/verify/dotnet/verify.ts b/scripts/verify/dotnet/verify.ts index b425794..1212472 100644 --- a/scripts/verify/dotnet/verify.ts +++ b/scripts/verify/dotnet/verify.ts @@ -53,8 +53,13 @@ export const verify = async (argv: string[]): Promise => { const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-dotnet-")); const ctxPath = path.join(tempPath.path, "ctx"); const runs = selected.map((name) => - scenarios.create(name, { selection: derived, workspace: lifecycle.workspaceAt(tempPath.path, name) }), + scenarios.create(name, { + selection: derived, + root: tempPath.path, + workspace: lifecycle.workspaceAt(tempPath.path, name), + }), ); + const variants = runs.flatMap((run) => run.variants); const apps = [...new Set(runs.flatMap((run) => (run.app === undefined ? [] : [run.app])))]; const build = apps.length === 0 @@ -67,11 +72,15 @@ export const verify = async (argv: string[]): Promise => { for (const run of runs) { testImages.set(run.testImage.tag, run.testImage); } + for (const variant of variants) { + testImages.set(variant.testImage.tag, variant.testImage); + } if (build !== undefined) { testImages.set(build.testImage.tag, build.testImage); } const workspaces: Workspace[] = [ ...runs.map((run) => run.workspace), + ...variants.map((variant) => variant.workspace), ...(build === undefined ? [] : [build.workspace]), ]; @@ -95,6 +104,13 @@ export const verify = async (argv: string[]): Promise => { await staging.features(run.workspace); await staging.assets(run.workspace, run.assets, run.directories); } + for (const variant of variants) { + await lifecycle.createWorkspace(variant.workspace, { + image: variant.testImage.tag, + skipWorkloadIntegrityCheck: variant.skipWorkloadIntegrityCheck, + }); + await staging.features(variant.workspace); + } if (build !== undefined) { await lifecycle.createWorkspace(build.workspace, { image: build.testImage.tag }); await staging.features(build.workspace); @@ -130,6 +146,18 @@ export const verify = async (argv: string[]): Promise => { await server.kill(); } } + for (const variant of run.variants) { + console.log(`>>> verify dotnet: scenario ${run.name} variant ${variant.name}`); + if ("buildFailure" in variant) { + await lifecycle.expectBuildFailure(variant.workspace, variant.buildFailure); + continue; + } + await lifecycle.buildContainer(variant.workspace); + await lifecycle.startContainer(variant.workspace); + for (const exec of variant.execs) { + assertions.exec(await lifecycle.execInContainer(variant.workspace, exec), exec.expect); + } + } } } finally { console.log(">>> verify dotnet: cleanup");