-
Notifications
You must be signed in to change notification settings - Fork 433
/
ManifestPublishingContext.cs
649 lines (529 loc) · 25 KB
/
ManifestPublishingContext.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
namespace Aspire.Hosting.Publishing;
/// <summary>
/// Contextual information used for manifest publishing during this execution of the AppHost.
/// </summary>
/// <param name="executionContext">Global contextual information for this invocation of the AppHost.</param>
/// <param name="manifestPath">Manifest path passed in for this invocation of the AppHost.</param>
/// <param name="writer">JSON writer used to writing the manifest.</param>
/// <param name="cancellationToken">Cancellation token for this operation.</param>
public sealed class ManifestPublishingContext(DistributedApplicationExecutionContext executionContext, string manifestPath, Utf8JsonWriter writer, CancellationToken cancellationToken = default)
{
/// <summary>
/// Gets execution context for this invocation of the AppHost.
/// </summary>
public DistributedApplicationExecutionContext ExecutionContext { get; } = executionContext;
/// <summary>
/// Gets manifest path specified for this invocation of the AppHost.
/// </summary>
public string ManifestPath { get; } = manifestPath;
/// <summary>
/// Gets JSON writer for writing manifest entries.
/// </summary>
public Utf8JsonWriter Writer { get; } = writer;
private PortAllocator PortAllocator { get; } = new();
/// <summary>
/// Gets cancellation token for this operation.
/// </summary>
public CancellationToken CancellationToken { get; } = cancellationToken;
private readonly Dictionary<string, IResource> _referencedResources = [];
private readonly HashSet<object?> _currentDependencySet = [];
/// <summary>
/// Generates a relative path based on the location of the manifest path.
/// </summary>
/// <param name="path">A path to a file.</param>
/// <returns>The specified path as a relative path to the manifest.</returns>
/// <exception cref="DistributedApplicationException">Throws when could not get the directory directory name from the output path.</exception>
[return: NotNullIfNotNull(nameof(path))]
public string? GetManifestRelativePath(string? path)
{
if (path is null)
{
return null;
}
var fullyQualifiedManifestPath = Path.GetFullPath(ManifestPath);
var manifestDirectory = Path.GetDirectoryName(fullyQualifiedManifestPath) ?? throw new DistributedApplicationException("Could not get directory name of output path");
var normalizedPath = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
var relativePath = Path.GetRelativePath(manifestDirectory, normalizedPath);
return relativePath.Replace('\\', '/');
}
internal async Task WriteModel(DistributedApplicationModel model, CancellationToken cancellationToken)
{
Writer.WriteStartObject();
Writer.WriteString("$schema", SchemaUtils.SchemaVersion);
Writer.WriteStartObject("resources");
foreach (var resource in model.Resources)
{
await WriteResourceAsync(resource).ConfigureAwait(false);
}
await WriteReferencedResources(model).ConfigureAwait(false);
Writer.WriteEndObject();
Writer.WriteEndObject();
await Writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
internal async Task WriteResourceAsync(IResource resource)
{
// First see if the resource has a callback annotation with overrides the behavior for rendering
// out the JSON. If so use that callback, otherwise use the fallback logic that we have.
if (resource.TryGetLastAnnotation<ManifestPublishingCallbackAnnotation>(out var manifestPublishingCallbackAnnotation))
{
if (manifestPublishingCallbackAnnotation.Callback != null)
{
await WriteResourceObjectAsync(resource, () => manifestPublishingCallbackAnnotation.Callback(this)).ConfigureAwait(false);
}
}
else if (resource is ContainerResource container)
{
await WriteResourceObjectAsync(container, () => WriteContainerAsync(container)).ConfigureAwait(false);
}
else if (resource is ProjectResource project)
{
await WriteResourceObjectAsync(project, () => WriteProjectAsync(project)).ConfigureAwait(false);
}
else if (resource is ExecutableResource executable)
{
await WriteResourceObjectAsync(executable, () => WriteExecutableAsync(executable)).ConfigureAwait(false);
}
else if (resource is IResourceWithConnectionString resourceWithConnectionString)
{
await WriteResourceObjectAsync(resource, () => WriteConnectionStringAsync(resourceWithConnectionString)).ConfigureAwait(false);
}
else if (resource is ParameterResource parameter)
{
await WriteResourceObjectAsync(parameter, () => WriteParameterAsync(parameter)).ConfigureAwait(false);
}
else
{
await WriteResourceObjectAsync(resource, WriteErrorAsync).ConfigureAwait(false);
}
async Task WriteResourceObjectAsync<T>(T resource, Func<Task> action) where T : IResource
{
Writer.WriteStartObject(resource.Name);
await action().ConfigureAwait(false);
Writer.WriteEndObject();
}
}
private Task WriteErrorAsync()
{
Writer.WriteString("error", "This resource does not support generation in the manifest.");
return Task.CompletedTask;
}
private Task WriteConnectionStringAsync(IResourceWithConnectionString resource)
{
// Write connection strings as value.v0
Writer.WriteString("type", "value.v0");
WriteConnectionString(resource);
return Task.CompletedTask;
}
private async Task WriteProjectAsync(ProjectResource project)
{
Writer.WriteString("type", "project.v0");
if (!project.TryGetLastAnnotation<IProjectMetadata>(out var metadata))
{
throw new DistributedApplicationException("Project metadata not found.");
}
var relativePathToProjectFile = GetManifestRelativePath(metadata.ProjectPath);
Writer.WriteString("path", relativePathToProjectFile);
await WriteCommandLineArgumentsAsync(project).ConfigureAwait(false);
await WriteEnvironmentVariablesAsync(project).ConfigureAwait(false);
WriteBindings(project);
}
private async Task WriteExecutableAsync(ExecutableResource executable)
{
Writer.WriteString("type", "executable.v0");
// Write the connection string if it exists.
WriteConnectionString(executable);
var relativePathToProjectFile = GetManifestRelativePath(executable.WorkingDirectory);
Writer.WriteString("workingDirectory", relativePathToProjectFile);
Writer.WriteString("command", executable.Command);
await WriteCommandLineArgumentsAsync(executable).ConfigureAwait(false);
await WriteEnvironmentVariablesAsync(executable).ConfigureAwait(false);
WriteBindings(executable);
}
internal Task WriteParameterAsync(ParameterResource parameter)
{
Writer.WriteString("type", "parameter.v0");
if (parameter.IsConnectionString)
{
Writer.WriteString("connectionString", parameter.ValueExpression);
}
Writer.WriteString("value", $"{{{parameter.Name}.inputs.value}}");
Writer.WriteStartObject("inputs");
Writer.WriteStartObject("value");
// https://github.com/Azure/azure-dev/issues/3487 tracks being able to remove this. All inputs are strings.
Writer.WriteString("type", "string");
if (parameter.Secret)
{
Writer.WriteBoolean("secret", true);
}
if (parameter.Default is not null)
{
Writer.WriteStartObject("default");
parameter.Default.WriteToManifest(this);
Writer.WriteEndObject();
}
Writer.WriteEndObject();
Writer.WriteEndObject();
return Task.CompletedTask;
}
/// <summary>
/// Writes JSON elements to the manifest which represent a container resource.
/// </summary>
/// <param name="container">The container resource to written to the manifest.</param>
/// <exception cref="DistributedApplicationException">Thrown if the container resource does not contain a <see cref="ContainerImageAnnotation"/>.</exception>
public async Task WriteContainerAsync(ContainerResource container)
{
if (container.Annotations.OfType<DockerfileBuildAnnotation>().Any())
{
Writer.WriteString("type", "container.v1");
WriteConnectionString(container);
WriteBuildContext(container);
}
else
{
Writer.WriteString("type", "container.v0");
if (!container.TryGetContainerImageName(out var image))
{
throw new DistributedApplicationException("Could not get container image name.");
}
WriteConnectionString(container);
Writer.WriteString("image", image);
}
if (container.Entrypoint is not null)
{
Writer.WriteString("entrypoint", container.Entrypoint);
}
// Write args if they are present
await WriteCommandLineArgumentsAsync(container).ConfigureAwait(false);
// Write volume & bind mount details
WriteContainerMounts(container);
await WriteEnvironmentVariablesAsync(container).ConfigureAwait(false);
WriteBindings(container);
}
private void WriteBuildContext(ContainerResource container)
{
if (container.TryGetAnnotationsOfType<DockerfileBuildAnnotation>(out var annotations) && annotations.Single() is { } annotation)
{
Writer.WriteStartObject("build");
Writer.WriteString("context", GetManifestRelativePath(annotation.ContextPath));
Writer.WriteString("dockerfile", GetManifestRelativePath(annotation.DockerfilePath));
if (annotation.Stage is { } stage)
{
Writer.WriteString("stage", stage);
}
if (annotation.BuildArguments.Count > 0)
{
Writer.WriteStartObject("args");
foreach (var (key, value) in annotation.BuildArguments)
{
var valueString = value switch
{
string stringValue => stringValue,
IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression,
bool boolValue => boolValue ? "true" : "false",
null => null, // null means let docker build pull from env var.
_ => value.ToString()
};
Writer.WriteString(key, valueString);
}
Writer.WriteEndObject();
}
if (annotation.BuildSecrets.Count > 0)
{
Writer.WriteStartObject("secrets");
foreach (var (key, value) in annotation.BuildSecrets)
{
var valueString = value switch
{
FileInfo fileValue => GetManifestRelativePath(fileValue.FullName),
string stringValue => stringValue,
IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression,
bool boolValue => boolValue ? "true" : "false",
null => null, // null means let docker build pull from env var.
_ => value.ToString()
};
Writer.WriteStartObject(key);
if (value is FileInfo)
{
Writer.WriteString("type", "file");
Writer.WriteString("source", valueString);
}
else
{
Writer.WriteString("type", "env");
Writer.WriteString("value", valueString);
}
Writer.WriteEndObject();
}
Writer.WriteEndObject();
}
Writer.WriteEndObject();
}
}
/// <summary>
/// Writes the "connectionString" field for the underlying resource.
/// </summary>
/// <param name="resource">The <see cref="IResource"/>.</param>
public void WriteConnectionString(IResource resource)
{
if (resource is IResourceWithConnectionString resourceWithConnectionString &&
resourceWithConnectionString.ConnectionStringExpression is { } connectionString)
{
Writer.WriteString("connectionString", connectionString.ValueExpression);
}
}
/// <summary>
/// Writes endpoints to the resource entry in the manifest based on the resource's
/// <see cref="EndpointAnnotation"/> entries in the <see cref="IResource.Annotations"/>
/// collection.
/// </summary>
/// <param name="resource">The <see cref="IResource"/> that contains <see cref="EndpointAnnotation"/> annotations.</param>
public void WriteBindings(IResource resource)
{
if (resource.TryGetEndpoints(out var endpoints))
{
// This is used to determine if an endpoint should be treated as the Default endpoint.
// Endpoints can come from 3 different sources (in this order):
// 1. Kestrel configuration
// 2. Default endpoints added by the framework
// 3. Explicitly added endpoints
// But wherever they come from, we treat the first one as Default, for each scheme.
var httpSchemesEncountered = new HashSet<string>();
static bool IsHttpScheme(string scheme) => scheme is "http" or "https";
Writer.WriteStartObject("bindings");
foreach (var endpoint in endpoints)
{
Writer.WriteStartObject(endpoint.Name);
Writer.WriteString("scheme", endpoint.UriScheme);
Writer.WriteString("protocol", endpoint.Protocol.ToString().ToLowerInvariant());
Writer.WriteString("transport", endpoint.Transport);
int? targetPort = (resource, endpoint.UriScheme, endpoint.TargetPort, endpoint.Port) switch
{
// The port was specified so use it
(_, _, int target, _) => target,
// Container resources get their default listening port from the exposed port.
(ContainerResource, _, null, int port) => port,
// Check whether the project view this endpoint as Default (for its scheme).
// If so, we don't specify the target port, as it will get one from the deployment tool.
(ProjectResource project, string uriScheme, null, _) when IsHttpScheme(uriScheme) && !httpSchemesEncountered.Contains(uriScheme) => null,
// Allocate a dynamic port
_ => PortAllocator.AllocatePort()
};
// We only keep track of schemes for project resources, since we don't want
// a non-project scheme to affect what project endpoints are considered default.
if (resource is ProjectResource && IsHttpScheme(endpoint.UriScheme))
{
httpSchemesEncountered.Add(endpoint.UriScheme);
}
int? exposedPort = (endpoint.UriScheme, endpoint.Port, targetPort) switch
{
// Exposed port and target port are the same, we don't need to mention the exposed port
(_, int p0, int p1) when p0 == p1 => null,
// Port was specified, so use it
(_, int port, _) => port,
// We have a target port, not need to specify an exposedPort
// it will default to the targetPort
(_, null, int port) => null,
// Let the tool infer the default http and https ports
("http", null, null) => null,
("https", null, null) => null,
// Other schemes just allocate a port
_ => PortAllocator.AllocatePort()
};
if (exposedPort is int ep)
{
PortAllocator.AddUsedPort(ep);
Writer.WriteNumber("port", ep);
}
if (targetPort is int tp)
{
PortAllocator.AddUsedPort(tp);
Writer.WriteNumber("targetPort", tp);
}
if (endpoint.IsExternal)
{
Writer.WriteBoolean("external", endpoint.IsExternal);
}
Writer.WriteEndObject();
}
Writer.WriteEndObject();
}
}
/// <summary>
/// Writes environment variables to the manifest base on the <see cref="IResource"/> resource's <see cref="EnvironmentCallbackAnnotation"/> annotations."/>
/// </summary>
/// <param name="resource">The <see cref="IResource"/> which contains <see cref="EnvironmentCallbackAnnotation"/> annotations.</param>
public async Task WriteEnvironmentVariablesAsync(IResource resource)
{
var config = new Dictionary<string, object>();
var envContext = new EnvironmentCallbackContext(ExecutionContext, config, CancellationToken);
if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var callbacks))
{
Writer.WriteStartObject("env");
foreach (var callback in callbacks)
{
await callback.Callback(envContext).ConfigureAwait(false);
}
foreach (var (key, value) in config)
{
var valueString = value switch
{
string stringValue => stringValue,
IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression,
_ => throw new DistributedApplicationException($"The value of the environment variable '{key}' is not supported.")
};
Writer.WriteString(key, valueString);
TryAddDependentResources(value);
}
Writer.WriteEndObject();
}
}
/// <summary>
/// Writes command line arguments to the manifest based on the <see cref="IResource"/> resource's <see cref="CommandLineArgsCallbackAnnotation"/> annotations.
/// </summary>
/// <param name="resource">The <see cref="IResource"/> that contains <see cref="CommandLineArgsCallbackAnnotation"/> annotations.</param>
/// <returns>The <see cref="Task"/> to await for completion.</returns>
public async Task WriteCommandLineArgumentsAsync(IResource resource)
{
var args = new List<object>();
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var argsCallback))
{
var commandLineArgsContext = new CommandLineArgsCallbackContext(args, CancellationToken);
foreach (var callback in argsCallback)
{
await callback.Callback(commandLineArgsContext).ConfigureAwait(false);
}
}
if (args.Count > 0)
{
Writer.WriteStartArray("args");
foreach (var arg in args)
{
var valueString = arg switch
{
string stringValue => stringValue,
IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression,
_ => throw new DistributedApplicationException($"The value of the argument '{arg}' is not supported.")
};
Writer.WriteStringValue(valueString);
TryAddDependentResources(arg);
}
Writer.WriteEndArray();
}
}
internal void WriteDockerBuildArgs(IEnumerable<DockerBuildArg>? buildArgs)
{
if (buildArgs?.ToArray() is { Length: > 0 } args)
{
Writer.WriteStartObject("buildArgs");
for (var i = 0; i < args.Length; i++)
{
var buildArg = args[i];
var valueString = buildArg.Value switch
{
string stringValue => stringValue,
IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression,
bool boolValue => boolValue ? "true" : "false",
null => null, // null means let docker build pull from env var.
_ => buildArg.Value.ToString()
};
Writer.WriteString(buildArg.Name, valueString);
TryAddDependentResources(buildArg.Value);
}
Writer.WriteEndObject();
}
}
private void WriteContainerMounts(ContainerResource container)
{
if (container.TryGetAnnotationsOfType<ContainerMountAnnotation>(out var mounts))
{
// Write out details for bind mounts
var bindMounts = mounts.Where(mounts => mounts.Type == ContainerMountType.BindMount).ToList();
if (bindMounts.Count > 0)
{
// Bind mounts are written as an array of objects to be consistent with volumes
Writer.WriteStartArray("bindMounts");
foreach (var bindMount in bindMounts)
{
Writer.WriteStartObject();
Writer.WritePropertyName("source");
var manifestRelativeSource = GetManifestRelativePath(bindMount.Source);
Writer.WriteStringValue(manifestRelativeSource);
Writer.WritePropertyName("target");
Writer.WriteStringValue(bindMount.Target.Replace('\\', '/'));
Writer.WriteBoolean("readOnly", bindMount.IsReadOnly);
Writer.WriteEndObject();
}
Writer.WriteEndArray();
}
// Write out details for volumes
var volumes = mounts.Where(mounts => mounts.Type == ContainerMountType.Volume).ToList();
if (volumes.Count > 0)
{
// Volumes are written as an array of objects as anonymous volumes do not have a name
Writer.WriteStartArray("volumes");
foreach (var volume in volumes)
{
Writer.WriteStartObject();
// This can be null for anonymous volumes
if (volume.Source is not null)
{
Writer.WritePropertyName("name");
Writer.WriteStringValue(volume.Source);
}
Writer.WritePropertyName("target");
Writer.WriteStringValue(volume.Target);
Writer.WriteBoolean("readOnly", volume.IsReadOnly);
Writer.WriteEndObject();
}
Writer.WriteEndArray();
}
}
}
/// <summary>
/// Ensures that any <see cref="IResource"/> instances referenced by <paramref name="value"/> are
/// written to the manifest.
/// </summary>
/// <param name="value">The object to check for references that may be resources that need to be written.</param>
public void TryAddDependentResources(object? value)
{
if (value is IResource resource)
{
// add the resource to the ReferencedResources for now. After the whole model is written,
// these will be written to the manifest
_referencedResources.TryAdd(resource.Name, resource);
}
else if (value is IValueWithReferences objectWithReferences)
{
// ensure we don't infinitely recurse if there are cycles in the graph
_currentDependencySet.Add(value);
foreach (var dependency in objectWithReferences.References)
{
if (!_currentDependencySet.Contains(dependency))
{
TryAddDependentResources(dependency);
}
}
_currentDependencySet.Remove(value);
}
}
private async Task WriteReferencedResources(DistributedApplicationModel model)
{
// remove references that were already in the model and were already written
foreach (var existingResource in model.Resources)
{
_referencedResources.Remove(existingResource.Name);
}
// now write all the leftover referenced resources
foreach (var resource in _referencedResources.Values)
{
await WriteResourceAsync(resource).ConfigureAwait(false);
}
_referencedResources.Clear();
}
}