Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/cspell/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"globalconfig",
"hasha",
"mktemp",
"libicu",
"msbuild",
"NOLOGO",
"packagejson",
Expand Down
22 changes: 22 additions & 0 deletions .devcontainer/features/src/features/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co
}
```

A feature stages artifacts at `/features/<id>/`, and it's consumed by copying `--from=<image> /features/<id>/` into
`<featuresPath>/<id>`:

```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 |
Expand All @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions .devcontainer/features/src/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ The `COPY` target should match `featuresPath` (defaults to `/opt/devcontainer-co
}
```

A feature stages artifacts at `/features/<id>/`, and it's consumed by copying `--from=<image> /features/<id>/` into
`<featuresPath>/<id>`:

```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 |
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions features/Features/FeatureException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace DevcontainerConfig.Features;

public sealed class FeatureException(string message) : Exception(message);
6 changes: 6 additions & 0 deletions features/Features/Features.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<RootNamespace>DevcontainerConfig.Features</RootNamespace>
</PropertyGroup>
</Project>
23 changes: 23 additions & 0 deletions features/Features/OptionsFile.cs
Original file line number Diff line number Diff line change
@@ -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<T>(string stagedDir, JsonTypeInfo<T> 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}");
}
}
}
27 changes: 27 additions & 0 deletions features/Features/OptionsSchema.cs
Original file line number Diff line number Diff line change
@@ -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);
}
83 changes: 83 additions & 0 deletions features/dotnet/Install/Completions.cs
Original file line number Diff line number Diff line change
@@ -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<Source> 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<string> 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<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> 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;
}
}
3 changes: 3 additions & 0 deletions features/dotnet/Install/Install.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
<AssemblyName>install</AssemblyName>
<OutputType>Exe</OutputType>
<RootNamespace>DevcontainerConfig.Dotnet.Install</RootNamespace>
<ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="NuGet.Versioning" />
<ProjectReference Include="../../Features/Features.csproj" />
<ProjectReference Include="../Options/Options.csproj" />
</ItemGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
Expand Down
7 changes: 7 additions & 0 deletions features/dotnet/Install/Permissions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace DevcontainerConfig.Dotnet.Install;

internal static class Permissions
{
internal const UnixFileMode Mode0644 =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
}
10 changes: 9 additions & 1 deletion features/dotnet/Install/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Runtime.Versioning;
using DevcontainerConfig.Dotnet;
using DevcontainerConfig.Dotnet.Install;
using DevcontainerConfig.Features;

[assembly: SupportedOSPlatform("linux")]

Expand All @@ -10,11 +12,17 @@

try
{
foreach (Source source in Scan.Sources(stagedDir))
Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions);
List<Source> 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)
Expand Down
8 changes: 3 additions & 5 deletions features/dotnet/Install/Registration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}");
}

Expand Down Expand Up @@ -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}");
}
}
34 changes: 34 additions & 0 deletions features/dotnet/NOTES.md
Original file line number Diff line number Diff line change
@@ -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/
```
15 changes: 15 additions & 0 deletions features/dotnet/Options/Options.cs
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions features/dotnet/Options/Options.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>
<RootNamespace>DevcontainerConfig.Dotnet</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../Features/Features.csproj" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions features/dotnet/Options/Program.cs
Original file line number Diff line number Diff line change
@@ -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());
Loading
Loading