Skip to content

Commit e98d757

Browse files
Add support for ExposedPorts to the Image config (#113)
Co-authored-by: Rainer Sigwald <[email protected]>
1 parent df43690 commit e98d757

File tree

7 files changed

+268
-11
lines changed

7 files changed

+268
-11
lines changed

Microsoft.NET.Build.Containers/ContainerHelpers.cs

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,73 @@ public static bool NormalizeImageName(string containerImageName, [NotNullWhen(fa
144144
}
145145
}
146146

147-
public static async Task Containerize(DirectoryInfo folder, string workingDir, string registryName, string baseName, string baseTag, string[] entrypoint, string[] entrypointArgs, string imageName, string[] imageTags, string outputRegistry, string[] labels)
147+
[Flags]
148+
public enum ParsePortError
149+
{
150+
MissingPortNumber,
151+
InvalidPortNumber,
152+
InvalidPortType,
153+
UnknownPortFormat
154+
}
155+
156+
public static bool TryParsePort(string? portNumber, string? portType, [NotNullWhen(true)] out Port? port, [NotNullWhen(false)] out ParsePortError? error)
157+
{
158+
var portNo = 0;
159+
error = null;
160+
if (String.IsNullOrEmpty(portNumber))
161+
{
162+
error = ParsePortError.MissingPortNumber;
163+
}
164+
else if (!int.TryParse(portNumber, out portNo))
165+
{
166+
error = ParsePortError.InvalidPortNumber;
167+
}
168+
169+
if (!Enum.TryParse<PortType>(portType, out PortType t))
170+
{
171+
if (portType is not null)
172+
{
173+
error = (error ?? ParsePortError.InvalidPortType) | ParsePortError.InvalidPortType;
174+
}
175+
else
176+
{
177+
t = PortType.tcp;
178+
}
179+
}
180+
181+
if (error is null)
182+
{
183+
port = new Port(portNo, t);
184+
return true;
185+
}
186+
else
187+
{
188+
port = null;
189+
return false;
190+
}
191+
192+
}
193+
194+
public static bool TryParsePort(string input, [NotNullWhen(true)] out Port? port, [NotNullWhen(false)] out ParsePortError? error)
195+
{
196+
var parts = input.Split('/');
197+
if (parts is [var portNumber, var type])
198+
{
199+
return TryParsePort(portNumber, type, out port, out error);
200+
}
201+
else if (parts is [var portNo])
202+
{
203+
return TryParsePort(portNo, null, out port, out error);
204+
}
205+
else
206+
{
207+
error = ParsePortError.UnknownPortFormat;
208+
port = null;
209+
return false;
210+
}
211+
}
212+
213+
public static async Task Containerize(DirectoryInfo folder, string workingDir, string registryName, string baseName, string baseTag, string[] entrypoint, string[] entrypointArgs, string imageName, string[] imageTags, string outputRegistry, string[] labels, Port[] exposedPorts)
148214
{
149215
Registry baseRegistry = new Registry(new Uri(registryName));
150216

@@ -172,10 +238,16 @@ public static async Task Containerize(DirectoryInfo folder, string workingDir, s
172238
{
173239
string[] labelPieces = label.Split('=');
174240

175-
// labels are validated by System.Commandline API
241+
// labels are validated by System.CommandLine API
176242
img.Label(labelPieces[0], labelPieces[1]);
177243
}
178244

245+
foreach (var (number, type) in exposedPorts)
246+
{
247+
// ports are validated by System.CommandLine API
248+
img.ExposePort(number, type);
249+
}
250+
179251
foreach (var tag in imageTags)
180252
{
181253
if (isDockerPush)
@@ -206,6 +278,5 @@ public static async Task Containerize(DirectoryInfo folder, string workingDir, s
206278
}
207279
}
208280
}
209-
210281
}
211-
}
282+
}

Microsoft.NET.Build.Containers/CreateNewImage.cs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,20 @@ public class CreateNewImage : Microsoft.Build.Utilities.Task
6666
/// </summary>
6767
public ITaskItem[] EntrypointArgs { get; set; }
6868

69+
/// <summary>
70+
/// Ports that the application declares that it will use.
71+
/// Note that this means nothing to container hosts, by default -
72+
/// it's mostly documentation.
73+
/// </summary>
74+
public ITaskItem[] ExposedPorts { get; set; }
75+
6976
/// <summary>
7077
/// Labels that the image configuration will include in metadata
7178
/// </summary>
7279
public ITaskItem[] Labels { get; set; }
7380

