public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder application, IngressBuilder ingress) { // This code assumes that in the future we might support other ingress types besides nginx. // // Right now we only know some hardcoded details about ingress-nginx that we use for both // validation and generation of manifests. // // For instance we don't support k8s 1.18.X IngressClass resources because that version // isn't broadly available yet. var ingressClass = "nginx"; if (!IngressClasses.Add(ingressClass)) { output.WriteDebugLine($"Already validated ingress class '{ingressClass}'."); return; } if (await KubectlDetector.GetKubernetesServerVersion(output) == null) { throw new CommandException($"Cannot validate ingress because kubectl is not installed."); } if (!await KubectlDetector.IsKubectlConnectedToClusterAsync(output)) { throw new CommandException($"Cannot validate ingress because kubectl is not connected to a cluster."); } output.WriteDebugLine($"Validating ingress class '{ingressClass}'."); var config = KubernetesClientConfiguration.BuildDefaultConfig(); // If namespace is null, set it to default config.Namespace ??= "default"; var kubernetes = new Kubernetes(config); // Looking for a deployment using a standard label. // Note: using a deployment instead of a service - minikube doesn't create a service for the controller. try { var result = await kubernetes.ListDeploymentForAllNamespacesWithHttpMessagesAsync( labelSelector : "app.kubernetes.io/name in (ingress-nginx, nginx-ingress-controller)"); if (result.Body.Items.Count > 0) { foreach (var service in result.Body.Items) { output.WriteInfoLine($"Found existing ingress controller '{service.Metadata.Name}' in namespace '{service.Metadata.NamespaceProperty}'."); } return; } } catch (HttpOperationException ex) when(ex.Response.StatusCode == HttpStatusCode.NotFound) { // The kubernetes client uses exceptions for 404s. } catch (Exception ex) { output.WriteDebugLine("Failed to query secret."); output.WriteDebugLine(ex.ToString()); throw new CommandException("Unable connect to kubernetes.", ex); } if (Force) { output.WriteDebugLine("Skipping because force was specified."); return; } if (!Interactive) { throw new CommandException( $"An ingress was specified for the application, but the 'ingress-nginx' controller could not be found. " + $"Rerun the command with --interactive to deploy a controller for development, or with --force to skip validation. Alternatively " + $"see our documentation on ingress: https://aka.ms/tye/ingress"); } output.WriteAlwaysLine( "Tye can deploy the ingress-nginx controller for you. This will be a basic deployment suitable for " + "experimentation and development. Your production needs, or requirements may differ depending on your Kubernetes distribution. " + "See: https://aka.ms/tye/ingress for documentation."); if (!output.Confirm($"Deploy ingress-nginx")) { // user skipped deployment of ingress, continue with deployment. return; } // We want to be able to detect minikube because the process for enabling nginx-ingress is different there, // it's shipped as an addon. // // see: https://github.com/telepresenceio/telepresence/blob/4364fd83d5926bef46babd704e7bd6c82a75dbd6/telepresence/startup.py#L220 if (config.CurrentContext == "minikube") { output.WriteDebugLine($"Running 'minikube addons enable ingress'"); output.WriteCommandLine("minikube", "addon enable ingress"); var capture = output.Capture(); var exitCode = await ProcessUtil.ExecuteAsync( $"minikube", $"addons enable ingress", System.Environment.CurrentDirectory, stdOut : capture.StdOut, stdErr : capture.StdErr); output.WriteDebugLine($"Done running 'minikube addons enable ingress' exit code: {exitCode}"); if (exitCode != 0) { throw new CommandException("'minikube addons enable ingress' failed."); } output.WriteInfoLine($"Deployed ingress-nginx."); } else { // If we get here then we should deploy the ingress controller. // The first time we apply the ingress controller, the validating webhook will not have started. // This causes an error to be returned from the process. As this always happens, we are going to // not check the error returned and assume the kubectl command worked. This is double checked in // the future as well when we try to create the ingress resource. output.WriteDebugLine($"Running 'kubectl apply'"); output.WriteCommandLine("kubectl", $"apply -f \"https://aka.ms/tye/ingress/deploy\""); var capture = output.Capture(); var exitCode = await ProcessUtil.ExecuteAsync( $"kubectl", $"apply -f \"https://aka.ms/tye/ingress/deploy\"", System.Environment.CurrentDirectory); output.WriteDebugLine($"Done running 'kubectl apply' exit code: {exitCode}"); output.WriteInfoLine($"Waiting for ingress-nginx controller to start."); // We need to then wait for the webhooks that are created by ingress-nginx to start. Deploying an ingress immediately // after creating the controller will fail if the webhook isn't ready. // // Internal error occurred: failed calling webhook "validate.nginx.ingress.kubernetes.io": // Post https://ingress-nginx-controller-admission.ingress-nginx.svc:443/networking.k8s.io/v1/ingresses?timeout=30s: // dial tcp 10.0.31.130:443: connect: connection refused // // Unfortunately this is the likely case for us. try { output.WriteDebugLine("Watching for ingress-nginx controller readiness..."); var response = await kubernetes.ListNamespacedPodWithHttpMessagesAsync( namespaceParameter : "ingress-nginx", labelSelector : "app.kubernetes.io/component=controller,app.kubernetes.io/name=ingress-nginx", watch : true); var tcs = new TaskCompletionSource <object?>(); using var watcher = response.Watch <V1Pod, V1PodList>( onEvent: (@event, pod) => { // Wait for the readiness-check to pass. if (pod.Status.Conditions.All(c => string.Equals(c.Status, bool.TrueString, StringComparison.OrdinalIgnoreCase))) { tcs.TrySetResult(null); // Success! output.WriteDebugLine($"Pod '{pod.Metadata.Name}' is ready."); } }, onError: ex => { tcs.TrySetException(ex); output.WriteDebugLine("Watch operation failed."); }, onClosed: () => { // YOLO? tcs.TrySetResult(null); output.WriteDebugLine("Watch operation completed."); }); await tcs.Task; } catch (Exception ex) { output.WriteDebugLine("Failed to ingress-nginx pods."); output.WriteDebugLine(ex.ToString()); throw new CommandException("Failed to query ingress-nginx pods.", ex); } output.WriteInfoLine($"Deployed ingress-nginx."); } }
public static async Task <ApplicationBuilder> CreateAsync(OutputContext output, FileInfo source) { if (source is null) { throw new ArgumentNullException(nameof(source)); } var queue = new Queue <(ConfigApplication, HashSet <string>)>(); var visited = new HashSet <string>(StringComparer.OrdinalIgnoreCase); var rootConfig = ConfigFactory.FromFile(source); rootConfig.Validate(); var root = new ApplicationBuilder(source, rootConfig.Name !); root.Namespace = rootConfig.Namespace; queue.Enqueue((rootConfig, new HashSet <string>())); while (queue.Count > 0) { var item = queue.Dequeue(); var config = item.Item1; // dependencies represents a set of all dependencies var dependencies = item.Item2; if (!visited.Add(config.Source.FullName)) { continue; } if (config == rootConfig && !string.IsNullOrEmpty(config.Registry)) { root.Registry = new ContainerRegistry(config.Registry); } if (config == rootConfig) { root.Network = rootConfig.Network; } foreach (var configExtension in config.Extensions) { var extension = new ExtensionConfiguration((string)configExtension["name"]); foreach (var kvp in configExtension) { if (kvp.Key == "name") { continue; } extension.Data.Add(kvp.Key, kvp.Value); } root.Extensions.Add(extension); } foreach (var configService in config.Services) { ServiceBuilder service; if (root.Services.Any(s => s.Name == configService.Name)) { // Even though this service has already created a service, we still need // to update dependency information AddToRootServices(root, dependencies, configService.Name); continue; } if (!string.IsNullOrEmpty(configService.Project)) { var expandedProject = Environment.ExpandEnvironmentVariables(configService.Project); var projectFile = new FileInfo(Path.Combine(config.Source.DirectoryName, expandedProject)); var project = new DotnetProjectServiceBuilder(configService.Name !, projectFile); service = project; project.Build = configService.Build ?? true; project.Args = configService.Args; foreach (var buildProperty in configService.BuildProperties) { project.BuildProperties.Add(buildProperty.Name, buildProperty.Value); } project.Replicas = configService.Replicas ?? 1; project.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; project.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. project.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; await ProjectReader.ReadProjectDetailsAsync(output, project); // Do k8s by default. project.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Image)) { var container = new ContainerServiceBuilder(configService.Name !, configService.Image !) { Args = configService.Args, Replicas = configService.Replicas ?? 1 }; service = container; container.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; container.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.DockerFile)) { var dockerFile = new DockerFileServiceBuilder(configService.Name !, configService.Image !) { Args = configService.Args, Build = configService.Build ?? true, Replicas = configService.Replicas ?? 1, DockerFile = Path.Combine(source.DirectoryName, configService.DockerFile), // Supplying an absolute path with trailing slashes fails for DockerFileContext when calling docker build, so trim trailing slash. DockerFileContext = GetDockerFileContext(source, configService) }; service = dockerFile; dockerFile.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; dockerFile.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. dockerFile.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; // Do k8s by default. dockerFile.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Executable)) { var expandedExecutable = Environment.ExpandEnvironmentVariables(configService.Executable); var workingDirectory = ""; // Special handling of .dlls as executables (it will be executed as dotnet {dll}) if (Path.GetExtension(expandedExecutable) == ".dll") { expandedExecutable = Path.GetFullPath(Path.Combine(config.Source.Directory.FullName, expandedExecutable)); workingDirectory = Path.GetDirectoryName(expandedExecutable) !; } var executable = new ExecutableServiceBuilder(configService.Name !, expandedExecutable) { Args = configService.Args, WorkingDirectory = configService.WorkingDirectory != null? Path.GetFullPath(Path.Combine(config.Source.Directory.FullName, Environment.ExpandEnvironmentVariables(configService.WorkingDirectory))) : workingDirectory, Replicas = configService.Replicas ?? 1 }; service = executable; executable.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; executable.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.Include)) { var expandedYaml = Environment.ExpandEnvironmentVariables(configService.Include); var nestedConfig = GetNestedConfig(rootConfig, Path.Combine(config.Source.DirectoryName, expandedYaml)); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (!string.IsNullOrEmpty(configService.Repository)) { // clone to .tye folder var path = Path.Join(rootConfig.Source.DirectoryName, ".tye", "deps"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } var clonePath = Path.Combine(path, configService.Name); if (!Directory.Exists(clonePath)) { if (!await GitDetector.Instance.IsGitInstalled.Value) { throw new CommandException($"Cannot clone repository {configService.Repository} because git is not installed. Please install git if you'd like to use \"repository\" in tye.yaml."); } var result = await ProcessUtil.RunAsync("git", $"clone {configService.Repository} {clonePath}", workingDirectory : path, throwOnError : false); if (result.ExitCode != 0) { throw new CommandException($"Failed to clone repository {configService.Repository} with exit code {result.ExitCode}.{Environment.NewLine}{result.StandardError}{result.StandardOutput}."); } } if (!ConfigFileFinder.TryFindSupportedFile(clonePath, out var file, out var errorMessage)) { throw new CommandException(errorMessage !); } // pick different service type based on what is in the repo. var nestedConfig = GetNestedConfig(rootConfig, file); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (configService.External) { var external = new ExternalServiceBuilder(configService.Name !); service = external; } else { throw new CommandException("Unable to determine service type."); } // Add dependencies to ourself before adding ourself to avoid self reference service.Dependencies.UnionWith(dependencies); AddToRootServices(root, dependencies, service.Name); root.Services.Add(service); // If there are no bindings and we're in ASP.NET Core project then add an HTTP and HTTPS binding if (configService.Bindings.Count == 0 && service is ProjectServiceBuilder project2 && project2.IsAspNet) { // HTTP is the default binding service.Bindings.Add(new BindingBuilder() { Protocol = "http" }); service.Bindings.Add(new BindingBuilder() { Name = "https", Protocol = "https" }); }
public static async Task <ApplicationBuilder> CreateAsync(OutputContext output, FileInfo source, string?framework = null, ApplicationFactoryFilter?filter = null) { if (source is null) { throw new ArgumentNullException(nameof(source)); } var queue = new Queue <(ConfigApplication, HashSet <string>)>(); var visited = new HashSet <string>(StringComparer.OrdinalIgnoreCase); var rootConfig = ConfigFactory.FromFile(source); rootConfig.Validate(); var root = new ApplicationBuilder(source, rootConfig.Name !, new ContainerEngine(rootConfig.ContainerEngineType), rootConfig.DashboardPort) { Namespace = rootConfig.Namespace }; queue.Enqueue((rootConfig, new HashSet <string>())); while (queue.TryDequeue(out var item)) { // dependencies represents a set of all dependencies var(config, dependencies) = item; if (!visited.Add(config.Source.FullName)) { continue; } if (config == rootConfig && !string.IsNullOrEmpty(config.Registry)) { root.Registry = new ContainerRegistry(config.Registry); } if (config == rootConfig) { root.Network = rootConfig.Network; } foreach (var configExtension in config.Extensions) { var extension = new ExtensionConfiguration((string)configExtension["name"]); foreach (var kvp in configExtension) { if (kvp.Key == "name") { continue; } extension.Data.Add(kvp.Key, kvp.Value); } root.Extensions.Add(extension); } var services = filter?.ServicesFilter != null? config.Services.Where(filter.ServicesFilter).ToList() : config.Services; var sw = Stopwatch.StartNew(); // Project services will be restored and evaluated before resolving all other services. // This batching will mitigate the performance cost of running MSBuild out of process. var projectServices = services.Where(s => !string.IsNullOrEmpty(s.Project)); var projectMetadata = new Dictionary <string, string>(); var msbuildEvaluationResult = await EvaluateProjectsAsync( projects : projectServices, configRoot : config.Source.DirectoryName !, output : output); var msbuildEvaluationOutput = msbuildEvaluationResult .StandardOutput .Split(Environment.NewLine); var multiTFMProjects = new List <ConfigService>(); foreach (var line in msbuildEvaluationOutput) { var trimmed = line.Trim(); if (trimmed.StartsWith("Microsoft.Tye metadata: ")) { var values = line.Split(':', 3); var projectName = values[1].Trim(); var metadataPath = values[2].Trim(); projectMetadata.Add(projectName, metadataPath); output.WriteDebugLine($"Resolved metadata for service {projectName} at {metadataPath}"); } else if (trimmed.StartsWith("Microsoft.Tye cross-targeting project: ")) { var values = line.Split(':', 2); var projectName = values[1].Trim(); var multiTFMConfigService = projectServices.First(p => string.Equals(p.Name, projectName, StringComparison.OrdinalIgnoreCase)); multiTFMConfigService.BuildProperties.Add(new BuildProperty { Name = "TargetFramework", Value = framework ?? string.Empty }); multiTFMProjects.Add(multiTFMConfigService); } } if (multiTFMProjects.Any()) { output.WriteDebugLine("Re-evaluating multi-targeted projects"); var multiTFMEvaluationResult = await EvaluateProjectsAsync( projects : multiTFMProjects, configRoot : config.Source.DirectoryName !, output : output); var multiTFMEvaluationOutput = multiTFMEvaluationResult .StandardOutput .Split(Environment.NewLine); foreach (var line in multiTFMEvaluationOutput) { var trimmed = line.Trim(); if (trimmed.StartsWith("Microsoft.Tye metadata: ")) { var values = line.Split(':', 3); var projectName = values[1].Trim(); var metadataPath = values[2].Trim(); projectMetadata.Add(projectName, metadataPath); output.WriteDebugLine($"Resolved metadata for service {projectName} at {metadataPath}"); } else if (trimmed.StartsWith("Microsoft.Tye cross-targeting project: ")) { var values = line.Split(':', 2); var projectName = values[1].Trim(); throw new CommandException($"Unable to run {projectName}. Your project targets multiple frameworks. Specify which framework to run using '--framework' or a build property in tye.yaml."); } } } output.WriteDebugLine($"Restore and project evaluation took: {sw.Elapsed.TotalMilliseconds}ms"); foreach (var configService in services) { ServiceBuilder service; if (root.Services.Any(s => s.Name == configService.Name)) { // Even though this service has already created a service, we still need // to update dependency information AddToRootServices(root, dependencies, configService.Name); continue; } if (!string.IsNullOrEmpty(configService.Project)) { // TODO: Investigate possible null. var project = new DotnetProjectServiceBuilder(configService.Name !, new FileInfo(configService.ProjectFullPath !), ServiceSource.Configuration); service = project; project.Build = configService.Build ?? true; project.Args = configService.Args; foreach (var buildProperty in configService.BuildProperties) { project.BuildProperties.Add(buildProperty.Name, buildProperty.Value); } project.Replicas = configService.Replicas ?? 1; project.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; project.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. project.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; // If project evaluation is successful this should not happen, therefore an exception will be thrown. if (!projectMetadata.ContainsKey(configService.Name)) { throw new CommandException($"Evaluated project metadata file could not be found for service {configService.Name}"); } ProjectReader.ReadProjectDetails(output, project, projectMetadata[configService.Name]); // Do k8s by default. project.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Image)) { var container = new ContainerServiceBuilder(configService.Name !, configService.Image !, ServiceSource.Configuration) { Args = configService.Args, Replicas = configService.Replicas ?? 1 }; service = container; container.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; container.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.DockerFile)) { var dockerFile = new DockerFileServiceBuilder(configService.Name !, configService.Image !, ServiceSource.Configuration) { Args = configService.Args, Build = configService.Build ?? true, Replicas = configService.Replicas ?? 1, DockerFile = Path.Combine(source.DirectoryName !, configService.DockerFile), // Supplying an absolute path with trailing slashes fails for DockerFileContext when calling docker build, so trim trailing slash. DockerFileContext = GetDockerFileContext(source, configService), BuildArgs = configService.DockerFileArgs }; service = dockerFile; dockerFile.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; dockerFile.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. dockerFile.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; // Do k8s by default. dockerFile.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Executable)) { var expandedExecutable = Environment.ExpandEnvironmentVariables(configService.Executable); var workingDirectory = ""; // Special handling of .dlls as executables (it will be executed as dotnet {dll}) if (Path.GetExtension(expandedExecutable) == ".dll") { expandedExecutable = Path.GetFullPath(Path.Combine(config.Source.Directory !.FullName, expandedExecutable)); workingDirectory = Path.GetDirectoryName(expandedExecutable) !; } var executable = new ExecutableServiceBuilder(configService.Name !, expandedExecutable, ServiceSource.Configuration) { Args = configService.Args, WorkingDirectory = configService.WorkingDirectory != null? Path.GetFullPath(Path.Combine(config.Source.Directory !.FullName, Environment.ExpandEnvironmentVariables(configService.WorkingDirectory))) : workingDirectory, Replicas = configService.Replicas ?? 1 }; service = executable; executable.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; executable.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.Include)) { var expandedYaml = Environment.ExpandEnvironmentVariables(configService.Include); var nestedConfig = GetNestedConfig(rootConfig, Path.Combine(config.Source.DirectoryName !, expandedYaml)); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (!string.IsNullOrEmpty(configService.Repository)) { // clone to .tye folder var path = configService.CloneDirectory ?? Path.Join(".tye", "deps"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } var clonePath = Path.Combine(rootConfig.Source.DirectoryName !, path, configService.Name); if (!Directory.Exists(clonePath)) { if (!await GitDetector.Instance.IsGitInstalled.Value) { throw new CommandException($"Cannot clone repository {configService.Repository} because git is not installed. Please install git if you'd like to use \"repository\" in tye.yaml."); } var result = await ProcessUtil.RunAsync("git", $"clone {configService.Repository} \"{clonePath}\"", workingDirectory : rootConfig.Source.DirectoryName, throwOnError : false); if (result.ExitCode != 0) { throw new CommandException($"Failed to clone repository {configService.Repository} with exit code {result.ExitCode}.{Environment.NewLine}{result.StandardError}{result.StandardOutput}."); } } if (!ConfigFileFinder.TryFindSupportedFile(clonePath, out var file, out var errorMessage)) { throw new CommandException(errorMessage !); } // pick different service type based on what is in the repo. var nestedConfig = GetNestedConfig(rootConfig, file); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (!string.IsNullOrEmpty(configService.AzureFunction)) { var azureFunctionDirectory = Path.Combine(config.Source.DirectoryName !, configService.AzureFunction); var functionBuilder = new AzureFunctionServiceBuilder( configService.Name, azureFunctionDirectory, ServiceSource.Configuration) { Args = configService.Args, Replicas = configService.Replicas ?? 1, FuncExecutablePath = configService.FuncExecutable, }; foreach (var proj in Directory.EnumerateFiles(azureFunctionDirectory)) { var fileInfo = new FileInfo(proj); if (fileInfo.Extension == ".csproj" || fileInfo.Extension == ".fsproj") { functionBuilder.ProjectFile = fileInfo.FullName; break; } } // TODO liveness? service = functionBuilder; } else if (configService.External) { var external = new ExternalServiceBuilder(configService.Name, ServiceSource.Configuration); service = external; } else { throw new CommandException("Unable to determine service type."); } // Add dependencies to ourself before adding ourself to avoid self reference service.Dependencies.UnionWith(dependencies); AddToRootServices(root, dependencies, service.Name); root.Services.Add(service); // If there are no bindings and we're in ASP.NET Core project then add an HTTP and HTTPS binding if (configService.Bindings.Count == 0 && service is ProjectServiceBuilder project2 && project2.IsAspNet) { // HTTP is the default binding service.Bindings.Add(new BindingBuilder() { Protocol = "http" }); service.Bindings.Add(new BindingBuilder() { Name = "https", Protocol = "https" }); }
public static async Task <ApplicationBuilder> CreateAsync(OutputContext output, FileInfo source, ApplicationFactoryFilter?filter = null) { if (source is null) { throw new ArgumentNullException(nameof(source)); } var queue = new Queue <(ConfigApplication, HashSet <string>)>(); var visited = new HashSet <string>(StringComparer.OrdinalIgnoreCase); var rootConfig = ConfigFactory.FromFile(source); rootConfig.Validate(); var root = new ApplicationBuilder(source, rootConfig.Name !); root.Namespace = rootConfig.Namespace; queue.Enqueue((rootConfig, new HashSet <string>())); while (queue.Count > 0) { var item = queue.Dequeue(); var config = item.Item1; // dependencies represents a set of all dependencies var dependencies = item.Item2; if (!visited.Add(config.Source.FullName)) { continue; } if (config == rootConfig && !string.IsNullOrEmpty(config.Registry)) { root.Registry = new ContainerRegistry(config.Registry); } if (config == rootConfig) { root.Network = rootConfig.Network; } foreach (var configExtension in config.Extensions) { var extension = new ExtensionConfiguration((string)configExtension["name"]); foreach (var kvp in configExtension) { if (kvp.Key == "name") { continue; } extension.Data.Add(kvp.Key, kvp.Value); } root.Extensions.Add(extension); } var services = filter?.ServicesFilter != null? config.Services.Where(filter.ServicesFilter).ToList() : config.Services; var sw = Stopwatch.StartNew(); // Project services will be restored and evaluated before resolving all other services. // This batching will mitigate the performance cost of running MSBuild out of process. var projectServices = services.Where(s => !string.IsNullOrEmpty(s.Project)); var projectMetadata = new Dictionary <string, string>(); using (var directory = TempDirectory.Create()) { var projectPath = Path.Combine(directory.DirectoryPath, Path.GetRandomFileName() + ".proj"); var sb = new StringBuilder(); sb.AppendLine("<Project>"); sb.AppendLine(" <ItemGroup>"); foreach (var project in projectServices) { var expandedProject = Environment.ExpandEnvironmentVariables(project.Project !); project.ProjectFullPath = Path.Combine(config.Source.DirectoryName !, expandedProject); if (!File.Exists(project.ProjectFullPath)) { throw new CommandException($"Failed to locate project: '{project.ProjectFullPath}'."); } sb.AppendLine($" <MicrosoftTye_ProjectServices " + $"Include=\"{project.ProjectFullPath}\" " + $"Name=\"{project.Name}\" " + $"BuildProperties=\"{(project.BuildProperties.Any() ? project.BuildProperties.Select(kvp => $"{kvp.Name}={kvp.Value}").Aggregate((a, b) => a + ";" + b) : string.Empty)}\" />"); } sb.AppendLine(@" </ItemGroup>"); sb.AppendLine($@" <Target Name=""MicrosoftTye_EvaluateProjects"">"); sb.AppendLine($@" <MsBuild Projects=""@(MicrosoftTye_ProjectServices)"" " + $@"Properties=""%(BuildProperties);" + $@"MicrosoftTye_ProjectName=%(Name)"" " + $@"Targets=""MicrosoftTye_GetProjectMetadata"" BuildInParallel=""true"" />"); sb.AppendLine(" </Target>"); sb.AppendLine("</Project>"); File.WriteAllText(projectPath, sb.ToString()); output.WriteDebugLine("Restoring and evaluating projects"); var msbuildEvaluationResult = await ProcessUtil.RunAsync( "dotnet", $"build " + $"\"{projectPath}\" " + $"/p:CustomAfterMicrosoftCommonTargets={Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "ProjectEvaluation.targets")} " + $"/nologo", throwOnError : false, workingDirectory : directory.DirectoryPath); // If the build fails, we're not really blocked from doing our work. // For now we just log the output to debug. There are errors that occur during // running these targets we don't really care as long as we get the data. if (msbuildEvaluationResult.ExitCode != 0) { output.WriteDebugLine($"Evaluating project failed with exit code {msbuildEvaluationResult.ExitCode}:" + $"{Environment.NewLine}Ouptut: {msbuildEvaluationResult.StandardOutput}" + $"{Environment.NewLine}Error: {msbuildEvaluationResult.StandardError}"); } var msbuildEvaluationOutput = msbuildEvaluationResult .StandardOutput .Split(Environment.NewLine); foreach (var line in msbuildEvaluationOutput) { if (line.Trim().StartsWith("Microsoft.Tye metadata: ")) { var values = line.Split(':', 3); var projectName = values[1].Trim(); var metadataPath = values[2].Trim(); projectMetadata.Add(projectName, metadataPath); output.WriteDebugLine($"Resolved metadata for service {projectName} at {metadataPath}"); } } output.WriteDebugLine($"Restore and project evaluation took: {sw.Elapsed.TotalMilliseconds}ms"); } foreach (var configService in services) { ServiceBuilder service; if (root.Services.Any(s => s.Name == configService.Name)) { // Even though this service has already created a service, we still need // to update dependency information AddToRootServices(root, dependencies, configService.Name); continue; } if (!string.IsNullOrEmpty(configService.Project)) { var project = new DotnetProjectServiceBuilder(configService.Name !, new FileInfo(configService.ProjectFullPath)); service = project; project.Build = configService.Build ?? true; project.Args = configService.Args; foreach (var buildProperty in configService.BuildProperties) { project.BuildProperties.Add(buildProperty.Name, buildProperty.Value); } project.Replicas = configService.Replicas ?? 1; project.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; project.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. project.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; // If project evaluation is successful this should not happen, therefore an exception will be thrown. if (!projectMetadata.ContainsKey(configService.Name)) { throw new CommandException($"Evaluated project metadata file could not be found for service {configService.Name}"); } ProjectReader.ReadProjectDetails(output, project, projectMetadata[configService.Name]); // Do k8s by default. project.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Image)) { var container = new ContainerServiceBuilder(configService.Name !, configService.Image !) { Args = configService.Args, Replicas = configService.Replicas ?? 1 }; service = container; container.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; container.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.DockerFile)) { var dockerFile = new DockerFileServiceBuilder(configService.Name !, configService.Image !) { Args = configService.Args, Build = configService.Build ?? true, Replicas = configService.Replicas ?? 1, DockerFile = Path.Combine(source.DirectoryName !, configService.DockerFile), // Supplying an absolute path with trailing slashes fails for DockerFileContext when calling docker build, so trim trailing slash. DockerFileContext = GetDockerFileContext(source, configService), BuildArgs = configService.DockerFileArgs }; service = dockerFile; dockerFile.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; dockerFile.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; // We don't apply more container defaults here because we might need // to prompt for the registry name. dockerFile.ContainerInfo = new ContainerInfo() { UseMultiphaseDockerfile = false, }; // Do k8s by default. dockerFile.ManifestInfo = new KubernetesManifestInfo(); } else if (!string.IsNullOrEmpty(configService.Executable)) { var expandedExecutable = Environment.ExpandEnvironmentVariables(configService.Executable); var workingDirectory = ""; // Special handling of .dlls as executables (it will be executed as dotnet {dll}) if (Path.GetExtension(expandedExecutable) == ".dll") { expandedExecutable = Path.GetFullPath(Path.Combine(config.Source.Directory !.FullName, expandedExecutable)); workingDirectory = Path.GetDirectoryName(expandedExecutable) !; } var executable = new ExecutableServiceBuilder(configService.Name !, expandedExecutable) { Args = configService.Args, WorkingDirectory = configService.WorkingDirectory != null? Path.GetFullPath(Path.Combine(config.Source.Directory !.FullName, Environment.ExpandEnvironmentVariables(configService.WorkingDirectory))) : workingDirectory, Replicas = configService.Replicas ?? 1 }; service = executable; executable.Liveness = configService.Liveness != null?GetProbeBuilder(configService.Liveness) : null; executable.Readiness = configService.Readiness != null?GetProbeBuilder(configService.Readiness) : null; } else if (!string.IsNullOrEmpty(configService.Include)) { var expandedYaml = Environment.ExpandEnvironmentVariables(configService.Include); var nestedConfig = GetNestedConfig(rootConfig, Path.Combine(config.Source.DirectoryName !, expandedYaml)); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (!string.IsNullOrEmpty(configService.Repository)) { // clone to .tye folder var path = configService.CloneDirectory ?? Path.Join(rootConfig.Source.DirectoryName, ".tye", "deps"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } var clonePath = Path.Combine(path, configService.Name); if (!Directory.Exists(clonePath)) { if (!await GitDetector.Instance.IsGitInstalled.Value) { throw new CommandException($"Cannot clone repository {configService.Repository} because git is not installed. Please install git if you'd like to use \"repository\" in tye.yaml."); } var result = await ProcessUtil.RunAsync("git", $"clone {configService.Repository} \"{clonePath}\"", workingDirectory : path, throwOnError : false); if (result.ExitCode != 0) { throw new CommandException($"Failed to clone repository {configService.Repository} with exit code {result.ExitCode}.{Environment.NewLine}{result.StandardError}{result.StandardOutput}."); } } if (!ConfigFileFinder.TryFindSupportedFile(clonePath, out var file, out var errorMessage)) { throw new CommandException(errorMessage !); } // pick different service type based on what is in the repo. var nestedConfig = GetNestedConfig(rootConfig, file); queue.Enqueue((nestedConfig, new HashSet <string>())); AddToRootServices(root, dependencies, configService.Name); continue; } else if (!string.IsNullOrEmpty(configService.AzureFunction)) { var azureFunctionDirectory = Path.Combine(config.Source.DirectoryName !, configService.AzureFunction); var functionBuilder = new AzureFunctionServiceBuilder( configService.Name, azureFunctionDirectory) { Args = configService.Args, Replicas = configService.Replicas ?? 1, FuncExecutablePath = configService.FuncExecutable, }; foreach (var proj in Directory.EnumerateFiles(azureFunctionDirectory)) { var fileInfo = new FileInfo(proj); if (fileInfo.Extension == ".csproj" || fileInfo.Extension == ".fsproj") { functionBuilder.ProjectFile = fileInfo.FullName; break; } } // TODO liveness? service = functionBuilder; } else if (configService.External) { var external = new ExternalServiceBuilder(configService.Name); service = external; } else { throw new CommandException("Unable to determine service type."); } // Add dependencies to ourself before adding ourself to avoid self reference service.Dependencies.UnionWith(dependencies); AddToRootServices(root, dependencies, service.Name); root.Services.Add(service); // If there are no bindings and we're in ASP.NET Core project then add an HTTP and HTTPS binding if (configService.Bindings.Count == 0 && service is ProjectServiceBuilder project2 && project2.IsAspNet) { // HTTP is the default binding service.Bindings.Add(new BindingBuilder() { Protocol = "http" }); service.Bindings.Add(new BindingBuilder() { Name = "https", Protocol = "https" }); }
public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder application) { using var step = output.BeginStep("Applying Kubernetes Manifests..."); if (await KubectlDetector.GetKubernetesServerVersion(output) == null) { throw new CommandException($"Cannot apply manifests because kubectl is not installed."); } if (!await KubectlDetector.IsKubectlConnectedToClusterAsync(output)) { throw new CommandException($"Cannot apply manifests because kubectl is not connected to a cluster."); } using var tempFile = TempFile.Create(); output.WriteInfoLine($"Writing output to '{tempFile.FilePath}'."); { await using var stream = File.OpenWrite(tempFile.FilePath); await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true); await ApplicationYamlWriter.WriteAsync(output, writer, application); } var ns = $"namespace ${application.Namespace}"; if (string.IsNullOrEmpty(application.Namespace)) { ns = "current namespace"; } output.WriteDebugLine($"Running 'kubectl apply' in ${ns}"); output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); var capture = output.Capture(); var exitCode = await ProcessUtil.ExecuteAsync( $"kubectl", $"apply -f \"{tempFile.FilePath}\"", System.Environment.CurrentDirectory, stdOut : capture.StdOut, stdErr : capture.StdErr); output.WriteDebugLine($"Done running 'kubectl apply' exit code: {exitCode}"); if (exitCode != 0) { throw new CommandException("'kubectl apply' failed."); } output.WriteInfoLine($"Deployed application '{application.Name}'."); if (application.Ingress.Count > 0) { output.WriteInfoLine($"Waiting for ingress to be deployed. This may take a long time."); foreach (var ingress in application.Ingress) { using var ingressStep = output.BeginStep($"Retrieving details for {ingress.Name}..."); var done = false; Action <string> complete = line => { done = line != "''"; if (done) { output.WriteInfoLine($"IngressIP: {line}"); } }; var retries = 0; while (!done && retries < 60) { var ingressExitCode = await ProcessUtil.ExecuteAsync( "kubectl", $"get ingress {ingress.Name} -o jsonpath='{{..ip}}'", Environment.CurrentDirectory, complete, capture.StdErr); if (ingressExitCode != 0) { throw new CommandException("'kubectl get ingress' failed"); } if (!done) { await Task.Delay(2000); retries++; } } } } }