| File: ImageConfig.cs | Web Access |
| Project: src\sdk\src\Containers\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj (Microsoft.NET.Build.Containers) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.ObjectModel; using System.Text.Json; using System.Text.Json.Nodes; namespace Microsoft.NET.Build.Containers; /// <summary> /// The class allows to modify base image configuration. /// </summary> internal sealed class ImageConfig { private readonly JsonObject _config; private readonly Dictionary<string, string> _labels; private readonly HashSet<Port> _exposedPorts; private readonly Dictionary<string, string> _environmentVariables; private string? _newWorkingDirectory; private string[]? _newEntrypoint; private string[]? _newCmd; private string? _user; private bool _userHasBeenExplicitlySet; /// <summary> /// Models the file system of the image. Typically has a key 'type' with value 'layers' and a key 'diff_ids' with a list of layer digests. /// </summary> private readonly List<string> _rootFsLayers; private readonly string _architecture; private readonly string _os; private readonly List<HistoryEntry> _history; /// <summary> /// Gets a value indicating whether the base image is has a Windows operating system. /// </summary> public bool IsWindows => "windows".Equals(_os, StringComparison.OrdinalIgnoreCase); public ReadOnlyDictionary<string, string> EnvironmentVariables => _environmentVariables.AsReadOnly(); public HashSet<Port> Ports => _exposedPorts; internal ImageConfig(string imageConfigJson) : this(JsonNode.Parse(imageConfigJson)!) { } internal ImageConfig(JsonNode config) { _config = config as JsonObject ?? throw new ArgumentException($"{nameof(config)} should be a JSON object.", nameof(config)); if (_config["config"] is not JsonObject) { throw new ArgumentException("Base image configuration should contain a 'config' node."); } _labels = GetLabels(); _exposedPorts = GetExposedPorts(); _environmentVariables = GetEnvironmentVariables(); _rootFsLayers = GetRootFileSystemLayers(); _architecture = GetArchitecture(); _os = GetOs(); _history = GetHistory(); _user = GetUser(); _newEntrypoint = GetEntrypoint(); _newCmd = GetCmd(); } // Return values from the base image config. internal string? GetUser() => _config["config"]?["User"]?.ToString(); internal string[]? GetEntrypoint() => _config["config"]?["Entrypoint"]?.AsArray()?.Select(node => node!.GetValue<string>())?.ToArray(); private string[]? GetCmd() => _config["config"]?["Entrypoint"]?.AsArray()?.Select(node => node!.GetValue<string>())?.ToArray(); private List<HistoryEntry> GetHistory() => _config["history"]?.AsArray().Select(node => node.Deserialize<HistoryEntry>()!).ToList() ?? new List<HistoryEntry>(); private string GetOs() => _config["os"]?.ToString() ?? throw new ArgumentException("Base image configuration should contain an 'os' property."); private string GetArchitecture() => _config["architecture"]?.ToString() ?? throw new ArgumentException("Base image configuration should contain an 'architecture' property."); /// <summary> /// Builds in additional configuration and returns updated image configuration in JSON format as string. /// </summary> internal string BuildConfig() { var newConfig = new JsonObject(); if (_exposedPorts.Any()) { newConfig["ExposedPorts"] = CreatePortMap(); } if (_labels.Any()) { newConfig["Labels"] = CreateLabelMap(); } if (_environmentVariables.Count != 0) { newConfig["Env"] = CreateEnvironmentVariablesMapping(); } if (_newWorkingDirectory is not null) { newConfig["WorkingDir"] = _newWorkingDirectory; } if (_newEntrypoint?.Length > 0) { newConfig["Entrypoint"] = ToJsonArray(_newEntrypoint); } if (_newCmd?.Length > 0) { newConfig["Cmd"] = ToJsonArray(_newCmd); } if (_user is not null) { newConfig["User"] = _user; } // These fields aren't (yet) supported by the task layer, but we should // preserve them if they're already set in the base image. foreach (string propertyName in new[] { "Volumes", "StopSignal" }) { if (_config["config"]?[propertyName] is JsonNode propertyValue) { // we can't just copy the property value because JsonValues have Parents // and they cannot be re-parented. So we need to Clone them, but there's // not an API for cloning, so the recommendation is to stringify and parse. newConfig[propertyName] = JsonNode.Parse(propertyValue.ToJsonString()); } } // Add history entries for ourselves so folks can map generated layers to the Dockerfile commands. // The number of (non empty) history items must match the number of layers in the image. // Some registries like JFrog Artifactory have there a strict validation rule (see sdk-container-builds#382). int numberOfLayers = _rootFsLayers.Count; int numberOfNonEmptyLayerHistoryEntries = _history.Count(h => h.empty_layer is null or false); int missingHistoryEntries = numberOfLayers - numberOfNonEmptyLayerHistoryEntries; HistoryEntry customHistoryEntry = new(created: DateTime.UtcNow, author: ".NET SDK", created_by: $".NET SDK Container Tooling, version {Constants.Version}"); for (int i = 0; i < missingHistoryEntries; i++) { _history.Add(customHistoryEntry); } var configContainer = new JsonObject() { ["config"] = newConfig, //update creation date ["created"] = RFC3339Format(DateTime.UtcNow), ["rootfs"] = new JsonObject() { ["type"] = "layers", ["diff_ids"] = ToJsonArray(_rootFsLayers) }, ["architecture"] = _architecture, ["os"] = _os, ["history"] = new JsonArray(_history.Select(CreateHistory).ToArray<JsonNode>()) }; return configContainer.ToJsonString(); static JsonArray ToJsonArray(IEnumerable<string> items) => new(items.Where(s => !string.IsNullOrEmpty(s)).Select(s => JsonValue.Create(s)).ToArray<JsonNode?>()); } private JsonObject CreateHistory(HistoryEntry h) { var history = new JsonObject(); if (h.author is not null) { history["author"] = h.author; } if (h.comment is not null) { history["comment"] = h.comment; } if (h.created is { } date) { history["created"] = RFC3339Format(date); } if (h.created_by is not null) { history["created_by"] = h.created_by; } if (h.empty_layer is not null) { history["empty_layer"] = h.empty_layer; } return history; } static string RFC3339Format(DateTimeOffset dateTime) => dateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", System.Globalization.CultureInfo.InvariantCulture); internal void ExposePort(int number, PortType type) { _exposedPorts.Add(new(number, type)); } internal void AddEnvironmentVariable(string envVarName, string value) { _environmentVariables[envVarName] = value; } internal void AddLabel(string name, string value) { _labels[name] = value; } internal void SetWorkingDirectory(string workingDirectory) { _newWorkingDirectory = workingDirectory; } internal void SetEntrypointAndCmd(string[] entrypoint, string[] cmd) { _newEntrypoint = entrypoint; _newCmd = cmd; } internal void AddLayer(Layer l) { _rootFsLayers.Add(l.Descriptor.UncompressedDigest!); } internal void SetUser(string user, bool isUserInteraction = false) { // we don't let automatic/inferred user settings overwrite an explicit user request if (_userHasBeenExplicitlySet && !isUserInteraction) { return; } _user = user; _userHasBeenExplicitlySet = isUserInteraction; } private HashSet<Port> GetExposedPorts() { HashSet<Port> ports = new(); if (_config["config"]?["ExposedPorts"] is JsonObject portsJson) { // read label mappings from object foreach (KeyValuePair<string, JsonNode?> property in portsJson) { if (property.Key is { } propertyName && property.Value is JsonObject propertyValue && ContainerHelpers.TryParsePort(propertyName, out Port? parsedPort, out ContainerHelpers.ParsePortError? _)) { ports.Add(parsedPort!.Value); } } } return ports; } private Dictionary<string, string> GetLabels() { Dictionary<string, string> labels = new(); if (_config["config"]?["Labels"] is JsonObject labelsJson) { // read label mappings from object foreach (KeyValuePair<string, JsonNode?> property in labelsJson) { if (property.Key is { } propertyName && property.Value is JsonValue propertyValue) { labels[propertyName] = propertyValue.ToString(); } } } return labels; } private Dictionary<string, string> GetEnvironmentVariables() { Dictionary<string, string> envVars = new(); if (_config["config"]?["Env"] is JsonArray envVarJson) { foreach (JsonNode? entry in envVarJson) { if (entry is null) continue; string[] val = entry.GetValue<string>().Split('=', 2); if (val.Length != 2) continue; envVars.Add(val[0], val[1]); } } return envVars; } private JsonObject CreatePortMap() { // ports are entries in a key/value map whose keys are "<number>/<type>" and whose values are an empty object. // yes, this is odd. JsonObject container = new(); foreach (Port port in _exposedPorts) { container.Add($"{port.Number}/{port.Type}", new JsonObject()); } return container; } private JsonArray CreateEnvironmentVariablesMapping() { // Env is a JSON array where each value is of the format: "VAR=value" JsonArray envVarJson = new(); foreach (KeyValuePair<string, string> envVar in _environmentVariables) { envVarJson.Add($"{envVar.Key}={envVar.Value}"); } return envVarJson; } private JsonObject CreateLabelMap() { JsonObject container = new(); foreach (KeyValuePair<string, string> label in _labels) { container.Add(label.Key, label.Value); } return container; } private List<string> GetRootFileSystemLayers() { if (_config["rootfs"] is { } rootfs) { if (rootfs["type"]?.GetValue<string>() == "layers" && rootfs["diff_ids"] is JsonArray layers) { return layers.Select(l => l!.GetValue<string>()).ToList(); } else { return new(); } } else { throw new InvalidOperationException("Base image configuration should contain a 'rootfs' node."); } } private record HistoryEntry(DateTimeOffset? created = null, string? created_by = null, bool? empty_layer = null, string? comment = null, string? author = null); }