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
7 changes: 6 additions & 1 deletion .config/cspell/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"slsa",
"uvx",
"ndjson",
"venv"
"venv",
"cmdline",
"GLIBCXX",
"libstdc",
"noproxy",
"pgrep"
]
}
1 change: 1 addition & 0 deletions .config/dotnet/.globalconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
dotnet_diagnostic.IDE0005.severity=warning
dotnet_diagnostic.IDE0305.severity=warning
7 changes: 5 additions & 2 deletions .config/dotnet/Format.targets
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<Project>
<Target Name="DotNetFormatCheck" Condition="'$(DotNetFormatCheck)' == 'true'" AfterTargets="GetTargetPath">
<Exec Command="dotnet format style --verify-no-changes" WorkingDirectory="$(MSBuildProjectDirectory)" />
<Exec
Command="dotnet format style --verify-no-changes --severity info"
WorkingDirectory="$(MSBuildProjectDirectory)"
/>
</Target>

<Target Name="DotNetFormat" Condition="'$(DotNetFormat)' == 'true'" AfterTargets="GetTargetPath">
<Exec Command="dotnet format style" WorkingDirectory="$(MSBuildProjectDirectory)" />
<Exec Command="dotnet format style --severity info" WorkingDirectory="$(MSBuildProjectDirectory)" />
</Target>
</Project>
27 changes: 27 additions & 0 deletions .github/workflows/code-server.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: "code-server"

on:
pull_request:
paths:
- "features/code-server/config.json"
push:
branches:
- main
paths:
- "features/code-server/config.json"
workflow_dispatch:

concurrency:
group: "code-server-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
verify:
uses: ./.github/workflows/verify.yml
permissions:
contents: write
packages: write
with:
feature: code-server
binaries: true
scenarios: '["default", "password", "socket", "args"]'
2 changes: 1 addition & 1 deletion .github/workflows/renovate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
feature: [dotnet, ripgrep, uv]
feature: [dotnet, ripgrep, code-server, uv]
steps:
- name: Checkout (GitHub)
uses: actions/checkout@v7
Expand Down
19 changes: 16 additions & 3 deletions features/Features/OptionsSchema.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
Expand All @@ -9,14 +10,26 @@ public static class OptionsSchema
{
private static readonly JsonSchemaExporterOptions ExporterOptions = new()
{
TreatNullObliviousAsNonNullable = true,
TransformSchemaNode = (context, node) =>
{
if (context.PropertyInfo is not { AttributeProvider: { } provider } property)
{
return node;
}
if (provider.GetCustomAttributes(typeof(DescriptionAttribute), false) is [DescriptionAttribute description])
{
((JsonObject)node)["description"] = description.Description;
}
if (
context.PropertyInfo?.AttributeProvider?.GetCustomAttributes(typeof(DescriptionAttribute), false)
is [DescriptionAttribute description]
provider.GetCustomAttributes(typeof(DefaultValueAttribute), false)
is [DefaultValueAttribute defaultValue]
)
{
((JsonObject)node)["description"] = description.Description;
((JsonObject)node)["default"] = JsonSerializer.SerializeToNode(
defaultValue.Value,
property.Options.GetTypeInfo(property.PropertyType)
);
}
return node;
},
Expand Down
9 changes: 9 additions & 0 deletions features/code-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM scratch AS linux-amd64-base
FROM scratch AS linux-arm64-base

ARG TARGETOS
ARG TARGETARCH
FROM ${TARGETOS}-${TARGETARCH}-base
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/features/code-server/ /features/code-server/
20 changes: 20 additions & 0 deletions features/code-server/Feature/Feature.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<AssemblyName>Feature</AssemblyName>
<OutputType>Exe</OutputType>
<RootNamespace>DevcontainerConfig.CodeServer.Feature</RootNamespace>
<ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
</PropertyGroup>

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

<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
<PublishAot>true</PublishAot>
<StaticExecutable>true</StaticExecutable>
<LinkerFlavor>lld</LinkerFlavor>
</PropertyGroup>
</Project>
19 changes: 19 additions & 0 deletions features/code-server/Feature/Install.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using DevcontainerConfig.Features;

namespace DevcontainerConfig.CodeServer.Feature;

internal static class Install
{
private const string BinDir = "/usr/local/bin";
private const string LinkPath = "/usr/local/bin/code-server";

internal static void Run(string stagedDir)
{
Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions);
options.Validate();

Directory.CreateDirectory(BinDir);
File.Delete(LinkPath);
File.CreateSymbolicLink(LinkPath, Path.Join(stagedDir, "code-server", "bin", "code-server"));
}
}
180 changes: 180 additions & 0 deletions features/code-server/Feature/PostStart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System.Diagnostics;
using System.Globalization;
using DevcontainerConfig.Features;

namespace DevcontainerConfig.CodeServer.Feature;

internal static class PostStart
{
internal static void Run(string stagedDir)
{
Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions);
options.Validate();

string cachePath = XdgPath("XDG_CACHE_HOME", ".cache", "code-server");
string statePath = XdgPath("XDG_STATE_HOME", Path.Join(".local", "state"), "code-server");
Directory.CreateDirectory(cachePath);
Directory.CreateDirectory(statePath);
if (options.Socket.Length > 0)
{
Directory.CreateDirectory(Path.GetDirectoryName(options.Socket)!);
}

string logPath = Path.Join(cachePath, "code-server.log");
string pidPath = Path.Join(statePath, "code-server.pid");
if (IsRunning(pidPath, stagedDir))
{
return;
}

ProcessStartInfo startInfo = new(Path.Join(stagedDir, "launch")) { UseShellExecute = false };
startInfo.ArgumentList.Add(logPath);
startInfo.ArgumentList.Add(Path.Join(stagedDir, "code-server", "bin", "code-server"));
foreach (string argument in ArgumentsOf(options))
{
startInfo.ArgumentList.Add(argument);
}
SetSecret(startInfo, "PASSWORD", options.PasswordFile);
SetSecret(startInfo, "HASHED_PASSWORD", options.HashedPasswordFile);
SetSecret(startInfo, "GITHUB_TOKEN", options.GithubAuthTokenFile);

using Process process = new() { StartInfo = startInfo };
try
{
if (!process.Start())
{
throw new FeatureException("code-server did not start");
}
File.WriteAllText(pidPath, $"{process.Id.ToString(CultureInfo.InvariantCulture)}\n");
}
catch
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// The process already exited, so there is no server left to stop.
}
throw;
}
}

