File: ComposeModels.cs
Web Access
Project: src\playground\NativeIntegrations\CSharp\DockerCompose\NativeIntegrations.DockerCompose.csproj (NativeIntegrations.DockerCompose)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
 
namespace NativeIntegrations.DockerCompose;
 
public sealed record ComposeBuild(string Context, string Dockerfile);
public sealed record ComposeHealthcheck(string[] Test, string? Interval = null, string? Timeout = null, int? Retries = null);
public sealed record ComposePort(string Endpoint, int? Published = null, string HostIp = "127.0.0.1");
 
public sealed class DockerComposeServiceOptions
{
    public string? Image { get; init; }
    public ComposeBuild? Build { get; init; }
    public ComposeHealthcheck? Healthcheck { get; init; }
    public ComposePort[]? Ports { get; init; }
    public bool Exclude { get; init; }
    public bool ReplaceRunLifecycle { get; init; }
 
    internal JsonObject ToJson()
    {
        var result = new JsonObject();
        if (Image is not null)
        {
            result["image"] = Image;
        }
        if (Build is not null)
        {
            result["build"] = new JsonObject { ["context"] = Build.Context, ["dockerfile"] = Build.Dockerfile };
        }
        if (Healthcheck is not null)
        {
            result["healthcheck"] = ComposeJson.Healthcheck(Healthcheck);
        }
        if (Ports is not null)
        {
            result["ports"] = new JsonArray(Ports.Select(port =>
            {
                var exposure = new JsonObject { ["endpoint"] = port.Endpoint, ["hostIp"] = port.HostIp };
                if (port.Published is { } published)
                {
                    exposure["published"] = published;
                }
 
                return (JsonNode)exposure;
            }).ToArray());
        }
        if (Exclude)
        {
            result["exclude"] = true;
        }
        if (ReplaceRunLifecycle)
        {
            result["replaceRunLifecycle"] = true;
        }
 
        return result;
    }
}
 
public sealed record ComposePublishedPort(string Endpoint, int Target, string Protocol, string Scheme, string HostIp, int? Published);
public sealed record ComposePlannedService(string Resource, string Service, bool Healthcheck, IReadOnlyList<ComposePublishedPort> Ports);
public sealed record DockerComposePlan(JsonObject Compose, JsonObject Inputs, List<ComposePlannedService> Services);
 
/// <summary>A typed customization view over the generated Compose service, not a second application model.</summary>
public sealed class ComposeServiceBuilder
{
    private readonly string _applicationDirectory;
    private readonly Func<string, string> _render;
    public JsonObject Model { get; }
 
    internal ComposeServiceBuilder(JsonObject model, string applicationDirectory, Func<string, string>? render = null)
    {
        Model = model;
        _applicationDirectory = applicationDirectory;
        _render = render ?? ComposeJson.Literal;
    }
 
    public string? Image
    {
        get => Model["image"]?.GetValue<string>();
        set
        {
            if (value is not null)
            {
                ComposeJson.ValidateImage(value);
            }
            Set("image", value is null ? null : ComposeJson.Literal(value));
        }
    }
 
    public ComposeBuild? Build
    {
        get => Model["build"] is JsonObject build ? new(build["context"]!.GetValue<string>(), build["dockerfile"]!.GetValue<string>()) : null;
        set => Set("build", value is null ? null : ComposeJson.BuildPaths(value, _applicationDirectory));
    }
 
    public ComposeHealthcheck? Healthcheck
    {
        get => Model["healthcheck"] is JsonObject health ? ComposeJson.ReadHealthcheck(health) : null;
        set => Set("healthcheck", value is null ? null : ComposeJson.Healthcheck(value, escape: true));
    }
 
    public string? ContainerName
    {
        get => Model["container_name"]?.GetValue<string>();
        set => Set("container_name", value is null ? null : _render(ComposeJson.Text(value)));
    }
 
    public string? Restart
    {
        get => Model["restart"]?.GetValue<string>();
        set
        {
            if (value is not null && !Regex.IsMatch(value, "^(no|always|unless-stopped|on-failure(?::[1-9][0-9]*)?)$", RegexOptions.CultureInvariant))
            {
                throw new ArgumentException("Unsupported Compose restart policy.", nameof(value));
            }
            Set("restart", value);
        }
    }
 
