diff --git a/src/Tye.Core/ValidateSecretStep.cs b/src/Tye.Core/ValidateSecretStep.cs new file mode 100644 index 00000000..4a558101 --- /dev/null +++ b/src/Tye.Core/ValidateSecretStep.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using k8s; +using k8s.Models; +using Microsoft.Rest; +using YamlDotNet.RepresentationModel; + +namespace Tye +{ + public sealed class ValidateSecretStep : ServiceExecutor.Step + { + public override string DisplayText => "Validating Secrets..."; + + public string Environment { get; set; } = "production"; + + public bool Interactive { get; set; } + + public bool Force { get; set; } + + // Keep track of secrets we've seen so we don't validate them twice. + public HashSet Secrets { get; } = new HashSet(); + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + var bindings = service.Outputs.OfType().FirstOrDefault(); + if (bindings is null) + { + return; + } + + foreach (var binding in bindings.Bindings) + { + if (binding is SecretInputBinding secretInputBinding) + { + if (!Secrets.Add(secretInputBinding.Name)) + { + output.WriteDebugLine($"Already validated secret '{secretInputBinding.Name}'."); + continue; + } + + output.WriteDebugLine($"Validating secret '{secretInputBinding.Name}'."); + + var config = KubernetesClientConfiguration.BuildDefaultConfig(); + var kubernetes = new Kubernetes(config); + + try + { + var result = await kubernetes.ReadNamespacedSecretWithHttpMessagesAsync(secretInputBinding.Name, config.Namespace ?? "default"); + output.WriteInfoLine($"Found existing secret '{secretInputBinding.Name}'."); + continue; + } + 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."); + continue; + } + + if (!Interactive) + { + throw new CommandException( + $"The secret '{secretInputBinding.Name}' used for service '{secretInputBinding.Service.Service.Name}' is missing from the deployment environment. " + + $"Rerun the command with --interactive to specify the value interactively, or with --force to skip validation. Alternatively " + + $"use the following command to manually create the secret." + System.Environment.NewLine + + $"kubectl create secret generic {secretInputBinding.Name} --from-literal=connectionstring="); + } + + // If we get here then we should create the sceret. + var text = output.Prompt($"Enter the connection string to use for service '{secretInputBinding.Service.Service.Name}'", allowEmpty: true); + if (string.IsNullOrWhiteSpace(text)) + { + output.WriteAlways($"Skipping creation of secret for '{secretInputBinding.Service.Service.Name}'. This may prevent creation of pods until secrets are created."); + output.WriteAlways($"Manually create a secret with:"); + output.WriteAlways($"kubectl create secret generic {secretInputBinding.Name} --from-literal=connectionstring="); + continue; + } + + var secret = new V1Secret(type: "Opaque", stringData: new Dictionary() + { + { "connectionstring", text }, + }); + secret.Metadata = new V1ObjectMeta(); + secret.Metadata.Name = secretInputBinding.Name; + + output.WriteDebugLine($"Creating secret '{secret.Metadata.Name}'."); + + try + { + await kubernetes.CreateNamespacedSecretWithHttpMessagesAsync(secret, config.Namespace ?? "default"); + output.WriteInfoLine($"Created secret '{secret.Metadata.Name}'."); + } + catch (Exception ex) + { + output.WriteDebugLine("Failed to create secret."); + output.WriteDebugLine(ex.ToString()); + throw new CommandException("Failed to create secret.", ex); + } + } + } + + var yaml = service.Outputs.OfType().ToArray(); + if (yaml.Length == 0) + { + output.WriteDebugLine($"No yaml manifests found for service '{service.FriendlyName}'. Skipping."); + return; + } + + using var tempFile = TempFile.Create(); + output.WriteDebugLine($"Writing output to '{tempFile.FilePath}'."); + + { + using var stream = File.OpenWrite(tempFile.FilePath); + using var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: -1, leaveOpen: true); + var yamlStream = new YamlStream(yaml.Select(y => y.Yaml)); + yamlStream.Save(writer, assignAnchors: false); + } + + // kubectl apply logic is implemented in the client in older versions of k8s. The capability + // to get the same behavior in the server isn't present in every version that's relevant. + // + // https://kubernetes.io/docs/reference/using-api/api-concepts/#server-side-apply + // + output.WriteDebugLine("Running 'kubectl apply'."); + output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); + var capture = output.Capture(); + var exitCode = await Process.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 service '{service.FriendlyName}'."); + } + } +} + + + diff --git a/src/tye/Program.DeployCommand.cs b/src/tye/Program.DeployCommand.cs index ba83b2fd..c86cf3ae 100644 --- a/src/tye/Program.DeployCommand.cs +++ b/src/tye/Program.DeployCommand.cs @@ -23,7 +23,13 @@ namespace Tye StandardOptions.Verbosity, }; - command.Handler = CommandHandler.Create((console, path, verbosity, interactive) => + command.AddOption(new Option(new[] { "-f", "--force" }) + { + Description = "Override validation and force deployment.", + Required = false + }); + + command.Handler = CommandHandler.Create((console, path, verbosity, interactive, force) => { // Workaround for https://github.com/dotnet/command-line-api/issues/723#issuecomment-593062654 if (path is null) @@ -32,14 +38,24 @@ namespace Tye } var application = ConfigFactory.FromFile(path); - return ExecuteDeployAsync(new OutputContext(console, verbosity), application, environment: "production", interactive); + return ExecuteDeployAsync(new OutputContext(console, verbosity), application, environment: "production", interactive, force); }); return command; } - private static async Task ExecuteDeployAsync(OutputContext output, ConfigApplication application, string environment, bool interactive) + private static async Task ExecuteDeployAsync(OutputContext output, ConfigApplication application, string environment, bool interactive, bool force) { + if (!await KubectlDetector.Instance.IsKubectlInstalled.Value) + { + throw new CommandException($"Cannot apply manifests because kubectl is not installed."); + } + + if (!await KubectlDetector.Instance.IsKubectlConnectedToCluster.Value) + { + throw new CommandException($"Cannot apply manifests because kubectl is not connected to a cluster."); + } + var temporaryApplication = await CreateApplicationAdapterAsync(output, application, interactive); var steps = new List() { @@ -47,6 +63,7 @@ namespace Tye new PublishProjectStep(), new BuildDockerImageStep() { Environment = environment, }, new PushDockerImageStep() { Environment = environment, }, + new ValidateSecretStep() { Environment = environment, Interactive = interactive, Force = force, }, }; steps.Add(new GenerateKubernetesManifestStep() { Environment = environment, }); @@ -161,16 +178,6 @@ namespace Tye await ApplicationYamlWriter.WriteAsync(output, writer, application); } - if (!await KubectlDetector.Instance.IsKubectlInstalled.Value) - { - throw new CommandException($"Cannot apply manifests because kubectl is not installed."); - } - - if (!await KubectlDetector.Instance.IsKubectlConnectedToCluster.Value) - { - throw new CommandException($"Cannot apply manifests because kubectl is not connected to a cluster."); - } - output.WriteDebugLine("Running 'kubectl apply'."); output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); var capture = output.Capture(); diff --git a/src/tye/Program.InitCommand.cs b/src/tye/Program.InitCommand.cs index dadb739a..e6e302e7 100644 --- a/src/tye/Program.InitCommand.cs +++ b/src/tye/Program.InitCommand.cs @@ -21,7 +21,7 @@ namespace Tye CommonArguments.Path_Optional, }; - command.AddOption(new Option("--force") + command.AddOption(new Option(new[] { "-f", "--force" }) { Description = "Overrides the tye.yaml file if already present for project.", Required = false