private static List<string> ArgumentsOf(Options options)
{
List<string> arguments = ["--auth", options.Auth];
if (options.Socket.Length > 0)
{
arguments.Add("--socket");
arguments.Add(options.Socket);
arguments.Add("--socket-mode");
arguments.Add(options.SocketMode);
}
else
{
arguments.Add("--bind-addr");
arguments.Add($"{options.Host}:{options.Port.ToString(CultureInfo.InvariantCulture)}");
}
AddFlag(arguments, options.DisableTelemetry, "--disable-telemetry");
AddFlag(arguments, options.DisableUpdateCheck, "--disable-update-check");
AddFlag(arguments, options.DisableWorkspaceTrust, "--disable-workspace-trust");
AddFlag(arguments, options.DisableFileDownloads, "--disable-file-downloads");
AddFlag(arguments, options.DisableFileUploads, "--disable-file-uploads");
AddFlag(arguments, options.DisableProxy, "--disable-proxy");
AddRepeated(arguments, "--proxy-domain", options.ProxyDomain);
if (options.AbsProxyBasePath.Length > 0)
{
arguments.Add("--abs-proxy-base-path");
arguments.Add(options.AbsProxyBasePath);
}
AddRepeated(arguments, "--trusted-origins", options.TrustedOrigins);
if (options.Locale.Length > 0)
{
arguments.Add("--locale");
arguments.Add(options.Locale);
}
AddFlag(arguments, options.Verbose, "--verbose");
arguments.Add(options.Workspace.Length > 0 ? options.Workspace : Directory.GetCurrentDirectory());
arguments.AddRange(options.Args);
return arguments;
}

private static void AddFlag(List<string> arguments, bool enabled, string flag)
{
if (enabled)
{
arguments.Add(flag);
}
}

private static void AddRepeated(List<string> arguments, string flag, string[] values)
{
foreach (string value in values)
{
arguments.Add(flag);
arguments.Add(value);
}
}

private static void SetSecret(ProcessStartInfo startInfo, string name, string path)
{
if (path.Length == 0)
{
return;
}
string value;
try
{
value = File.ReadAllText(path).TrimEnd('\r', '\n');
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
throw new FeatureException($"{name} file {path} cannot be read: {e.Message}");
}
if (value.Length == 0)
{
throw new FeatureException($"{name} file {path} is empty");
}
startInfo.Environment[name] = value;
}

private static string XdgPath(string variable, string fallback, string suffix)
{
string? root = Environment.GetEnvironmentVariable(variable);
if (root is null || root.Length == 0 || !Path.IsPathRooted(root))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (home.Length == 0)
{
throw new FeatureException($"HOME is not set; cannot resolve {variable}");
}
root = Path.Join(home, fallback);
}
return Path.Join(root, suffix);
}

private static bool IsRunning(string pidPath, string stagedDir)
{
if (!File.Exists(pidPath))
{
return false;
}
string text = File.ReadAllText(pidPath).Trim();
if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int pid) || pid <= 0)
{
return false;
}
string cmdline;
try
{
cmdline = File.ReadAllText(Path.Join("/proc", pid.ToString(CultureInfo.InvariantCulture), "cmdline"));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return false;
}
string nodePath = Path.Join(stagedDir, "code-server", "lib", "node");
return cmdline.Split('\0').Contains(nodePath, StringComparer.Ordinal);
}
}
33 changes: 33 additions & 0 deletions features/code-server/Feature/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Runtime.Versioning;
using DevcontainerConfig.CodeServer.Feature;

[assembly: SupportedOSPlatform("linux")]

string hook = args.Length > 0 ? args[0] : "";
if (hook is not ("install" or "postStart"))
{
Console.Error.WriteLine("usage: Feature <install|postStart>");
return 2;
}

string stagedDir = Environment.GetEnvironmentVariable("FEATURES_DIR") is { Length: > 0 } dir
? dir
: Directory.GetCurrentDirectory();

try
{
if (hook == "install")
{
Install.Run(stagedDir);
}
else
{
PostStart.Run(stagedDir);
}
return 0;
}
catch (Exception e)
{
Console.Error.WriteLine($"{hook}: {e.Message.ReplaceLineEndings(" ")}");
return 1;
}
19 changes: 19 additions & 0 deletions features/code-server/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Example Options

```json
{
"auth": "password",
"hashedPasswordFile": "/run/secrets/code-server-password",
"host": "127.0.0.1",
"port": 8080
}
```

## Address

With `socket` set, the server listens on that absolute Unix socket with `socketMode` as its file mode. `host` and `port` are ignored. Without `socket`, the server listens on `host:port`.

## Paths

- Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log`
- PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid`
Loading
Loading