    public string? PullPolicy
    {
        get => Model["pull_policy"]?.GetValue<string>();
        set
        {
            if (value is not null && !Regex.IsMatch(value, "^(always|never|missing|build|daily|weekly|every_[0-9]+[hms])$", RegexOptions.CultureInvariant))
            {
                throw new ArgumentException("Unsupported Compose pull policy.", nameof(value));
            }
            Set("pull_policy", value);
        }
    }
 
    public void SetLabel(string name, string value) => Model["labels"]!.AsObject()[name] = ComposeJson.Literal(value);
 
    public void AddVolume(string source, string target, bool isReadOnly = false)
    {
        ComposeJson.Key(source);
        ComposeJson.AbsoluteTarget(target);
        (Model["volumes"] ??= new JsonArray()).AsArray().Add(new JsonObject
        {
            ["type"] = "volume", ["source"] = source, ["target"] = ComposeJson.Literal(target), ["read_only"] = isReadOnly
        });
    }
 
    private void Set(string key, JsonNode? value)
    {
        if (value is null)
        {
            Model.Remove(key);
        }
        else
        {
            Model[key] = value;
        }
    }
}
 
public sealed class ComposeFileBuilder(JsonObject model, string applicationDirectory)
{
    public JsonObject Model { get; } = model;
    public string Name
    {
        get => Model["name"]!.GetValue<string>();
        set
        {
            if (!Regex.IsMatch(value, "^[a-z0-9][a-z0-9_-]*$", RegexOptions.CultureInvariant))
            {
                throw new ArgumentException("Invalid Compose project name.", nameof(value));
            }
            Model["name"] = value;
        }
    }
 
    public ComposeServiceBuilder GetService(string name) => new(
        Model["services"]![name]?.AsObject() ?? throw new InvalidOperationException($"Compose service '{name}' does not exist."), applicationDirectory);
 
    public ComposeServiceBuilder AddService(string name, string image, IReadOnlyDictionary<string, string>? environment = null)
    {
        ComposeJson.Key(name);
        ComposeJson.ValidateImage(image);
        var services = Model["services"]!.AsObject();
        if (services.ContainsKey(name))
        {
            throw new ArgumentException($"Duplicate Compose service '{name}'.", nameof(name));
        }
        var service = new JsonObject { ["image"] = ComposeJson.Literal(image), ["labels"] = new JsonObject() };
        if (environment is not null)
        {
            service["environment"] = new JsonObject(environment.Select(pair =>
                KeyValuePair.Create(pair.Key, (JsonNode?)JsonValue.Create(ComposeJson.Literal(pair.Value)))));
        }
        services.Add(name, service);
 
        return new(service, applicationDirectory);
    }
 
    public void AddNetwork(string name, JsonObject options) => Add("networks", name, ComposeJson.Escape(options));
    public void AddVolume(string name, string? driver = null, IReadOnlyDictionary<string, string>? labels = null)
    {
        var volume = new JsonObject();
        if (driver is not null)
        {
            volume["driver"] = ComposeJson.Literal(driver);
        }
        if (labels is not null)
        {
            volume["labels"] = new JsonObject(labels.Select(pair =>
                KeyValuePair.Create(pair.Key, (JsonNode?)JsonValue.Create(ComposeJson.Literal(pair.Value)))));
        }
        Add("volumes", name, volume);
    }
    public void AddConfig(string name, string content) => Add("configs", name, new() { ["content"] = ComposeJson.Literal(content) });
    public void AddSecret(string name, string? externalName = null)
    {
        var secret = new JsonObject { ["external"] = true };
        if (externalName is not null)
        {
            secret["name"] = ComposeJson.Literal(externalName);
        }
        Add("secrets", name, secret);
    }
    private void Add(string collection, string name, JsonObject value)
    {
        ComposeJson.Key(name);
        (Model[collection] ??= new JsonObject()).AsObject()[name] = value;
    }
}
 
public sealed class CapturedEnvironmentVariable(JsonObject model)
{
    public string? Description
    {
        get => model["description"]?.GetValue<string>();
        set => model["description"] = value;
    }
    public string? DefaultValue
    {
        get => model["defaultValue"]?.GetValue<string>();
        set
        {
            if (value is not null && model["secret"]!.GetValue<bool>())
            {
                throw new InvalidOperationException("Secret defaults cannot be written to deployment artifacts.");
            }
            if (value is null)
            {
                model.Remove("defaultValue");
            }
            else
            {
                model["defaultValue"] = ComposeJson.Text(value, allowEmpty: true);
            }
        }
    }
}
 