81+
private bool IsDockerPush { get => OutputRegistry == "docker://"; }
82+
7483
public CreateNewImage()
7584
{
7685
BaseRegistry = "";
@@ -84,8 +93,55 @@ public CreateNewImage()
8493
Entrypoint = Array.Empty<ITaskItem>();
8594
EntrypointArgs = Array.Empty<ITaskItem>();
8695
Labels = Array.Empty<ITaskItem>();
96+
ExposedPorts = Array.Empty<ITaskItem>();
8797
}
8898

99+
private void SetPorts(Image image, ITaskItem[] exposedPorts)
100+
{
101+
foreach (var port in exposedPorts)
102+
{
103+
var portNo = port.ItemSpec;
104+
var portTy = port.GetMetadata("Type");
105+
if (ContainerHelpers.TryParsePort(portNo, portTy, out var parsedPort, out var errors))
106+
{
107+
image.ExposePort(parsedPort.number, parsedPort.type);
108+
}
109+
else
110+
{
111+
ContainerHelpers.ParsePortError parsedErrors = (ContainerHelpers.ParsePortError)errors!;
112+
var portString = portTy == null ? portNo : $"{portNo}/{portTy}";
113+
if (parsedErrors.HasFlag(ContainerHelpers.ParsePortError.MissingPortNumber))
114+
{
115+
Log.LogError("ContainerPort item '{0}' does not specify the port number. Please ensure the item's Include is a port number, for example '<ContainerPort Include=\"80\" />'", port.ItemSpec);
116+
}
117+
else
118+
{
119+
var message = "A ContainerPort item was provided with ";
120+
var arguments = new List<string>(2);
121+
if (parsedErrors.HasFlag(ContainerHelpers.ParsePortError.InvalidPortNumber) && parsedErrors.HasFlag(ContainerHelpers.ParsePortError.InvalidPortNumber))
122+
{
123+
message += "an invalid port number '{0}' and an invalid port type '{1}'";
124+
arguments.Add(portNo);
125+
arguments.Add(portTy!);
126+
}
127+
else if (parsedErrors.HasFlag(ContainerHelpers.ParsePortError.InvalidPortNumber))
128+
{
129+
message += "an invalid port number '{0}'";
130+
arguments.Add(portNo);
131+
}
132+
else if (parsedErrors.HasFlag(ContainerHelpers.ParsePortError.InvalidPortNumber))
133+
{
134+
message += "an invalid port type '{0}'";
135+
arguments.Add(portTy!);
136+
}
137+
message += ". ContainerPort items must have an Include value that is an integer, and a Type value that is either 'tcp' or 'udp'";
138+
139+
Log.LogError(message, arguments);
140+
}
141+
}
142+
}
143+
144+
}
89145

90146
public override bool Execute()
91147
{
@@ -123,6 +179,14 @@ public override bool Execute()
123179
image.Label(label.ItemSpec, label.GetMetadata("Value"));
124180
}
125181

182+
SetPorts(image, ExposedPorts);
183+
184+
// at the end of this step, if any failed then bail out.
185+
if (Log.HasLoggedErrors)
186+
{
187+
return false;
188+
}
189+
126190
var isDockerPush = OutputRegistry.StartsWith("docker://");
127191
Registry? outputReg = isDockerPush ? null : new Registry(new Uri(OutputRegistry));
128192
foreach (var tag in ImageTags)
@@ -160,7 +224,6 @@ public override bool Execute()
160224
}
161225
}
162226
}
163-
164227
}
165228

166229
return !Log.HasLoggedErrors;

Microsoft.NET.Build.Containers/Image.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Security.Cryptography;
23
using System.Text;
34
using System.Text.Json;
@@ -7,6 +8,16 @@ namespace Microsoft.NET.Build.Containers;
78

89
record Label(string name, string value);
910

11+
// Explicitly lowercase to ease parsing - the incoming values are
12+
// lowercased by spec
13+
public enum PortType
14+
{
15+
tcp,
16+
udp
17+
}
18+
19+
public record Port(int number, PortType type);
20+
1021
public class Image
1122
{
1223
public JsonNode manifest;
@@ -19,14 +30,17 @@ public class Image
1930

2031
private HashSet<Label> labels;
2132

33+
internal HashSet<Port> exposedPorts;
34+
2235
public Image(JsonNode manifest, JsonNode config, string name, Registry? registry)
2336
{
2437
this.manifest = manifest;
2538
this.config = config;
2639
this.OriginatingName = name;
2740
this.originatingRegistry = registry;
28-
// labels are inherited from the parent image, so we need to seed our new image with them.
41+
// these next values are inherited from the parent image, so we need to seed our new image with them.
2942
this.labels = ReadLabelsFromConfig(config);
43+
this.exposedPorts = ReadPortsFromConfig(config);
3044
}
3145

3246
public IEnumerable<Descriptor> LayerDescriptors
@@ -67,6 +81,18 @@ private void RecalculateDigest()
6781
manifest["config"]!["digest"] = GetDigest(config);
6882
}
6983

