From 7b7f160c9901820a5acf82e8b4633de85bf6b75d Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 7 Mar 2020 17:10:39 -0800 Subject: [PATCH] Support validating and prompting for secrets We'll give you more info and choices if you're trying to deploy something that would depend on k8s secrets you have haven't configured yet. Basically when we would use a secret we'll check k8s first, and then if it's not found: - Prompt for it - Create it if you told us what to put there - Otherwise print to the commandline the command you could use to create it. --- src/Tye.Core/ValidateSecretStep.cs | 166 +++++++++++++++++++++++++++++ src/tye/Program.DeployCommand.cs | 33 +++--- src/tye/Program.InitCommand.cs | 2 +- 3 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 src/Tye.Core/ValidateSecretStep.cs 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