From 8ab79aa215c3dda93b6dec76c390c8375ab15878 Mon Sep 17 00:00:00 2001 From: gdlol <.> Date: Sun, 8 Nov 2026 11:48:45 +0000 Subject: [PATCH 1/2] code-server feature --- .config/cspell/cspell.json | 7 +- .config/dotnet/.globalconfig | 1 + .config/dotnet/Format.targets | 7 +- .github/workflows/code-server.yml | 27 ++ .github/workflows/renovate.yml | 2 +- features/Features/OptionsSchema.cs | 19 +- features/code-server/Dockerfile | 9 + features/code-server/Feature/Feature.csproj | 20 ++ features/code-server/Feature/Install.cs | 19 ++ features/code-server/Feature/PostStart.cs | 180 +++++++++++++ features/code-server/Feature/Program.cs | 33 +++ features/code-server/NOTES.md | 53 ++++ features/code-server/Options/Options.cs | 164 ++++++++++++ features/code-server/Options/Options.csproj | 11 + features/code-server/Options/Program.cs | 6 + features/code-server/README.md | 96 +++++++ features/code-server/config.json | 18 ++ features/code-server/feature.json | 76 ++++++ features/code-server/install | 2 + features/code-server/launch | 13 + features/code-server/postStart | 2 + features/dotnet/Feature/Registration.cs | 8 +- scripts/tasks/features/architecture.ts | 11 + scripts/tasks/features/code-server/feature.ts | 33 +++ .../features/code-server/generateConfig.ts | 59 +++++ .../tasks/features/code-server/packBinary.ts | 13 + scripts/tasks/features/code-server/payload.ts | 47 ++++ scripts/tasks/features/config.ts | 46 ++++ .../tasks/features/dotnet/generateConfig.ts | 34 +-- scripts/tasks/features/featureBinary.ts | 27 +- scripts/tasks/features/features.ts | 8 +- scripts/tasks/features/githubReleases.ts | 120 +++++++++ .../tasks/features/ripgrep/generateConfig.ts | 152 ++--------- scripts/tasks/features/ripgrep/payload.ts | 51 +--- scripts/tasks/features/tarball.ts | 41 +++ scripts/tasks/features/uv/feature.ts | 4 +- scripts/tasks/features/uv/generateConfig.ts | 34 +-- scripts/tasks/features/uv/packBinary.ts | 27 +- scripts/tasks/features/uv/paths.ts | 8 +- scripts/tasks/features/uv/payload.ts | 38 +-- scripts/tasks/imageFeatures.ts | 11 +- .../code-server/assets/options-unknown.json | 3 + scripts/verify/code-server/scenarios.ts | 241 ++++++++++++++++++ scripts/verify/code-server/selection.ts | 38 +++ .../code-server/test-image-options.Dockerfile | 8 + .../verify/code-server/test-image.Dockerfile | 7 + scripts/verify/code-server/verify.ts | 32 +++ scripts/verify/dotnet/selection.ts | 29 +-- scripts/verify/userInit.ts | 37 +++ scripts/verify/uv/selection.ts | 30 +-- 50 files changed, 1605 insertions(+), 357 deletions(-) create mode 100644 .github/workflows/code-server.yml create mode 100644 features/code-server/Dockerfile create mode 100644 features/code-server/Feature/Feature.csproj create mode 100644 features/code-server/Feature/Install.cs create mode 100644 features/code-server/Feature/PostStart.cs create mode 100644 features/code-server/Feature/Program.cs create mode 100644 features/code-server/NOTES.md create mode 100644 features/code-server/Options/Options.cs create mode 100644 features/code-server/Options/Options.csproj create mode 100644 features/code-server/Options/Program.cs create mode 100644 features/code-server/README.md create mode 100644 features/code-server/config.json create mode 100644 features/code-server/feature.json create mode 100755 features/code-server/install create mode 100755 features/code-server/launch create mode 100755 features/code-server/postStart create mode 100644 scripts/tasks/features/code-server/feature.ts create mode 100644 scripts/tasks/features/code-server/generateConfig.ts create mode 100644 scripts/tasks/features/code-server/packBinary.ts create mode 100644 scripts/tasks/features/code-server/payload.ts create mode 100644 scripts/tasks/features/githubReleases.ts create mode 100644 scripts/tasks/features/tarball.ts create mode 100644 scripts/verify/code-server/assets/options-unknown.json create mode 100644 scripts/verify/code-server/scenarios.ts create mode 100644 scripts/verify/code-server/selection.ts create mode 100644 scripts/verify/code-server/test-image-options.Dockerfile create mode 100644 scripts/verify/code-server/test-image.Dockerfile create mode 100644 scripts/verify/code-server/verify.ts create mode 100644 scripts/verify/userInit.ts diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json index 6019151..6e84fb7 100644 --- a/.config/cspell/cspell.json +++ b/.config/cspell/cspell.json @@ -35,6 +35,11 @@ "slsa", "uvx", "ndjson", - "venv" + "venv", + "cmdline", + "GLIBCXX", + "libstdc", + "noproxy", + "pgrep" ] } diff --git a/.config/dotnet/.globalconfig b/.config/dotnet/.globalconfig index 43e49c1..961e394 100644 --- a/.config/dotnet/.globalconfig +++ b/.config/dotnet/.globalconfig @@ -1 +1,2 @@ dotnet_diagnostic.IDE0005.severity=warning +dotnet_diagnostic.IDE0305.severity=warning diff --git a/.config/dotnet/Format.targets b/.config/dotnet/Format.targets index ddcc8d5..b39f3bf 100644 --- a/.config/dotnet/Format.targets +++ b/.config/dotnet/Format.targets @@ -1,9 +1,12 @@ - + - + diff --git a/.github/workflows/code-server.yml b/.github/workflows/code-server.yml new file mode 100644 index 0000000..f831f8d --- /dev/null +++ b/.github/workflows/code-server.yml @@ -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"]' diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 1258e25..00ca32c 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -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 diff --git a/features/Features/OptionsSchema.cs b/features/Features/OptionsSchema.cs index a3abd57..af8027f 100644 --- a/features/Features/OptionsSchema.cs +++ b/features/Features/OptionsSchema.cs @@ -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; @@ -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; }, diff --git a/features/code-server/Dockerfile b/features/code-server/Dockerfile new file mode 100644 index 0000000..6c27e42 --- /dev/null +++ b/features/code-server/Dockerfile @@ -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/ diff --git a/features/code-server/Feature/Feature.csproj b/features/code-server/Feature/Feature.csproj new file mode 100644 index 0000000..c4661db --- /dev/null +++ b/features/code-server/Feature/Feature.csproj @@ -0,0 +1,20 @@ + + + $(DefaultTargetFramework) + Feature + Exe + DevcontainerConfig.CodeServer.Feature + false + + + + + + + + + true + true + lld + + diff --git a/features/code-server/Feature/Install.cs b/features/code-server/Feature/Install.cs new file mode 100644 index 0000000..eb9499b --- /dev/null +++ b/features/code-server/Feature/Install.cs @@ -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")); + } +} diff --git a/features/code-server/Feature/PostStart.cs b/features/code-server/Feature/PostStart.cs new file mode 100644 index 0000000..38da603 --- /dev/null +++ b/features/code-server/Feature/PostStart.cs @@ -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 ArgumentsOf(Options options) + { + List 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 arguments, bool enabled, string flag) + { + if (enabled) + { + arguments.Add(flag); + } + } + + private static void AddRepeated(List 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); + } +} diff --git a/features/code-server/Feature/Program.cs b/features/code-server/Feature/Program.cs new file mode 100644 index 0000000..73c895c --- /dev/null +++ b/features/code-server/Feature/Program.cs @@ -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 "); + 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; +} diff --git a/features/code-server/NOTES.md b/features/code-server/NOTES.md new file mode 100644 index 0000000..d12fa06 --- /dev/null +++ b/features/code-server/NOTES.md @@ -0,0 +1,53 @@ +## Staging + +The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns. + +## Options file + +Options are read from `options.json` in the feature directory: + +```json +{ + "auth": "password", + "passwordFile": "/run/secrets/code-server-password", + "host": "127.0.0.1", + "port": 8080 +} +``` + +`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`. + +`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder. + +## Authentication + +`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy. + +## 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` + +An unset, empty or relative XDG value uses the `$HOME` path. + +## Arguments + +`args` is an array of code-server arguments appended after the option-derived arguments: + +```json +{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] } +``` + +One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing. + +## Requirements + +Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies. + +## Restart + +A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually. diff --git a/features/code-server/Options/Options.cs b/features/code-server/Options/Options.cs new file mode 100644 index 0000000..bc46a1d --- /dev/null +++ b/features/code-server/Options/Options.cs @@ -0,0 +1,164 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using DevcontainerConfig.Features; + +namespace DevcontainerConfig.CodeServer; + +public sealed class Options( + string auth = "none", + string host = "127.0.0.1", + int port = 8080, + string socket = "", + string socketMode = "0600", + string workspace = "", + string passwordFile = "", + string hashedPasswordFile = "", + string githubAuthTokenFile = "", + bool disableTelemetry = true, + bool disableUpdateCheck = true, + bool disableWorkspaceTrust = true, + bool disableFileDownloads = false, + bool disableFileUploads = false, + string[] proxyDomain = null!, + string absProxyBasePath = "", + string[] trustedOrigins = null!, + bool disableProxy = false, + string locale = "", + bool verbose = false, + string[] args = null! +) +{ + [JsonPropertyName("auth")] + [Description( + "The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access." + )] + public string Auth { get; } = auth; + + [JsonPropertyName("host")] + [Description("The address in --bind-addr when socket is empty. The default keeps the server on loopback.")] + public string Host { get; } = host; + + [JsonPropertyName("port")] + [Description("The port in --bind-addr when socket is empty.")] + public int Port { get; } = port; + + [JsonPropertyName("socket")] + [Description("An absolute Unix socket path. When set, the socket is used instead of host and port.")] + public string Socket { get; } = socket; + + [JsonPropertyName("socketMode")] + [Description("The file mode of the Unix socket, as octal digits. Used only when socket is set.")] + public string SocketMode { get; } = socketMode; + + [JsonPropertyName("workspace")] + [Description("The positional workspace path. An empty value uses the hook's current directory.")] + public string Workspace { get; } = workspace; + + [JsonPropertyName("passwordFile")] + [Description("A file read at postStart into PASSWORD. Trailing newlines are removed.")] + public string PasswordFile { get; } = passwordFile; + + [JsonPropertyName("hashedPasswordFile")] + [Description("A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile.")] + public string HashedPasswordFile { get; } = hashedPasswordFile; + + [JsonPropertyName("githubAuthTokenFile")] + [Description("A file read at postStart into GITHUB_TOKEN.")] + public string GithubAuthTokenFile { get; } = githubAuthTokenFile; + + [JsonPropertyName("disableTelemetry")] + [Description("Pass --disable-telemetry.")] + public bool DisableTelemetry { get; } = disableTelemetry; + + [JsonPropertyName("disableUpdateCheck")] + [Description("Pass --disable-update-check.")] + public bool DisableUpdateCheck { get; } = disableUpdateCheck; + + [JsonPropertyName("disableWorkspaceTrust")] + [Description("Pass --disable-workspace-trust.")] + public bool DisableWorkspaceTrust { get; } = disableWorkspaceTrust; + + [JsonPropertyName("disableFileDownloads")] + [Description("Pass --disable-file-downloads.")] + public bool DisableFileDownloads { get; } = disableFileDownloads; + + [JsonPropertyName("disableFileUploads")] + [Description("Pass --disable-file-uploads.")] + public bool DisableFileUploads { get; } = disableFileUploads; + + [JsonPropertyName("proxyDomain")] + [Description("One --proxy-domain argument per element.")] + [DefaultValue(new string[] { })] + public string[] ProxyDomain { get; } = proxyDomain ?? []; + + [JsonPropertyName("absProxyBasePath")] + [Description("Pass --abs-proxy-base-path.")] + public string AbsProxyBasePath { get; } = absProxyBasePath; + + [JsonPropertyName("trustedOrigins")] + [Description("One --trusted-origins argument per element.")] + [DefaultValue(new string[] { })] + public string[] TrustedOrigins { get; } = trustedOrigins ?? []; + + [JsonPropertyName("disableProxy")] + [Description("Pass --disable-proxy.")] + public bool DisableProxy { get; } = disableProxy; + + [JsonPropertyName("locale")] + [Description("Pass --locale.")] + public string Locale { get; } = locale; + + [JsonPropertyName("verbose")] + [Description("Pass --verbose.")] + public bool Verbose { get; } = verbose; + + [JsonPropertyName("args")] + [Description("One code-server argument per element, appended after the option-derived arguments.")] + [DefaultValue(new string[] { })] + public string[] Args { get; } = args ?? []; + + public void Validate() + { + if (Auth is not ("none" or "password")) + { + throw new FeatureException($"auth must be none or password, got {Auth}"); + } + if (Port is < 1 or > 65535) + { + throw new FeatureException($"port must be between 1 and 65535, got {Port}"); + } + if (Socket.Length > 0 && !Path.IsPathRooted(Socket)) + { + throw new FeatureException($"socket must be an absolute path, got {Socket}"); + } + if (Socket.Length > 0 && !IsOctalMode(SocketMode)) + { + throw new FeatureException($"socketMode must be one to four octal digits, got {SocketMode}"); + } + if (Socket.Length > 0 && Path.GetDirectoryName(Socket) is not { Length: > 0 }) + { + throw new FeatureException($"socket must include a parent directory, got {Socket}"); + } + ValidateElements("proxyDomain", ProxyDomain); + ValidateElements("trustedOrigins", TrustedOrigins); + ValidateElements("args", Args); + } + + private static void ValidateElements(string name, string[] values) + { + if (Array.Exists(values, value => value is null)) + { + throw new FeatureException($"{name} must not contain null elements"); + } + } + + private static bool IsOctalMode(string value) => + value.Length is >= 1 and <= 4 && value.All(digit => digit is >= '0' and <= '7'); +} + +[JsonSourceGenerationOptions( + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + RespectNullableAnnotations = true +)] +[JsonSerializable(typeof(Options), TypeInfoPropertyName = "FeatureOptions")] +public sealed partial class OptionsContext : JsonSerializerContext; diff --git a/features/code-server/Options/Options.csproj b/features/code-server/Options/Options.csproj new file mode 100644 index 0000000..19a84c5 --- /dev/null +++ b/features/code-server/Options/Options.csproj @@ -0,0 +1,11 @@ + + + $(DefaultTargetFramework) + Exe + DevcontainerConfig.CodeServer + + + + + + diff --git a/features/code-server/Options/Program.cs b/features/code-server/Options/Program.cs new file mode 100644 index 0000000..fa1472a --- /dev/null +++ b/features/code-server/Options/Program.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Nodes; +using DevcontainerConfig.CodeServer; +using DevcontainerConfig.Features; + +JsonNode schema = OptionsSchema.Of(OptionsContext.Default.FeatureOptions); +Console.WriteLine(schema.ToJsonString()); diff --git a/features/code-server/README.md b/features/code-server/README.md new file mode 100644 index 0000000..2771f4d --- /dev/null +++ b/features/code-server/README.md @@ -0,0 +1,96 @@ +# code-server + +Installs the code-server standalone release and starts the server from postStart. + +## Example Usage + +```dockerfile +FROM ghcr.io/devcontainer-config/features/code-server AS code-server + +FROM base-image +COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/ +``` + +## Options + +| Options Id | Description | Type | Default Value | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------- | +| auth | The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access. | string | none | +| host | The address in --bind-addr when socket is empty. The default keeps the server on loopback. | string | 127.0.0.1 | +| port | The port in --bind-addr when socket is empty. | integer | 8080 | +| socket | An absolute Unix socket path. When set, the socket is used instead of host and port. | string | | +| socketMode | The file mode of the Unix socket, as octal digits. Used only when socket is set. | string | 0600 | +| workspace | The positional workspace path. An empty value uses the hook's current directory. | string | | +| passwordFile | A file read at postStart into PASSWORD. Trailing newlines are removed. | string | | +| hashedPasswordFile | A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile. | string | | +| githubAuthTokenFile | A file read at postStart into GITHUB_TOKEN. | string | | +| disableTelemetry | Pass --disable-telemetry. | boolean | true | +| disableUpdateCheck | Pass --disable-update-check. | boolean | true | +| disableWorkspaceTrust | Pass --disable-workspace-trust. | boolean | true | +| disableFileDownloads | Pass --disable-file-downloads. | boolean | false | +| disableFileUploads | Pass --disable-file-uploads. | boolean | false | +| proxyDomain | One --proxy-domain argument per element. | array | [] | +| absProxyBasePath | Pass --abs-proxy-base-path. | string | | +| trustedOrigins | One --trusted-origins argument per element. | array | [] | +| disableProxy | Pass --disable-proxy. | boolean | false | +| locale | Pass --locale. | string | | +| verbose | Pass --verbose. | boolean | false | +| args | One code-server argument per element, appended after the option-derived arguments. | array | [] | + +## Staging + +The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns. + +## Options file + +Options are read from `options.json` in the feature directory: + +```json +{ + "auth": "password", + "passwordFile": "/run/secrets/code-server-password", + "host": "127.0.0.1", + "port": 8080 +} +``` + +`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`. + +`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder. + +## Authentication + +`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy. + +## 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` + +An unset, empty or relative XDG value uses the `$HOME` path. + +## Arguments + +`args` is an array of code-server arguments appended after the option-derived arguments: + +```json +{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] } +``` + +One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing. + +## Requirements + +Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies. + +## Restart + +A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually. + +--- + +_Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/code-server/feature.json). Add additional notes to a `NOTES.md`._ diff --git a/features/code-server/config.json b/features/code-server/config.json new file mode 100644 index 0000000..36de4e7 --- /dev/null +++ b/features/code-server/config.json @@ -0,0 +1,18 @@ +{ + "revision": 1, + "versions": { + "4.145.2": { + "linux-x64": { + "url": "https://github.com/coder/code-server/releases/download/v4.145.2/code-server-4.145.2-linux-amd64.tar.gz", + "sha256": "2f6db6c3e8b733dd6b3aeee83532e148b2bae2e392353ba85452d03572348962" + }, + "linux-arm64": { + "url": "https://github.com/coder/code-server/releases/download/v4.145.2/code-server-4.145.2-linux-arm64.tar.gz", + "sha256": "e867a34416e2da4a83496b09580001893e7a4e5bb58a0624c4fcd22819e7d53d" + } + } + }, + "aliases": { + "4.145.2": ["4.145", "4", "latest"] + } +} diff --git a/features/code-server/feature.json b/features/code-server/feature.json new file mode 100644 index 0000000..b341fbf --- /dev/null +++ b/features/code-server/feature.json @@ -0,0 +1,76 @@ +{ + "id": "code-server", + "name": "code-server", + "description": "Installs the code-server standalone release and starts the server from postStart.", + "image": "ghcr.io/devcontainer-config/features/code-server", + "options": { + "auth": { + "type": "string", + "default": "none", + "description": "The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access." + }, + "host": { + "type": "string", + "default": "127.0.0.1", + "description": "The address in --bind-addr when socket is empty. The default keeps the server on loopback." + }, + "port": { "type": "integer", "default": 8080, "description": "The port in --bind-addr when socket is empty." }, + "socket": { + "type": "string", + "default": "", + "description": "An absolute Unix socket path. When set, the socket is used instead of host and port." + }, + "socketMode": { + "type": "string", + "default": "0600", + "description": "The file mode of the Unix socket, as octal digits. Used only when socket is set." + }, + "workspace": { + "type": "string", + "default": "", + "description": "The positional workspace path. An empty value uses the hook's current directory." + }, + "passwordFile": { + "type": "string", + "default": "", + "description": "A file read at postStart into PASSWORD. Trailing newlines are removed." + }, + "hashedPasswordFile": { + "type": "string", + "default": "", + "description": "A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile." + }, + "githubAuthTokenFile": { + "type": "string", + "default": "", + "description": "A file read at postStart into GITHUB_TOKEN." + }, + "disableTelemetry": { "type": "boolean", "default": true, "description": "Pass --disable-telemetry." }, + "disableUpdateCheck": { "type": "boolean", "default": true, "description": "Pass --disable-update-check." }, + "disableWorkspaceTrust": { "type": "boolean", "default": true, "description": "Pass --disable-workspace-trust." }, + "disableFileDownloads": { "type": "boolean", "default": false, "description": "Pass --disable-file-downloads." }, + "disableFileUploads": { "type": "boolean", "default": false, "description": "Pass --disable-file-uploads." }, + "proxyDomain": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "One --proxy-domain argument per element." + }, + "absProxyBasePath": { "type": "string", "default": "", "description": "Pass --abs-proxy-base-path." }, + "trustedOrigins": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "One --trusted-origins argument per element." + }, + "disableProxy": { "type": "boolean", "default": false, "description": "Pass --disable-proxy." }, + "locale": { "type": "string", "default": "", "description": "Pass --locale." }, + "verbose": { "type": "boolean", "default": false, "description": "Pass --verbose." }, + "args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "One code-server argument per element, appended after the option-derived arguments." + } + } +} diff --git a/features/code-server/install b/features/code-server/install new file mode 100755 index 0000000..9b4448a --- /dev/null +++ b/features/code-server/install @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/Feature" install diff --git a/features/code-server/launch b/features/code-server/launch new file mode 100755 index 0000000..0743ebd --- /dev/null +++ b/features/code-server/launch @@ -0,0 +1,13 @@ +#!/bin/sh +# launch [args...] +# +# Runs the command with its stdio detached from the caller's: stdin reads EOF, +# and stdout and stderr append to . The postStart hook returns at once and +# the devcontainer CLI closes the hook's pipes with it, so the server must not +# hold them. +# +# exec replaces this shell, so the PID the caller records is the command's. +set -eu +log=$1 +shift +exec "$@" < /dev/null >> "$log" 2>&1 diff --git a/features/code-server/postStart b/features/code-server/postStart new file mode 100755 index 0000000..82d902b --- /dev/null +++ b/features/code-server/postStart @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/Feature" postStart diff --git a/features/dotnet/Feature/Registration.cs b/features/dotnet/Feature/Registration.cs index ae44991..3c84fbc 100644 --- a/features/dotnet/Feature/Registration.cs +++ b/features/dotnet/Feature/Registration.cs @@ -1,5 +1,3 @@ -using DevcontainerConfig.Features; - namespace DevcontainerConfig.Dotnet.Feature; internal static class Registration @@ -26,13 +24,9 @@ private static void WriteInstallLocation(string root) private static void CreateSymlink(string root) { string target = Path.Join(root, "dotnet"); - if (Directory.Exists(LinkPath)) - { - throw new FeatureException($"{LinkPath} is a directory."); - } // rename(2) replaces whatever else the path holds (absent, symlink or regular file) and never resolves the - // destination, so the link lands in one step. + // destination, so the link lands in one step; a real directory fails the rename. string temp = Path.Join(Path.GetDirectoryName(LinkPath), $".dotnet-{Guid.NewGuid():N}"); File.CreateSymbolicLink(temp, target); try diff --git a/scripts/tasks/features/architecture.ts b/scripts/tasks/features/architecture.ts index ba5f55b..d5e894d 100644 --- a/scripts/tasks/features/architecture.ts +++ b/scripts/tasks/features/architecture.ts @@ -31,3 +31,14 @@ export const isFile = async (candidate: string): Promise => { throw error; } }; + +export const isDirectory = async (candidate: string): Promise => { + try { + return (await stat(candidate)).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +}; diff --git a/scripts/tasks/features/code-server/feature.ts b/scripts/tasks/features/code-server/feature.ts new file mode 100644 index 0000000..24eff06 --- /dev/null +++ b/scripts/tasks/features/code-server/feature.ts @@ -0,0 +1,33 @@ +import type { ConfigDocument } from "@/scripts/tasks/features/config.js"; +import type { Feature, FeatureStage } from "@/scripts/tasks/features/feature.js"; + +import type { FeatureConfig } from "./generateConfig.js"; +import { configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; +import { packFeatureBinary } from "./packBinary.js"; +import { derive, ensurePayload, loadSection, payloadPresent } from "./payload.js"; + +const component = "code-server"; + +export const documentOf = (config: FeatureConfig): ConfigDocument => ({ + revision: config.revision, + namespaces: [{ name: component, versions: config.versions, aliases: config.aliases }], +}); + +const stage: FeatureStage = { subdir: ["code-server"], files: ["install", "launch", "postStart"], binary: true }; + +export const codeServer: Feature = { + id: "code-server", + configPath, + components: [component], + document: async () => documentOf(await readConfig()), + parseDocument: (text, source) => documentOf(parseConfig(text, source)), + generateConfig, + repository: () => "code-server", + stage: () => stage, + payload: { + ensure: async (_component, key, arch, force) => ensurePayload(await loadSection(key), arch, force), + present: async (_component, key, arch) => payloadPresent(derive(await loadSection(key), arch)), + }, + binary: { ensure: packFeatureBinary }, + gitTag: (_component, canonical) => `features-code-server-${canonical}`, +}; diff --git a/scripts/tasks/features/code-server/generateConfig.ts b/scripts/tasks/features/code-server/generateConfig.ts new file mode 100644 index 0000000..e663162 --- /dev/null +++ b/scripts/tasks/features/code-server/generateConfig.ts @@ -0,0 +1,59 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { projectRoot } from "@/scripts/project.js"; +import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js"; + +import type { TrackedRelease } from "../githubReleases.js"; +import { artifactOf, trackedRelease, versionAliases } from "../githubReleases.js"; + +export const configPath = path.resolve(projectRoot, "features/code-server/config.json"); + +const artifactSchema = z.object({ url: z.string(), sha256: z.string() }); + +const versionEntrySchema = z.strictObject({ + "linux-x64": artifactSchema, + "linux-arm64": artifactSchema, +}); + +const aliasTableSchema = z.record(z.string(), z.array(z.string())); + +export const featureConfigSchema = z.strictObject({ + revision: z.int().min(1), + versions: z.record(z.string(), versionEntrySchema), + aliases: aliasTableSchema, +}); + +export type VersionEntry = z.infer; +export type FeatureConfig = z.infer; + +const validateConfig = (config: FeatureConfig, source: string): void => { + checkTagNamespace({ + source, + revision: config.revision, + versions: config.versions, + aliases: config.aliases, + requiredClaims: ["latest"], + }); +}; + +const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig }); + +export const parseConfig = config.parse; +export const readConfig = config.read; + +const versionEntry = (release: TrackedRelease): VersionEntry => ({ + "linux-x64": artifactOf(release, `code-server-${release.version}-linux-amd64.tar.gz`), + "linux-arm64": artifactOf(release, `code-server-${release.version}-linux-arm64.tar.gz`), +}); + +export const generateConfig = async (): Promise => { + const release = await trackedRelease("coder/code-server"); + const document: FeatureConfig = { + revision: await config.revision(), + versions: { [release.version]: versionEntry(release) }, + aliases: { [release.version]: versionAliases(release) }, + }; + await config.write(document); +}; diff --git a/scripts/tasks/features/code-server/packBinary.ts b/scripts/tasks/features/code-server/packBinary.ts new file mode 100644 index 0000000..75ce0e4 --- /dev/null +++ b/scripts/tasks/features/code-server/packBinary.ts @@ -0,0 +1,13 @@ +import path from "node:path"; + +import { projectRoot, workspaces } from "@/scripts/project.js"; + +import type { Rid } from "../architecture.js"; +import { ensureFeatureBinary } from "../featureBinary.js"; + +const csproj = path.resolve(projectRoot, "features/code-server/Feature/Feature.csproj"); + +const packPath = (rid: Rid): string => path.join(workspaces, "bin/code-server/Feature", rid); + +export const packFeatureBinary = (rid: Rid, force: boolean): Promise => + ensureFeatureBinary(csproj, packPath(rid), rid, force); diff --git a/scripts/tasks/features/code-server/payload.ts b/scripts/tasks/features/code-server/payload.ts new file mode 100644 index 0000000..9e46bed --- /dev/null +++ b/scripts/tasks/features/code-server/payload.ts @@ -0,0 +1,47 @@ +import type { DockerArch } from "../architecture.js"; +import { isDirectory, ridFor } from "../architecture.js"; +import { payloadPath } from "../paths.js"; +import type { Tarball } from "../tarball.js"; +import { stageTarball } from "../tarball.js"; +import type { FeatureConfig, VersionEntry } from "./generateConfig.js"; +import { configPath, readConfig } from "./generateConfig.js"; + +export interface PayloadSection { + key: string; + entry: VersionEntry; +} + +export interface Payload { + cache: string; + tarball: Tarball; +} + +export const sectionOf = (config: FeatureConfig, key: string): PayloadSection => { + const entry = config.versions[key]; + if (entry === undefined) { + throw new Error(`Version ${key} is not in ${configPath}`); + } + return { key, entry }; +}; + +export const loadSection = async (key: string): Promise => sectionOf(await readConfig(), key); + +export const derive = (section: PayloadSection, arch: DockerArch): Payload => { + const cache = payloadPath("code-server", "code-server", section.key, arch); + const archive = section.entry[ridFor(arch)]; + return { + cache, + tarball: { cache, url: archive.url, sha256: archive.sha256 }, + }; +}; + +export const payloadPresent = (payload: Payload): Promise => isDirectory(payload.cache); + +export const ensurePayload = async (section: PayloadSection, arch: DockerArch, force: boolean): Promise => { + const payload = derive(section, arch); + if (!force && (await payloadPresent(payload))) { + console.log(`Payload already present at ${payload.cache}; skipping fetch`); + return; + } + await stageTarball(payload.tarball); +}; diff --git a/scripts/tasks/features/config.ts b/scripts/tasks/features/config.ts index 61be1a2..53b1d10 100644 --- a/scripts/tasks/features/config.ts +++ b/scripts/tasks/features/config.ts @@ -1,4 +1,7 @@ +import { readFile, writeFile } from "node:fs/promises"; + import * as prettier from "prettier"; +import { z } from "zod"; import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; @@ -103,3 +106,46 @@ export const checkTagNamespace = (namespace: TagNamespace): void => { export const formatConfig = async (document: unknown, configPath: string): Promise => prettier.format(JSON.stringify(document, null, 2), { ...prettierOptions, filepath: configPath }); + +export interface ConfigFile { + parse: (text: string, source: string) => T; + read: () => Promise; + /** The document's revision on disk, 1 when the file does not exist yet. */ + revision: () => Promise; + /** Validates the document, then writes it canonically. */ + write: (config: T) => Promise; +} + +export const configFile = >(options: { + path: string; + schema: S; + validate: (config: z.infer, source: string) => void; +}): ConfigFile> => { + const parse = (text: string, source: string): z.infer => { + const result = options.schema.safeParse(JSON.parse(text)); + if (!result.success) { + throw new Error(`${source}: ${z.prettifyError(result.error)}`); + } + options.validate(result.data, source); + return result.data; + }; + const read = async (): Promise> => parse(await readFile(options.path, "utf-8"), options.path); + return { + parse, + read, + revision: async () => { + try { + return (await read()).revision; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return 1; + } + throw error; + } + }, + write: async (config) => { + options.validate(config, options.path); + await writeFile(options.path, await formatConfig(config, options.path)); + }, + }; +}; diff --git a/scripts/tasks/features/dotnet/generateConfig.ts b/scripts/tasks/features/dotnet/generateConfig.ts index 9df887b..97a03ed 100644 --- a/scripts/tasks/features/dotnet/generateConfig.ts +++ b/scripts/tasks/features/dotnet/generateConfig.ts @@ -1,10 +1,9 @@ -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { projectRoot } from "@/scripts/project.js"; -import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js"; +import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js"; const releasesIndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json"; export const configPath = path.resolve(projectRoot, "features/dotnet/config.json"); @@ -208,40 +207,21 @@ const validateConfig = (config: FeatureConfig, source: string): void => { } }; -export const parseConfig = (text: string, source: string): FeatureConfig => { - const result = featureConfigSchema.safeParse(JSON.parse(text)); - if (!result.success) { - throw new Error(`${source}: ${z.prettifyError(result.error)}`); - } - validateConfig(result.data, source); - return result.data; -}; +const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig }); -export const readConfig = async (): Promise => - parseConfig(await readFile(configPath, "utf-8"), configPath); - -const readRevision = async (): Promise => { - try { - return (await readConfig()).revision; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return 1; - } - throw error; - } -}; +export const parseConfig = config.parse; +export const readConfig = config.read; export const generateConfig = async (): Promise => { const index = await fetchJson(releasesIndexUrl, releasesIndexSchema); const channelData = await fetchChannelData(selectChannels(index)); - const config: FeatureConfig = { - revision: await readRevision(), + const document: FeatureConfig = { + revision: await config.revision(), components: { sdk: resolveComponentData("sdk", channelData), runtime: resolveComponentData("runtime", channelData), aspnet: resolveComponentData("aspnet", channelData), }, }; - validateConfig(config, configPath); - await writeFile(configPath, await formatConfig(config, configPath)); + await config.write(document); }; diff --git a/scripts/tasks/features/featureBinary.ts b/scripts/tasks/features/featureBinary.ts index 7ffa6f9..d24a22a 100644 --- a/scripts/tasks/features/featureBinary.ts +++ b/scripts/tasks/features/featureBinary.ts @@ -1,12 +1,35 @@ import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; -import { extractTar } from "@/scripts/tasks/features/tar.js"; +import { project$$ } from "@/scripts/shell.js"; -import type { DockerArch } from "./architecture.js"; +import type { DockerArch, Rid } from "./architecture.js"; import { dockerArchOptions, hostArch, isFile, ridFor } from "./architecture.js"; import type { Feature } from "./feature.js"; import { requireBinary } from "./feature.js"; +import { extractTar } from "./tar.js"; + +export const ensureFeatureBinary = async ( + csproj: string, + directory: string, + rid: Rid, + force: boolean, +): Promise => { + const binaryPath = path.join(directory, "Feature"); + if (!force && (await isFile(binaryPath))) { + return binaryPath; + } + await project$$`dotnet publish ${[ + ...["--configuration", "Release"], + ...["--runtime", rid], + csproj, + `-p:PublishDir=${directory}/`, + ]}`; + if (!(await isFile(binaryPath))) { + throw new Error(`dotnet publish did not produce ${binaryPath}`); + } + return binaryPath; +}; const localBinary = async (binary: NonNullable, arch: DockerArch): Promise => { if (arch !== hostArch()) { diff --git a/scripts/tasks/features/features.ts b/scripts/tasks/features/features.ts index ce6b38f..ac2ba9f 100644 --- a/scripts/tasks/features/features.ts +++ b/scripts/tasks/features/features.ts @@ -1,6 +1,12 @@ +import { codeServer } from "./code-server/feature.js"; import { dotnet } from "./dotnet/feature.js"; import type { Feature } from "./feature.js"; import { ripgrep } from "./ripgrep/feature.js"; import { uv } from "./uv/feature.js"; -export const features: Readonly> = { dotnet, ripgrep, uv }; +export const features: Readonly> = { + "code-server": codeServer, + dotnet, + ripgrep, + uv, +}; diff --git a/scripts/tasks/features/githubReleases.ts b/scripts/tasks/features/githubReleases.ts new file mode 100644 index 0000000..f395ae2 --- /dev/null +++ b/scripts/tasks/features/githubReleases.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +export type Version = readonly [number, number, number]; + +export interface ReleaseAsset { + name: string; + url: string; + digest: string | null; +} + +export interface TrackedRelease { + /** The release tag with its `v` prefix removed. */ + version: string; + number: Version; + assets: readonly ReleaseAsset[]; +} + +const releaseSchema = z.object({ + tag_name: z.string(), + draft: z.boolean(), + prerelease: z.boolean(), + assets: z.array( + z.object({ + name: z.string(), + browser_download_url: z.string(), + digest: z.string().nullish(), + }), + ), +}); + +const releasesPageSchema = z.array(releaseSchema); + +const sha256Digest = /^sha256:([0-9a-f]{64})$/u; +const versionTag = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +const versionOf = (tag: string): { version: string; number: Version } | undefined => { + const match = versionTag.exec(tag); + if (match === null) { + return undefined; + } + const [, major, minor, patch] = match; + return { version: match[0].replace(/^v/u, ""), number: [Number(major), Number(minor), Number(patch)] }; +}; + +const compare = (left: Version, right: Version): number => { + for (const [index, part] of left.entries()) { + if (part !== right[index]) { + return part - right[index]; + } + } + return 0; +}; + +const fetchReleases = async (repository: string): Promise> => { + const releases: z.infer = []; + for (let page = 1; ; page++) { + const url = `https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`; + const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } }); + if (!response.ok) { + throw new Error(`GET ${url} → HTTP ${response.status}`); + } + const result = releasesPageSchema.safeParse(await response.json()); + if (!result.success) { + throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`); + } + if (result.data.length === 0) { + return releases; + } + releases.push(...result.data); + } +}; + +/** The newest stable release of `repository` at or above `minimum`, by semantic version. */ +export const trackedRelease = async (repository: string, minimum?: Version): Promise => { + const releases = await fetchReleases(repository); + const candidates = releases.flatMap((release): TrackedRelease[] => { + if (release.draft || release.prerelease) { + return []; + } + const parsed = versionOf(release.tag_name); + if (parsed === undefined || (minimum !== undefined && compare(parsed.number, minimum) < 0)) { + return []; + } + return [ + { + ...parsed, + assets: release.assets.map((asset) => ({ + name: asset.name, + url: asset.browser_download_url, + digest: asset.digest ?? null, + })), + }, + ]; + }); + if (candidates.length === 0) { + const limit = minimum === undefined ? "" : ` at or above ${minimum.join(".")}`; + throw new Error(`No stable release of ${repository}${limit}`); + } + return candidates.reduce((best, candidate) => (compare(candidate.number, best.number) > 0 ? candidate : best)); +}; + +/** The url and sha256 digest of the one release asset named `name`. */ +export const artifactOf = (release: TrackedRelease, name: string): { url: string; sha256: string } => { + const matches = release.assets.filter((asset) => asset.name === name); + if (matches.length !== 1) { + throw new Error(`Release ${release.version}: expected one asset ${name}, found ${matches.length}`); + } + const [asset] = matches; + const match = sha256Digest.exec(asset.digest ?? ""); + if (match === null) { + throw new Error(`Release ${release.version}: asset ${name} has no sha256 digest (${asset.digest ?? "none"})`); + } + return { url: asset.url, sha256: match[1] }; +}; + +/** The `.`, `` and `latest` names a release claims. */ +export const versionAliases = (release: TrackedRelease): string[] => { + const [major, minor] = release.number; + return [`${major}.${minor}`, `${major}`, "latest"]; +}; diff --git a/scripts/tasks/features/ripgrep/generateConfig.ts b/scripts/tasks/features/ripgrep/generateConfig.ts index 6c72c23..70ea1eb 100644 --- a/scripts/tasks/features/ripgrep/generateConfig.ts +++ b/scripts/tasks/features/ripgrep/generateConfig.ts @@ -1,36 +1,20 @@ -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { projectRoot } from "@/scripts/project.js"; -import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js"; +import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js"; + +import type { TrackedRelease, Version } from "../githubReleases.js"; +import { artifactOf, trackedRelease, versionAliases } from "../githubReleases.js"; -const releasesUrl = "https://api.github.com/repos/BurntSushi/ripgrep/releases"; export const configPath = path.resolve(projectRoot, "features/ripgrep/config.json"); export const targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] as const; export type Target = (typeof targets)[number]; -type Version = readonly [number, number, number]; - const minimumVersion: Version = [15, 2, 0]; -const releaseSchema = z.object({ - tag_name: z.string(), - draft: z.boolean(), - prerelease: z.boolean(), - assets: z.array( - z.object({ - name: z.string(), - browser_download_url: z.string(), - digest: z.string().nullish(), - }), - ), -}); - -const releasesPageSchema = z.array(releaseSchema); - const artifactSchema = z.object({ url: z.string(), sha256: z.string() }); const versionEntrySchema = z.strictObject({ @@ -46,95 +30,9 @@ export const featureConfigSchema = z.strictObject({ aliases: aliasTableSchema, }); -export type Release = z.infer; -export type Artifact = z.infer; export type VersionEntry = z.infer; export type FeatureConfig = z.infer; -interface Candidate { - tag: string; - number: Version; - release: Release; -} - -const versionOf = (tag: string): Version | undefined => { - const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag); - if (match === null) { - return undefined; - } - return [Number(match[1]), Number(match[2]), Number(match[3])]; -}; - -const compare = (left: Version, right: Version): number => { - for (const [index, part] of left.entries()) { - if (part !== right[index]) { - return part - right[index]; - } - } - return 0; -}; - -const fetchReleases = async (): Promise => { - const releases: Release[] = []; - for (let page = 1; ; page++) { - const url = `${releasesUrl}?per_page=100&page=${page}`; - const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } }); - if (!response.ok) { - throw new Error(`GET ${url} → HTTP ${response.status}`); - } - const result = releasesPageSchema.safeParse(await response.json()); - if (!result.success) { - throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`); - } - if (result.data.length === 0) { - return releases; - } - releases.push(...result.data); - } -}; - -const trackedRelease = async (): Promise => { - const releases = await fetchReleases(); - const candidates = releases.flatMap((release) => { - if (release.draft || release.prerelease) { - return []; - } - const number = versionOf(release.tag_name); - if (number === undefined || compare(number, minimumVersion) < 0) { - return []; - } - return [{ tag: release.tag_name, number, release }]; - }); - if (candidates.length === 0) { - throw new Error(`No stable release at or above ${minimumVersion.join(".")}`); - } - return candidates.reduce((best, candidate) => (compare(candidate.number, best.number) > 0 ? candidate : best)); -}; - -const resolveArtifact = (candidate: Candidate, target: Target): Artifact => { - const name = `ripgrep-${candidate.tag}-${target}.tar.gz`; - const matches = candidate.release.assets.filter((asset) => asset.name === name); - if (matches.length !== 1) { - throw new Error(`Release ${candidate.tag}: expected one asset ${name}, found ${matches.length}`); - } - const [asset] = matches; - const match = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? ""); - if (match === null) { - throw new Error(`Release ${candidate.tag}: asset ${name} has no sha256 digest (${asset.digest ?? "none"})`); - } - return { url: asset.browser_download_url, sha256: match[1] }; -}; - -const versionEntry = (candidate: Candidate): VersionEntry => ({ - "x86_64-unknown-linux-musl": resolveArtifact(candidate, "x86_64-unknown-linux-musl"), - "aarch64-unknown-linux-musl": resolveArtifact(candidate, "aarch64-unknown-linux-musl"), -}); - -const versionAliases = (candidate: Candidate): string[] => { - const [major, minor] = candidate.number; - return [`${major}.${minor}`, `${major}`, "latest"]; -}; - const validateConfig = (config: FeatureConfig, source: string): void => { checkTagNamespace({ source, @@ -145,36 +43,22 @@ const validateConfig = (config: FeatureConfig, source: string): void => { }); }; -export const parseConfig = (text: string, source: string): FeatureConfig => { - const result = featureConfigSchema.safeParse(JSON.parse(text)); - if (!result.success) { - throw new Error(`${source}: ${z.prettifyError(result.error)}`); - } - validateConfig(result.data, source); - return result.data; -}; +const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig }); -export const readConfig = async (): Promise => - parseConfig(await readFile(configPath, "utf-8"), configPath); - -const readRevision = async (): Promise => { - try { - return (await readConfig()).revision; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return 1; - } - throw error; - } -}; +export const parseConfig = config.parse; +export const readConfig = config.read; + +const versionEntry = (release: TrackedRelease): VersionEntry => ({ + "x86_64-unknown-linux-musl": artifactOf(release, `ripgrep-${release.version}-x86_64-unknown-linux-musl.tar.gz`), + "aarch64-unknown-linux-musl": artifactOf(release, `ripgrep-${release.version}-aarch64-unknown-linux-musl.tar.gz`), +}); export const generateConfig = async (): Promise => { - const candidate = await trackedRelease(); - const config: FeatureConfig = { - revision: await readRevision(), - versions: { [candidate.tag]: versionEntry(candidate) }, - aliases: { [candidate.tag]: versionAliases(candidate) }, + const release = await trackedRelease("BurntSushi/ripgrep", minimumVersion); + const document: FeatureConfig = { + revision: await config.revision(), + versions: { [release.version]: versionEntry(release) }, + aliases: { [release.version]: versionAliases(release) }, }; - validateConfig(config, configPath); - await writeFile(configPath, await formatConfig(config, configPath)); + await config.write(document); }; diff --git a/scripts/tasks/features/ripgrep/payload.ts b/scripts/tasks/features/ripgrep/payload.ts index 6940f56..0902eb3 100644 --- a/scripts/tasks/features/ripgrep/payload.ts +++ b/scripts/tasks/features/ripgrep/payload.ts @@ -1,13 +1,11 @@ -import { mkdir, rename, rm } from "node:fs/promises"; import path from "node:path"; -import { $$ } from "@/scripts/shell.js"; -import { download } from "@/scripts/tasks/features/download.js"; - import type { DockerArch } from "../architecture.js"; import { isFile } from "../architecture.js"; import { payloadPath } from "../paths.js"; -import type { Artifact, FeatureConfig, Target, VersionEntry } from "./generateConfig.js"; +import type { Tarball } from "../tarball.js"; +import { stageTarball } from "../tarball.js"; +import type { FeatureConfig, Target, VersionEntry } from "./generateConfig.js"; import { configPath, readConfig } from "./generateConfig.js"; const targets: Record = { @@ -32,12 +30,9 @@ export interface PayloadSection { } export interface Payload { - key: string; - target: string; cache: string; present: string; - top: string; - archive: Artifact; + tarball: Tarball; } export const sectionOf = (config: FeatureConfig, key: string): PayloadSection => { @@ -53,13 +48,16 @@ export const loadSection = async (key: string): Promise => secti export const derive = (section: PayloadSection, arch: DockerArch): Payload => { const target = targets[arch]; const cache = payloadPath("ripgrep", "ripgrep", section.key, arch); + const archive = section.entry[target]; return { - key: section.key, - target, cache, present: path.join(cache, "rg"), - top: `ripgrep-${section.key}-${target}`, - archive: section.entry[target], + tarball: { + cache, + url: archive.url, + sha256: archive.sha256, + extract: { top: `ripgrep-${section.key}-${target}`, entries }, + }, }; }; @@ -71,30 +69,5 @@ export const ensurePayload = async (section: PayloadSection, arch: DockerArch, f console.log(`Payload already present at ${payload.cache}; skipping fetch`); return; } - const tempPath = `${payload.cache}.tmp`; - await rm(tempPath, { recursive: true, force: true }); - const archivePath = path.join(tempPath, "archive.tar.gz"); - const extractedPath = path.join(tempPath, "extracted"); - await mkdir(extractedPath, { recursive: true }); - const actual = await download(payload.archive.url, archivePath); - if (actual !== payload.archive.sha256) { - throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`); - } - await $$`tar ${[ - "-xzf", - archivePath, - "-C", - extractedPath, - "--strip-components=1", - ...entries.map((entry) => `${payload.top}/${entry}`), - ]}`; - for (const entry of entries) { - if (!(await isFile(path.join(extractedPath, entry)))) { - throw new Error(`${payload.archive.url}: the archive has no ${entry}`); - } - } - await rm(payload.cache, { recursive: true, force: true }); - await rename(extractedPath, payload.cache); - await rm(tempPath, { recursive: true, force: true }); - console.log(`Payload written to ${payload.cache}`); + await stageTarball(payload.tarball); }; diff --git a/scripts/tasks/features/tarball.ts b/scripts/tasks/features/tarball.ts new file mode 100644 index 0000000..e6c3b52 --- /dev/null +++ b/scripts/tasks/features/tarball.ts @@ -0,0 +1,41 @@ +import { mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; + +import { $$ } from "@/scripts/shell.js"; + +import { isFile } from "./architecture.js"; +import { download } from "./download.js"; + +export interface Tarball { + /** The payload directory the extracted archive replaces in one rename. */ + cache: string; + url: string; + sha256: string; + /** The members to extract, under the archive's single top directory; every member when absent. */ + extract?: { top: string; entries: readonly string[] }; +} + +/** Fetches and extracts `tarball`; its cache is replaced only once the archive has passed every check. */ +export const stageTarball = async (tarball: Tarball): Promise => { + const tempPath = `${tarball.cache}.tmp`; + await rm(tempPath, { recursive: true, force: true }); + const archivePath = path.join(tempPath, "archive.tar.gz"); + const extractedPath = path.join(tempPath, "extracted"); + await mkdir(extractedPath, { recursive: true }); + const actual = await download(tarball.url, archivePath); + if (actual !== tarball.sha256) { + throw new Error(`${tarball.url}: sha256 ${actual} does not match the configured ${tarball.sha256}`); + } + const extract = tarball.extract; + const members = extract === undefined ? [] : extract.entries.map((entry) => `${extract.top}/${entry}`); + await $$`tar ${["-xzf", archivePath, "-C", extractedPath, "--strip-components=1", ...members]}`; + for (const entry of extract?.entries ?? []) { + if (!(await isFile(path.join(extractedPath, entry)))) { + throw new Error(`${tarball.url}: the archive has no ${entry}`); + } + } + await rm(tarball.cache, { recursive: true, force: true }); + await rename(extractedPath, tarball.cache); + await rm(tempPath, { recursive: true, force: true }); + console.log(`Payload written to ${tarball.cache}`); +}; diff --git a/scripts/tasks/features/uv/feature.ts b/scripts/tasks/features/uv/feature.ts index b1f63ab..76eb942 100644 --- a/scripts/tasks/features/uv/feature.ts +++ b/scripts/tasks/features/uv/feature.ts @@ -3,7 +3,7 @@ import type { Feature, FeatureStage } from "@/scripts/tasks/features/feature.js" import type { Component, FeatureConfig } from "./generateConfig.js"; import { components, componentSchema, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; -import { ensureFeatureBinary } from "./packBinary.js"; +import { packFeatureBinary } from "./packBinary.js"; import { derive, ensurePayload, payloadPresent, sectionOf } from "./payload.js"; const repositories: Record = { uv: "uv", python: "uv/python" }; @@ -37,6 +37,6 @@ export const uv: Feature = { present: async (component, key, arch) => payloadPresent(derive(sectionOf(await readConfig(), componentSchema.parse(component), key), arch)), }, - binary: { ensure: ensureFeatureBinary }, + binary: { ensure: packFeatureBinary }, gitTag: (component, canonical) => `features-uv-${componentSchema.parse(component)}-${canonical}`, }; diff --git a/scripts/tasks/features/uv/generateConfig.ts b/scripts/tasks/features/uv/generateConfig.ts index e5cc74e..5f16a30 100644 --- a/scripts/tasks/features/uv/generateConfig.ts +++ b/scripts/tasks/features/uv/generateConfig.ts @@ -1,10 +1,9 @@ -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { projectRoot } from "@/scripts/project.js"; -import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js"; +import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js"; import type { DockerArch, Rid } from "../architecture.js"; import { dockerArchOptions, ridFor, rids } from "../architecture.js"; @@ -226,35 +225,17 @@ const validateConfig = (config: FeatureConfig, source: string): void => { validatePython(config, `${source} component python`); }; -export const parseConfig = (text: string, source: string): FeatureConfig => { - const result = featureConfigSchema.safeParse(JSON.parse(text)); - if (!result.success) { - throw new Error(`${source}: ${z.prettifyError(result.error)}`); - } - validateConfig(result.data, source); - return result.data; -}; - -export const readConfig = async (): Promise => - parseConfig(await readFile(configPath, "utf-8"), configPath); +const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig }); -const readRevision = async (): Promise => { - try { - return (await readConfig()).revision; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return 1; - } - throw error; - } -}; +export const parseConfig = config.parse; +export const readConfig = config.read; export const generateConfig = async (): Promise => { const uvRelease = trackedUvRelease(parseIndex(await fetchText(uvIndexUrl), uvIndexUrl)); const pythonReleases = parseIndex(await fetchText(pythonIndexUrl), pythonIndexUrl); const [major, minor] = uvRelease.version.split("."); - const config: FeatureConfig = { - revision: await readRevision(), + const document: FeatureConfig = { + revision: await config.revision(), components: { uv: { versions: { @@ -271,6 +252,5 @@ export const generateConfig = async (): Promise => { python: await pythonVersions(pythonReleases), }, }; - validateConfig(config, configPath); - await writeFile(configPath, await formatConfig(config, configPath)); + await config.write(document); }; diff --git a/scripts/tasks/features/uv/packBinary.ts b/scripts/tasks/features/uv/packBinary.ts index d243ff2..a360ab4 100644 --- a/scripts/tasks/features/uv/packBinary.ts +++ b/scripts/tasks/features/uv/packBinary.ts @@ -1,24 +1,13 @@ import path from "node:path"; -import { project$$ } from "@/scripts/shell.js"; +import { projectRoot, workspaces } from "@/scripts/project.js"; import type { Rid } from "../architecture.js"; -import { isFile } from "../architecture.js"; -import { packPath } from "./paths.js"; +import { ensureFeatureBinary } from "../featureBinary.js"; -export const ensureFeatureBinary = async (rid: Rid, force: boolean): Promise => { - const binaryPath = path.join(packPath(rid), "Feature"); - if (!force && (await isFile(binaryPath))) { - return binaryPath; - } - await project$$`dotnet publish ${[ - ...["--configuration", "Release"], - ...["--runtime", rid], - "features/uv/Feature/Feature.csproj", - `-p:PublishDir=${packPath(rid)}/`, - ]}`; - if (!(await isFile(binaryPath))) { - throw new Error(`dotnet publish did not produce ${binaryPath}`); - } - return binaryPath; -}; +const csproj = path.resolve(projectRoot, "features/uv/Feature/Feature.csproj"); + +const packPath = (rid: Rid): string => path.join(workspaces, "bin/uv/Feature", rid); + +export const packFeatureBinary = (rid: Rid, force: boolean): Promise => + ensureFeatureBinary(csproj, packPath(rid), rid, force); diff --git a/scripts/tasks/features/uv/paths.ts b/scripts/tasks/features/uv/paths.ts index b83c33a..4c1950d 100644 --- a/scripts/tasks/features/uv/paths.ts +++ b/scripts/tasks/features/uv/paths.ts @@ -1,8 +1,4 @@ -import path from "node:path"; - -import { workspaces } from "@/scripts/project.js"; - -import type { DockerArch, Rid } from "../architecture.js"; +import type { DockerArch } from "../architecture.js"; export const muslTriples: Record = { amd64: "x86_64-unknown-linux-musl", @@ -17,5 +13,3 @@ export const gnuTriples: Record = { const machines: Record = { amd64: "x86_64", arm64: "aarch64" }; export const machineOf = (arch: DockerArch): string => machines[arch]; - -export const packPath = (rid: Rid): string => path.join(workspaces, "bin/uv/Feature", rid); diff --git a/scripts/tasks/features/uv/payload.ts b/scripts/tasks/features/uv/payload.ts index ae28b2e..2b00b96 100644 --- a/scripts/tasks/features/uv/payload.ts +++ b/scripts/tasks/features/uv/payload.ts @@ -1,8 +1,8 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { $$ } from "@/scripts/shell.js"; import { download } from "@/scripts/tasks/features/download.js"; +import { stageTarball } from "@/scripts/tasks/features/tarball.js"; import type { DockerArch } from "../architecture.js"; import { isFile, ridFor } from "../architecture.js"; @@ -186,35 +186,13 @@ export function derive(section: PayloadSection, arch: DockerArch): Payload { export const payloadPresent = (payload: Payload): Promise => isFile(payload.present); -const fetchUvPayload = async (payload: UvPayload): Promise => { - const tempPath = `${payload.cache}.tmp`; - await rm(tempPath, { recursive: true, force: true }); - const archivePath = path.join(tempPath, "archive.tar.gz"); - const extractedPath = path.join(tempPath, "extracted"); - await mkdir(extractedPath, { recursive: true }); - const actual = await download(payload.archive.url, archivePath); - if (actual !== payload.archive.sha256) { - throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`); - } - const top = `uv-${payload.triple}`; - await $$`tar ${[ - "-xzf", - archivePath, - "-C", - extractedPath, - "--strip-components=1", - ...uvBinaries.map((binary) => `${top}/${binary}`), - ]}`; - for (const binary of uvBinaries) { - if (!(await isFile(path.join(extractedPath, binary)))) { - throw new Error(`${payload.archive.url}: the archive has no ${binary}`); - } - } - await rm(payload.cache, { recursive: true, force: true }); - await rename(extractedPath, payload.cache); - await rm(tempPath, { recursive: true, force: true }); - console.log(`Payload written to ${payload.cache}`); -}; +const fetchUvPayload = async (payload: UvPayload): Promise => + stageTarball({ + cache: payload.cache, + url: payload.archive.url, + sha256: payload.archive.sha256, + extract: { top: `uv-${payload.triple}`, entries: uvBinaries }, + }); const fetchPythonPayload = async (payload: PythonPayload): Promise => { const tempPath = `${payload.cache}.tmp`; diff --git a/scripts/tasks/imageFeatures.ts b/scripts/tasks/imageFeatures.ts index cdfedf6..0e5f29b 100644 --- a/scripts/tasks/imageFeatures.ts +++ b/scripts/tasks/imageFeatures.ts @@ -11,9 +11,12 @@ import { projectRoot } from "@/scripts/project.js"; const featuresPath = path.resolve(projectRoot, "features"); +const optionDefaultSchema = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]); + const optionSchema = z.strictObject({ type: z.string(), - default: z.union([z.string(), z.number(), z.boolean()]), + items: z.strictObject({ type: z.union([z.string(), z.array(z.string())]) }).optional(), + default: optionDefaultSchema, description: z.string(), }); @@ -115,6 +118,9 @@ const optionsOf = async (id: string): Promise => { return result.data.properties; }; +const defaultText = (value: z.infer): string => + Array.isArray(value) ? JSON.stringify(value) : String(value); + const optionsTable = (options: Options): string => { const entries = Object.entries(options); if (entries.length === 0) { @@ -126,7 +132,8 @@ const optionsTable = (options: Options): string => { "| Options Id | Description | Type | Default Value |", "| ---------- | ----------- | ---- | ------------- |", ...entries.map( - ([optionId, option]) => `| ${optionId} | ${option.description} | ${option.type} | ${option.default} |`, + ([optionId, option]) => + `| ${optionId} | ${option.description} | ${option.type} | ${defaultText(option.default)} |`, ), ].join("\n"); }; diff --git a/scripts/verify/code-server/assets/options-unknown.json b/scripts/verify/code-server/assets/options-unknown.json new file mode 100644 index 0000000..5994354 --- /dev/null +++ b/scripts/verify/code-server/assets/options-unknown.json @@ -0,0 +1,3 @@ +{ + "unknown": true +} diff --git a/scripts/verify/code-server/scenarios.ts b/scripts/verify/code-server/scenarios.ts new file mode 100644 index 0000000..03df604 --- /dev/null +++ b/scripts/verify/code-server/scenarios.ts @@ -0,0 +1,241 @@ +import { writeFile } from "node:fs/promises"; +import path, { posix } from "node:path"; + +import { z } from "zod"; + +import { selectionSchema } from "../arguments.js"; +import type { TestImage, TestImageFile, Workspace } from "../lifecycle.js"; +import { lifecycle } from "../lifecycle.js"; +import type { Exec, Scenario, ScenarioVariant } from "../run.js"; +import type { Selection, SelectionTarget } from "./selection.js"; + +const names = ["default", "password", "socket", "args", "smoke"] as const; +type ScenarioName = (typeof names)[number]; + +export const scenarioSelectionSchema = selectionSchema( + z.enum(names), + names, + names.filter((name) => name !== "smoke"), +); + +const testImagePrefix = "features-verify-code-server"; +const featureDir = "/opt/devcontainer-config/features/code-server"; +const installedDir = `${featureDir}/code-server`; +const nodePath = `${installedDir}/lib/node`; +const postStart = "/opt/devcontainer-config/features-run postStart"; + +interface ScenarioContext { + selection: Selection; + target: SelectionTarget | undefined; + root: string; + workspace: Workspace; +} + +const testImageOf = (name: string, selection: Selection, files?: readonly TestImageFile[]): TestImage => ({ + tag: `${testImagePrefix}:${name}-${selection.key}`, + components: [selection], + dockerfile: path.join( + import.meta.dirname, + files === undefined ? "test-image.Dockerfile" : "test-image-options.Dockerfile", + ), + args: [`CODE_SERVER_IMAGE_REF=${selection.ref}`], + files, +}); + +const optionsImage = async (name: string, root: string, selection: Selection, options: unknown): Promise => { + const source = path.join(root, `${name}-options.json`); + await writeFile(source, `${JSON.stringify(options, null, 2)}\n`); + return testImageOf(name, selection, [{ name: "options.json", source }]); +}; + +const healthCheck = (selection: Selection, url: string, socketPath?: string): Exec => { + const target = socketPath === undefined ? `"${url}"` : `--unix-socket "${socketPath}" "${url}"`; + const script = [ + "deadline=$(( $(date +%s) + 30 ))", + 'while [ "$(date +%s)" -lt "$deadline" ]; do', + ` status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 --noproxy '*' ${target} || true)`, + ' if [ "$status" = 200 ]; then echo "$status"; exit 0; fi', + " sleep 0.1", + "done", + 'echo "last status: $status"', + `tail -n 50 "${selection.logPath}"`, + "exit 1", + ].join("\n"); + return { command: ["sh", "-c", script], expect: { stdout: ["200"] } }; +}; + +const version = (key: string): Exec => ({ + command: ["sh", "-c", "code-server --version | awk 'NR == 1 { print $1 }'"], + expect: { stdout: [key] }, +}); + +const installed = (): readonly Exec[] => [ + { command: ["sh", "-c", "command -v code-server"], expect: { stdout: ["/usr/local/bin/code-server"] } }, + { + command: ["readlink", "-f", "/usr/local/bin/code-server"], + expect: { stdout: [`${installedDir}/bin/code-server`] }, + }, +]; + +const logLine = (selection: Selection, line: string): Exec => ({ + command: ["sh", "-c", `grep -Fq -- '${line}' "${selection.logPath}"`], + expect: { stdout: [] }, +}); + +const logCount = (selection: Selection, text: string, count: number): Exec => ({ + command: ["sh", "-c", `grep -Fc -- '${text}' "${selection.logPath}"`], + expect: { stdout: [String(count)] }, +}); + +const processUser = (selection: Selection): Exec => ({ + command: ["sh", "-c", `pid=$(cat "${selection.pidPath}"); ps -o user= -p "$pid"`], + expect: { stdout: ["verify"], fields: true }, +}); + +const processCount = (): Exec => ({ + command: ["sh", "-c", `pgrep -P 1 -c -f '^${nodePath} '`], + expect: { stdout: ["1"] }, +}); + +const reused = (selection: Selection): Exec => ({ + command: [ + "sh", + "-c", + [ + `pid=$(cat "${selection.pidPath}")`, + "output=$(mktemp)", + `if ! ${postStart} > "$output" 2>&1; then`, + ' cat "$output"', + " exit 1", + "fi", + 'rm -f "$output"', + `now=$(cat "${selection.pidPath}")`, + 'if [ "$pid" != "$now" ]; then echo "pid changed from $pid to $now"; exit 1; fi', + `count=$(pgrep -P 1 -c -f '^${nodePath} ')`, + 'if [ "$count" != 1 ]; then echo "process count: $count"; exit 1; fi', + "echo reused", + ].join("\n"), + ], + expect: { stdout: ["reused"] }, +}); + +const stopped = (selection: Selection, socketPath: string): Exec => ({ + command: [ + "sh", + "-c", + [ + `pid=$(cat "${selection.pidPath}")`, + 'kill "$pid"', + "deadline=$(( $(date +%s) + 10 ))", + `while [ -e "${socketPath}" ] && [ "$(date +%s)" -lt "$deadline" ]; do sleep 0.05; done`, + `if [ -e "${socketPath}" ]; then echo "socket still present"; exit 1; fi`, + "echo removed", + ].join("\n"), + ], + expect: { stdout: ["removed"] }, +}); + +const defaultExecs = (selection: Selection): readonly Exec[] => [ + healthCheck(selection, "http://127.0.0.1:8080/healthz"), + version(selection.key), + ...installed(), + logLine(selection, "HTTP server listening on http://127.0.0.1:8080/"), + logLine(selection, " - Authentication is disabled"), + processUser(selection), + processCount(), + reused(selection), +]; + +const passwordExecs = (selection: Selection): readonly Exec[] => [ + healthCheck(selection, "http://127.0.0.1:8080/healthz"), + logLine(selection, " - Authentication is enabled"), + logLine(selection, " - Using password from $PASSWORD"), +]; + +const socketExecs = (selection: Selection, socketPath: string): readonly Exec[] => [ + healthCheck(selection, "http://localhost/healthz", socketPath), + { command: ["stat", "-c", "%a", socketPath], expect: { stdout: ["600"] } }, + logLine(selection, `HTTP server listening on ${socketPath}`), + stopped(selection, socketPath), +]; + +const argsExecs = (selection: Selection): readonly Exec[] => [ + healthCheck(selection, "http://127.0.0.1:18080/healthz"), + logLine(selection, "HTTP server listening on http://127.0.0.1:18080/"), + logCount(selection, "Using extensions-dir ", 1), +]; + +const smokeExecs = (selection: Selection): readonly Exec[] => [ + healthCheck(selection, "http://127.0.0.1:8080/healthz"), + version(selection.key), + ...installed(), + logLine(selection, " - Authentication is disabled"), +]; + +const unknownOptionsVariant = (selection: Selection, root: string): ScenarioVariant => ({ + name: "options-unknown", + workspace: lifecycle.workspaceAt(root, "default-options-unknown"), + testImage: testImageOf("default-options-unknown", selection, [ + { name: "options.json", source: path.join(import.meta.dirname, "assets", "options-unknown.json") }, + ]), + buildFailure: "install: /opt/devcontainer-config/features/code-server/options.json", +}); + +const createScenario = async (name: ScenarioName, context: ScenarioContext): Promise => { + const { selection, target, root, workspace } = context; + switch (name) { + case "default": + return { + name, + workspace, + testImage: testImageOf(name, selection), + execs: defaultExecs(selection), + variants: [unknownOptionsVariant(selection, root)], + }; + case "password": { + const passwordPath = posix.join(workspace.containerPath, "password"); + return { + name, + workspace, + testImage: await optionsImage(name, root, selection, { auth: "password", passwordFile: passwordPath }), + execs: passwordExecs(selection), + stage: async (staged) => { + await writeFile(path.join(staged.path, "password"), "verify-password\n"); + }, + }; + } + case "socket": { + const socketPath = posix.join(workspace.containerPath, "code-server.sock"); + return { + name, + workspace, + testImage: await optionsImage(name, root, selection, { socket: socketPath }), + execs: socketExecs(selection, socketPath), + }; + } + case "args": + return { + name, + workspace, + testImage: await optionsImage(name, root, selection, { + args: ["--verbose", "--bind-addr", "127.0.0.1:18080"], + }), + execs: argsExecs(selection), + }; + case "smoke": { + if (target === undefined) { + throw new Error("The smoke scenario requires --component and --version"); + } + return { + name, + workspace, + testImage: testImageOf(name, selection), + execs: smokeExecs(selection), + }; + } + } +}; + +export const scenarios = { + create: createScenario, +} as const; diff --git a/scripts/verify/code-server/selection.ts b/scripts/verify/code-server/selection.ts new file mode 100644 index 0000000..a46a31d --- /dev/null +++ b/scripts/verify/code-server/selection.ts @@ -0,0 +1,38 @@ +import { posix } from "node:path"; + +import { codeServer, documentOf } from "@/scripts/tasks/features/code-server/feature.js"; +import { configPath, readConfig } from "@/scripts/tasks/features/code-server/generateConfig.js"; +import { claimedKey } from "@/scripts/tasks/features/config.js"; +import { imageEntry } from "@/scripts/tasks/features/imageEntries.js"; + +import type { ComponentImage } from "../lifecycle.js"; +import { userInit } from "../userInit.js"; + +const component = "code-server"; + +export interface Selection extends ComponentImage { + logPath: string; + pidPath: string; +} + +export interface SelectionTarget { + component: "code-server"; + key: string; +} + +const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => { + const config = await readConfig(); + const key = target?.key ?? claimedKey(config.aliases, "latest", configPath); + const { canonical } = imageEntry(codeServer, documentOf(config), prefix, channel, { component, key }); + const cachePath = posix.join(await userInit.xdgPath("XDG_CACHE_HOME"), component); + const statePath = posix.join(await userInit.xdgPath("XDG_STATE_HOME"), component); + return { + component, + key, + ref: canonical, + logPath: posix.join(cachePath, "code-server.log"), + pidPath: posix.join(statePath, "code-server.pid"), + }; +}; + +export const selection = { derive } as const; diff --git a/scripts/verify/code-server/test-image-options.Dockerfile b/scripts/verify/code-server/test-image-options.Dockerfile new file mode 100644 index 0000000..39d7239 --- /dev/null +++ b/scripts/verify/code-server/test-image-options.Dockerfile @@ -0,0 +1,8 @@ +# check=skip=InvalidDefaultArgInFrom +ARG CODE_SERVER_IMAGE_REF + +FROM ${CODE_SERVER_IMAGE_REF} AS code-server + +FROM mcr.microsoft.com/devcontainers/base:debian +COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/ +COPY options.json /opt/devcontainer-config/features/code-server/options.json diff --git a/scripts/verify/code-server/test-image.Dockerfile b/scripts/verify/code-server/test-image.Dockerfile new file mode 100644 index 0000000..dd2893d --- /dev/null +++ b/scripts/verify/code-server/test-image.Dockerfile @@ -0,0 +1,7 @@ +# check=skip=InvalidDefaultArgInFrom +ARG CODE_SERVER_IMAGE_REF + +FROM ${CODE_SERVER_IMAGE_REF} AS code-server + +FROM mcr.microsoft.com/devcontainers/base:debian +COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/ diff --git a/scripts/verify/code-server/verify.ts b/scripts/verify/code-server/verify.ts new file mode 100644 index 0000000..2b6b88d --- /dev/null +++ b/scripts/verify/code-server/verify.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { parseVerifyArguments, requireSmokeTarget, targetSchema } from "../arguments.js"; +import { lifecycle } from "../lifecycle.js"; +import { runSuite } from "../run.js"; +import { scenarios, scenarioSelectionSchema } from "./scenarios.js"; +import { selection } from "./selection.js"; + +export const verify = async (argv: string[]): Promise => { + const { options, scenarios: requested, component, version } = await parseVerifyArguments(argv); + const selected = scenarioSelectionSchema.parse(requested); + const target = targetSchema(z.literal("code-server")).parse({ component, version }); + requireSmokeTarget(selected, target); + const derived = await selection.derive(options.channel, options.prefix, target); + console.log(`>>> verify code-server: selection version=${derived.key} ref=${derived.ref}`); + + await runSuite({ + ...options, + name: "code-server", + create: async (root) => + Promise.all( + selected.map((name) => + scenarios.create(name, { + selection: derived, + target, + root, + workspace: lifecycle.workspaceAt(root, name), + }), + ), + ), + }); +}; diff --git a/scripts/verify/dotnet/selection.ts b/scripts/verify/dotnet/selection.ts index 188e108..8a0ce11 100644 --- a/scripts/verify/dotnet/selection.ts +++ b/scripts/verify/dotnet/selection.ts @@ -1,23 +1,12 @@ -import { readFile } from "node:fs/promises"; -import path, { posix } from "node:path"; +import { posix } from "node:path"; -import { z } from "zod"; - -import { projectRoot } from "@/scripts/project.js"; import { claimedKey } from "@/scripts/tasks/features/config.js"; import { documentOf, dotnet as dotnetFeature } from "@/scripts/tasks/features/dotnet/feature.js"; import type { Component, FeatureConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js"; import { configPath, readConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js"; import { imageEntry } from "@/scripts/tasks/features/imageEntries.js"; -import { remoteUser } from "../lifecycle.js"; - -const userInitFeaturePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json"); -const dataRootSource = "XDG_DATA_HOME-${devcontainerId}"; - -const userInitFeatureSchema = z.object({ - mounts: z.array(z.object({ source: z.string(), target: z.string() })), -}); +import { userInit } from "../userInit.js"; export interface ComponentImage { component: Component; @@ -57,18 +46,8 @@ const tfmOf = (sdkVersion: string): string => { return `net${match[1]}.${match[2]}`; }; -const readGlobalPackagesPath = async (): Promise => { - const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8")); - const result = userInitFeatureSchema.safeParse(data); - if (!result.success) { - throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`); - } - const dataRoot = result.data.mounts.find((mount) => mount.source === dataRootSource)?.target; - if (dataRoot === undefined) { - throw new Error(`${userInitFeaturePath}: no mounts entry with source ${dataRootSource}`); - } - return posix.join(dataRoot, remoteUser, "NuGet/global-packages"); -}; +const readGlobalPackagesPath = async (): Promise => + posix.join(await userInit.xdgPath("XDG_DATA_HOME"), "NuGet/global-packages"); const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => { const config = await readConfig(); diff --git a/scripts/verify/userInit.ts b/scripts/verify/userInit.ts new file mode 100644 index 0000000..d7f223e --- /dev/null +++ b/scripts/verify/userInit.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import path, { posix } from "node:path"; + +import { z } from "zod"; + +import { projectRoot } from "@/scripts/project.js"; + +import { remoteUser } from "./lifecycle.js"; + +const featurePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json"); +const containerId = "${devcontainerId}"; + +const featureSchema = z.object({ + mounts: z.array(z.object({ source: z.string(), target: z.string() })), +}); + +const readMounts = async (): Promise => { + const data: unknown = JSON.parse(await readFile(featurePath, "utf-8")); + const result = featureSchema.safeParse(data); + if (!result.success) { + throw new Error(`${featurePath}: ${z.prettifyError(result.error)}`); + } + return result.data.mounts; +}; + +const xdgRoot = async (variable: string): Promise => { + const source = `${variable}-${containerId}`; + const root = (await readMounts()).find((mount) => mount.source === source)?.target; + if (root === undefined) { + throw new Error(`${featurePath}: no mounts entry with source ${source}`); + } + return root; +}; + +const xdgPath = async (variable: string): Promise => posix.join(await xdgRoot(variable), remoteUser); + +export const userInit = { xdgRoot, xdgPath } as const; diff --git a/scripts/verify/uv/selection.ts b/scripts/verify/uv/selection.ts index 052c7f7..cbf561c 100644 --- a/scripts/verify/uv/selection.ts +++ b/scripts/verify/uv/selection.ts @@ -1,9 +1,3 @@ -import { readFile } from "node:fs/promises"; -import path, { posix } from "node:path"; - -import { z } from "zod"; - -import { projectRoot } from "@/scripts/project.js"; import { hostArch } from "@/scripts/tasks/features/architecture.js"; import { claimedKey } from "@/scripts/tasks/features/config.js"; import { imageEntry } from "@/scripts/tasks/features/imageEntries.js"; @@ -12,14 +6,7 @@ import type { Component, FeatureConfig, PythonVersion } from "@/scripts/tasks/fe import { configPath, readConfig } from "@/scripts/tasks/features/uv/generateConfig.js"; import { machineOf, muslTriples } from "@/scripts/tasks/features/uv/paths.js"; -import { remoteUser } from "../lifecycle.js"; - -const userInitFeaturePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json"); -const dataRootSource = "XDG_DATA_HOME-${devcontainerId}"; - -const userInitFeatureSchema = z.object({ - mounts: z.array(z.object({ source: z.string(), target: z.string() })), -}); +import { userInit } from "../userInit.js"; export interface ComponentImage { component: Component; @@ -101,19 +88,6 @@ const previousPython = ( return previous === undefined ? undefined : pythonImage(config, previous[0], channel, prefix); }; -const readXdgDataPath = async (): Promise => { - const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8")); - const result = userInitFeatureSchema.safeParse(data); - if (!result.success) { - throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`); - } - const dataRoot = result.data.mounts.find((mount) => mount.source === dataRootSource)?.target; - if (dataRoot === undefined) { - throw new Error(`${userInitFeaturePath}: no mounts entry with source ${dataRootSource}`); - } - return posix.join(dataRoot, remoteUser); -}; - const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => { const config = await readConfig(); const uvKey = @@ -129,7 +103,7 @@ const derive = async (channel: string, prefix: string, target?: SelectionTarget) uv: componentImage(config, "uv", uvKey, channel, prefix), python: pythonImage(config, pythonKey, channel, prefix), previous: previousPython(config, pythonKey, channel, prefix), - xdgDataPath: await readXdgDataPath(), + xdgDataPath: await userInit.xdgPath("XDG_DATA_HOME"), machine: machineOf(arch), muslTriple: muslTriples[arch], }; From c2cedc37cc8c6251920976573b5d7c3add708730 Mon Sep 17 00:00:00 2001 From: gdlol <.> Date: Sun, 8 Nov 2026 11:57:02 +0000 Subject: [PATCH 2/2] Trim notes --- features/code-server/NOTES.md | 38 ++-------------------------------- features/code-server/README.md | 38 ++-------------------------------- 2 files changed, 4 insertions(+), 72 deletions(-) diff --git a/features/code-server/NOTES.md b/features/code-server/NOTES.md index d12fa06..370a78a 100644 --- a/features/code-server/NOTES.md +++ b/features/code-server/NOTES.md @@ -1,28 +1,14 @@ -## Staging - -The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns. - -## Options file - -Options are read from `options.json` in the feature directory: +## Example Options ```json { "auth": "password", - "passwordFile": "/run/secrets/code-server-password", + "hashedPasswordFile": "/run/secrets/code-server-password", "host": "127.0.0.1", "port": 8080 } ``` -`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`. - -`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder. - -## Authentication - -`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy. - ## 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`. @@ -31,23 +17,3 @@ With `socket` set, the server listens on that absolute Unix socket with `socketM - Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log` - PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid` - -An unset, empty or relative XDG value uses the `$HOME` path. - -## Arguments - -`args` is an array of code-server arguments appended after the option-derived arguments: - -```json -{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] } -``` - -One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing. - -## Requirements - -Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies. - -## Restart - -A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually. diff --git a/features/code-server/README.md b/features/code-server/README.md index 2771f4d..b72b315 100644 --- a/features/code-server/README.md +++ b/features/code-server/README.md @@ -37,31 +37,17 @@ COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features | verbose | Pass --verbose. | boolean | false | | args | One code-server argument per element, appended after the option-derived arguments. | array | [] | -## Staging - -The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns. - -## Options file - -Options are read from `options.json` in the feature directory: +## Example Options ```json { "auth": "password", - "passwordFile": "/run/secrets/code-server-password", + "hashedPasswordFile": "/run/secrets/code-server-password", "host": "127.0.0.1", "port": 8080 } ``` -`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`. - -`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder. - -## Authentication - -`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy. - ## 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`. @@ -71,26 +57,6 @@ With `socket` set, the server listens on that absolute Unix socket with `socketM - Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log` - PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid` -An unset, empty or relative XDG value uses the `$HOME` path. - -## Arguments - -`args` is an array of code-server arguments appended after the option-derived arguments: - -```json -{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] } -``` - -One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing. - -## Requirements - -Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies. - -## Restart - -A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually. - --- _Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/code-server/feature.json). Add additional notes to a `NOTES.md`._