84+
private JsonObject CreatePortMap()
85+
{
86+
// ports are entries in a key/value map whose keys are "<number>/<type>" and whose values are an empty object.
87+
// yes, this is odd.
88+
var container = new JsonObject();
89+
foreach (var port in exposedPorts)
90+
{
91+
container.Add($"{port.number}/{port.type}", new JsonObject());
92+
}
93+
return container;
94+
}
95+
7096
private static HashSet<Label> ReadLabelsFromConfig(JsonNode inputConfig)
7197
{
7298
if (inputConfig is JsonObject config && config["Labels"] is JsonObject labelsJson)
@@ -84,11 +110,35 @@ private static HashSet<Label> ReadLabelsFromConfig(JsonNode inputConfig)
84110
}
85111
else
86112
{
87-
// initialize and empty labels map
113+
// initialize an empty labels map
88114
return new HashSet<Label>();
89115
}
90116
}
91117

118+
private static HashSet<Port> ReadPortsFromConfig(JsonNode inputConfig)
119+
{
120+
if (inputConfig is JsonObject config && config["ExposedPorts"] is JsonObject portsJson)
121+
{
122+
// read label mappings from object
123+
var ports = new HashSet<Port>();
124+
foreach (var property in portsJson)
125+
{
126+
if (property.Key is { } propertyName
127+
&& property.Value is JsonObject propertyValue
128+
&& ContainerHelpers.TryParsePort(propertyName, out var parsedPort, out var _))
129+
{
130+
ports.Add(parsedPort);
131+
}
132+
}
133+
return ports;
134+
}
135+
else
136+
{
137+
// initialize an empty ports map
138+
return new HashSet<Port>();
139+
}
140+
}
141+
92142
private JsonObject CreateLabelMap()
93143
{
94144
var container = new JsonObject();
@@ -141,6 +191,13 @@ public void Label(string name, string value)
141191
RecalculateDigest();
142192
}
143193

194+
public void ExposePort(int number, PortType type)
195+
{
196+
exposedPorts.Add(new(number, type));
197+
config["config"]!["ExposedPorts"] = CreatePortMap();
198+
RecalculateDigest();
199+
}
200+
144201
public string GetDigest(JsonNode json)
145202
{
146203
string hashString;

Test.Microsoft.NET.Build.Containers/ContainerHelpersTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,27 @@ public void IsValidImageTag_InvalidLength()
7575
{
7676
Assert.AreEqual(false, ContainerHelpers.IsValidImageTag(new string('a', 129)));
7777
}
78+
79+
[TestMethod]
80+
[DataRow("80/tcp", true, 80, PortType.tcp, null)]
81+
[DataRow("80", true, 80, PortType.tcp, null)]
82+
[DataRow("125/dup", false, 125, PortType.tcp, ContainerHelpers.ParsePortError.InvalidPortType)]
83+
[DataRow("invalidNumber", false, null, null, ContainerHelpers.ParsePortError.InvalidPortNumber)]
84+
[DataRow("welp/unknowntype", false, null, null, (ContainerHelpers.ParsePortError)3)]
85+
[DataRow("a/b/c", false, null, null, ContainerHelpers.ParsePortError.UnknownPortFormat)]
86+
[DataRow("/tcp", false, null, null, ContainerHelpers.ParsePortError.MissingPortNumber)]
87+
public void CanParsePort(string input, bool shouldParse, int? expectedPortNumber, PortType? expectedType, ContainerHelpers.ParsePortError? expectedError) {
88+
var parseSuccess = ContainerHelpers.TryParsePort(input, out var port, out var errors);
89+
Assert.AreEqual<bool>(shouldParse, parseSuccess, $"{(shouldParse ? "Should" : "Shouldn't")} have parsed {input} into a port");
90+
91+
if (shouldParse) {
92+
Assert.IsNotNull(port);
93+
Assert.AreEqual(port.number, expectedPortNumber);
94+
Assert.AreEqual(port.type, expectedType);
95+
} else {
96+
Assert.IsNull(port);
97+
Assert.IsNotNull(errors);
98+
Assert.AreEqual(expectedError, errors);
99+
}
100+
}
78101
}

0 commit comments

Comments
 (0)