internal static class ComposeJson
{
    internal static readonly JsonSerializerOptions s_indented = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
    internal static string Json(JsonNode node) => node.ToJsonString(s_indented) + "\n";
    internal static string Literal(string value) => value.Replace("$", "$$", StringComparison.Ordinal);
    internal static string Text(string value, bool allowEmpty = false)
    {
        if ((!allowEmpty && string.IsNullOrWhiteSpace(value)) || value.Contains('\0'))
        {
            throw new ArgumentException("Expected text without NUL characters.", nameof(value));
        }
 
        return value;
    }
    internal static void Key(string value)
    {
        if (!Regex.IsMatch(value, "^[a-zA-Z0-9][a-zA-Z0-9_.-]*$", RegexOptions.CultureInvariant))
        {
            throw new ArgumentException("Invalid Compose identifier.", nameof(value));
        }
    }
    internal static void AbsoluteTarget(string value)
    {
        Text(value);
        if (!value.StartsWith('/'))
        {
            throw new ArgumentException("Compose mount targets must be absolute.", nameof(value));
        }
    }
    internal static void ValidateImage(string value)
    {
        if (!Regex.IsMatch(value, "^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$", RegexOptions.CultureInvariant))
        {
            throw new ArgumentException("Deployment image must be a literal image reference.", nameof(value));
        }
    }
    internal static void Port(int value)
    {
        ArgumentOutOfRangeException.ThrowIfLessThan(value, 1);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 65535);
    }
    internal static JsonObject BuildPaths(ComposeBuild build, string applicationDirectory)
    {
        var context = Path.GetFullPath(Text(build.Context), applicationDirectory);
        var dockerfile = Path.GetFullPath(Text(build.Dockerfile), applicationDirectory);
        var within = Path.GetRelativePath(context, dockerfile);
        if (within is "." or ".." || within.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(within))
        {
            throw new ArgumentException("Dockerfile must be inside its explicit build context.", nameof(build));
        }
 
        return new() { ["context"] = Literal(context), ["dockerfile"] = Literal(dockerfile) };
    }
    internal static JsonObject Healthcheck(ComposeHealthcheck value, bool escape = false)
    {
        if (value.Test.Length < 2 || value.Test[0] is not ("CMD" or "CMD-SHELL") ||
            value.Test[0] == "CMD-SHELL" && value.Test.Length != 2)
        {
            throw new ArgumentException("Healthcheck must start with CMD or CMD-SHELL and contain a command.", nameof(value));
        }
        var result = new JsonObject { ["test"] = Strings(value.Test.Select(part => escape ? Literal(Text(part, true)) : Text(part, true))) };
        foreach (var (key, duration) in new[] { ("interval", value.Interval), ("timeout", value.Timeout) })
        {
            if (duration is not null)
            {
                if (!Regex.IsMatch(duration, @"^(?:[0-9]+(?:\.[0-9]+)?(?:h|ms|us|s|m))+$", RegexOptions.CultureInvariant) ||
                    !duration.Any(character => character is >= '1' and <= '9'))
                {
                    throw new ArgumentException("Healthcheck durations must be positive Compose durations.", nameof(value));
                }
                result[key] = duration;
            }
        }
        if (value.Retries is { } retries)
        {
            ArgumentOutOfRangeException.ThrowIfLessThan(retries, 1);
            result["retries"] = retries;
        }
 
        return result;
    }
    internal static ComposeHealthcheck ReadHealthcheck(JsonObject value) => new(
        value["test"]!.AsArray().Select(part => part!.GetValue<string>()).ToArray(),
        value["interval"]?.GetValue<string>(), value["timeout"]?.GetValue<string>(), value["retries"]?.GetValue<int>());
    internal static JsonArray Strings(IEnumerable<string> values) => new(values.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray());
    internal static JsonObject Escape(JsonObject source) => EscapeNode(source)!.AsObject();
    private static JsonNode? EscapeNode(JsonNode? node) => node switch
    {
        JsonObject obj => new JsonObject(obj.Select(pair => KeyValuePair.Create(pair.Key, EscapeNode(pair.Value)))),
        JsonArray array => new JsonArray(array.Select(EscapeNode).ToArray()),
        JsonValue value when value.TryGetValue<string>(out var text) => JsonValue.Create(Literal(text)),
        _ => node?.DeepClone()
    };
}