Created
October 5, 2020 15:22
-
-
Save lawrencejones/6d4e3119c56101614cc97f2e79abbecf to your computer and use it in GitHub Desktop.
Theatre consoles wrapper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package cmd | |
import ( | |
"bytes" | |
"context" | |
"encoding/json" | |
"fmt" | |
"os" | |
"strings" | |
"text/template" | |
"time" | |
"github.com/Masterminds/sprig" | |
"github.com/gocardless/anu/utopia/pkg/registry" | |
kitlog "github.com/go-kit/kit/log" | |
workloadsv1alpha1 "github.com/gocardless/theatre/v2/apis/workloads/v1alpha1" | |
"github.com/gocardless/theatre/v2/pkg/workloads/console/runner" | |
"github.com/manifoldco/promptui" | |
"k8s.io/client-go/rest" | |
"k8s.io/client-go/tools/clientcmd" | |
) | |
var ( | |
consoles = app.Command("consoles", consolesUsage) | |
consolesService = new(RegistryOptions).Bind(consoles, "registry-", true).BindService(consoles, "") | |
consolesCreate = consoles.Command("create", "Create a console") | |
consolesCreateTemplate = consolesCreate. | |
Flag("template", "Optional specific template, ie. readonly, or readwrite").String() | |
consolesCreateTimeout = consolesCreate. | |
Flag("timeout", "Optional timeout for console, overriding default").Default("0").Duration() | |
consolesCreateReason = consolesCreate. | |
Flag("reason", "Reason for opening console").Required().String() | |
consolesCreateAttach = consolesCreate. | |
Flag("attach", "Attach to the console if it starts successfully").Default("true").Bool() | |
consolesCreateNoninteractive = consolesCreate. | |
Flag("noninteractive", "Do not enable TTY and STDIN on console container").Bool() | |
consolesCreateCommand = consolesCreate. | |
Arg("command", "Command to run in console").Strings() | |
consolesAttach = consoles.Command("attach", "Attach to an existing console") | |
consolesAttachName = consolesAttach.Flag("name", "Name of the console to attach to").Required().String() | |
consolesList = consoles.Command("list", "List currently running consoles") | |
consolesListMine = consolesList.Flag("mine", "Filter consoles to mine only (using --user)").Default("false").Bool() | |
consolesListIdentity = new(IdentityOptions).Bind(consolesList, "") | |
consolesAuthorise = consoles.Command("authorise", "Authorise a console which requires review") | |
consolesAuthoriseName = consolesAuthorise.Flag("name", "Name of console to authorise").Required().String() | |
consolesAuthoriseIdentity = new(IdentityOptions).Bind(consolesAuthorise, "") | |
) | |
const consolesUsage = `Manage consoles inside Kubernetes | |
Create and administrate consoles inside GoCardless Kubernetes clusters. | |
As with all utopia commands, the --service flag can be omitted when | |
running within the Git repository of the service. | |
Examples: | |
# Open bash in a payments-service console | |
utopia consoles create \ | |
--service payments-service \ | |
--environment live-staging \ | |
--reason "PT-0001: Debugging a timeout" \ | |
--template default \ | |
-- \ | |
bash | |
msg="console has been requested" console=paysvc-live-default-hsmdl namespace=staging | |
msg="console is ready" console=paysvc-live-default-hsmdl namespace=staging pod=paysvc-live-default-hsmdl-console-zbkz8 | |
app@paysvc-live-default-hsmdl-console-zbkz8:~$ echo hello | |
hello | |
# Attach to a console that has already been created | |
# Press ctrl-p, then ctrl-q to detach | |
utopia consoles attach \ | |
--service payments-service \ | |
--environment live-staging \ | |
--name paysvc-live-default-hsmdl | |
msg="attaching to pod" console=paysvc-live-default-hsmdl namespace=staging pod=paysvc-live-default-hsmdl-console-zbkz8 | |
app@paysvc-live-default-hsmdl-console-zbkz8:~$ echo again | |
again | |
` | |
// kubernetesConfigFor builds Kubernetes configuration for a specific deployment target. | |
// If we can find a Kubernetes config that matches our target context name, then we return | |
// that. If we can't find one, and we're inside a Kubernetes cluster, we try | |
// authenticating with in-cluster credentials. | |
// | |
// If that fails, we return the original error from trying to find the specific context, | |
// as the caller might never have intended for us to try the in-cluster approach. | |
func kubernetesConfigFor(target registry.Target) (*rest.Config, error) { | |
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( | |
clientcmd.NewDefaultClientConfigLoadingRules(), | |
&clientcmd.ConfigOverrides{ | |
CurrentContext: target.Spec.Context, | |
}, | |
).ClientConfig() | |
if _, ok := os.LookupEnv("KUBERNETES_SERVICE_HOST"); ok { | |
if config, err := rest.InClusterConfig(); err == nil { | |
return config, nil | |
} | |
} | |
return config, err | |
} | |
func consolesRun(ctx context.Context, command string) error { | |
_, service, environment, target, err := consolesService.GetUniqueTarget(ctx) | |
if err != nil { | |
return err | |
} | |
// Alias target fields to make constructing the command structs a bit easier | |
namespace := target.Spec.Namespace | |
selector := fmt.Sprintf("release=%s", target.Spec.Release) | |
cfg, err := kubernetesConfigFor(*target) | |
if err != nil { | |
return err | |
} | |
consoleRunner, err := runner.New(cfg) | |
if err != nil { | |
return err | |
} | |
// Configurable printer, so we can log appropriate messages whenever console events | |
// happen | |
printer := LifecyclePrinter(logger, *service, *environment) | |
switch command { | |
case consolesCreate.FullCommand(): | |
// If we specify a template, we want to append it to our selector | |
if *consolesCreateTemplate != "" { | |
selector = fmt.Sprintf("%s,template=%s", selector, *consolesCreateTemplate) | |
} | |
_, err := consoleRunner.Create( | |
ctx, | |
runner.CreateOptions{ | |
Namespace: namespace, | |
Selector: selector, | |
KubeConfig: cfg, | |
Hook: printer, | |
Timeout: *consolesCreateTimeout, | |
Reason: *consolesCreateReason, | |
Command: *consolesCreateCommand, | |
Attach: *consolesCreateAttach, | |
Noninteractive: *consolesCreateNoninteractive, | |
IO: runner.IOStreams{ | |
In: os.Stdin, | |
Out: os.Stdout, | |
ErrOut: os.Stderr, | |
}, | |
}, | |
) | |
// Try harder to format errors about multiple console templates, as this is a normal | |
// thing to error on and might be a source of user frustration. | |
// | |
// We don't assume we're looking for the default template here: that means we'll | |
// automatically work for any service with just one template, but the corollary is we | |
// are broken without the --template flag for any service that defines many. | |
// | |
// This seems fine for now. | |
if err, ok := err.(runner.MultipleConsoleTemplateError); ok { | |
errMsg := "Found multiple ConsoleTemplates for this service" | |
if *consolesCreateTemplate != "" { | |
errMsg = fmt.Sprintf("%s with label template=%s", errMsg, *consolesCreateTemplate) | |
} | |
var identifiers []string | |
for _, tpl := range err.ConsoleTemplates { | |
identifiers = append(identifiers, | |
fmt.Sprintf(`%s/%s{template="%s"}`, | |
tpl.Namespace, | |
tpl.Name, | |
tpl.Labels["template"])) | |
} | |
errMsg = fmt.Sprintf("%s:\n - %s\n", errMsg, strings.Join(identifiers, "\n - ")) | |
errMsg = fmt.Sprintf("%s\nUse the --template flag to choose a specific template.\n\n", errMsg) | |
fmt.Fprintf(os.Stderr, errMsg) | |
return fmt.Errorf("failed to find specific console template") | |
} | |
return err | |
case consolesAttach.FullCommand(): | |
return consoleRunner.Attach( | |
ctx, | |
runner.AttachOptions{ | |
Namespace: namespace, | |
KubeConfig: cfg, | |
Hook: printer, | |
Name: *consolesAttachName, | |
IO: runner.IOStreams{ | |
In: os.Stdin, | |
Out: os.Stdout, | |
ErrOut: os.Stderr, | |
}, | |
}, | |
) | |
case consolesList.FullCommand(): | |
var user string | |
// Only set a user if we've specified --mine, otherwise we want to see everyone's | |
// consoles. | |
if *consolesListMine { | |
var err error | |
user, err = consolesListIdentity.GetUser(ctx) | |
if err != nil { | |
return err | |
} | |
} | |
_, err = consoleRunner.List( | |
ctx, | |
runner.ListOptions{ | |
Namespace: namespace, | |
Selector: selector, | |
Username: user, | |
Output: os.Stdout, | |
}, | |
) | |
return err | |
case consolesAuthorise.FullCommand(): | |
csl, err := consoleRunner.Get(ctx, runner.GetOptions{ | |
Namespace: namespace, | |
ConsoleName: *consolesAuthoriseName, | |
}) | |
if err != nil { | |
return err | |
} | |
confirmation, err := confirmConsoleAuthorisation(csl, *service, *environment, *target) | |
if err != nil { | |
return err | |
} | |
if !confirmation { | |
logger.Log("msg", "declined to authorise console") | |
return nil | |
} | |
user, err := consolesAuthoriseIdentity.GetUser(ctx) | |
if err != nil { | |
return err | |
} | |
return consoleRunner.Authorise( | |
ctx, | |
runner.AuthoriseOptions{ | |
Namespace: namespace, | |
ConsoleName: *consolesAuthoriseName, | |
Username: user, | |
}, | |
) | |
} | |
panic("unrecognised command") | |
} | |
// LifecyclePrinter hooks into console lifecycle events, reporting on the change of | |
// console phases during creation or attaching | |
func LifecyclePrinter(logger kitlog.Logger, service registry.Service, environment registry.Environment) runner.LifecycleHook { | |
return runner.DefaultLifecycleHook{ | |
AttachingToPodFunc: func(csl *workloadsv1alpha1.Console) error { | |
logger.Log("msg", "attaching to pod", | |
"console", csl.Name, "namespace", csl.Namespace, "pod", csl.Status.PodName) | |
return nil | |
}, | |
ConsoleRequiresAuthorisationFunc: func(csl *workloadsv1alpha1.Console, rule *workloadsv1alpha1.ConsoleAuthorisationRule) error { | |
approverSlice := make([]string, 0, len(rule.ConsoleAuthorisers.Subjects)) | |
for _, approver := range rule.ConsoleAuthorisers.Subjects { | |
approverSlice = append(approverSlice, fmt.Sprintf(" - %s (%s)", approver.Name, approver.Kind)) | |
} | |
approvers := strings.Join(approverSlice, "\n") | |
fmt.Printf(` | |
This console is currently pending authorisation (rule: %s). | |
An authorisation is required from %d members of: | |
%s | |
Send this command to an authoriser: | |
utopia consoles authorise --service %s --environment %s --name %s | |
Waiting for authorisation... | |
`, | |
rule.Name, | |
rule.AuthorisationsRequired, | |
approvers, | |
service.Spec.Name, | |
environment.Spec.Name, | |
csl.Name, | |
) | |
return nil | |
}, | |
ConsoleReadyFunc: func(csl *workloadsv1alpha1.Console) error { | |
logger.Log("msg", "console is ready", | |
"console", csl.Name, "namespace", csl.Namespace, "pod", csl.Status.PodName) | |
return nil | |
}, | |
ConsoleCreatedFunc: func(csl *workloadsv1alpha1.Console) error { | |
logger.Log("msg", "console has been requested", | |
"console", csl.Name, "namespace", csl.Namespace) | |
return nil | |
}, | |
} | |
} | |
var confirmConsoleAuthorisationTemplate = template.Must( | |
template.New("console-authorisation").Funcs(sprig.TxtFuncMap()).Parse(` | |
Console details: | |
User: {{ .Console.Spec.User }} | |
Template: {{ .Template }} | |
Service: {{ .Service.Spec.Name }} | |
Environment: {{ .Environment.Spec.Name }} | |
Context: {{ .Target.Spec.Context }} | |
Namespace: {{ .Target.Spec.Namespace }} | |
Repo: {{ .Service.Spec.Repository }} | |
Command: {{ .Command }} | |
Reason: {{ .Console.Spec.Reason }} | |
Time: {{ .Console.ObjectMeta.CreationTimestamp }} ({{ .Age }} ago) | |
`)) | |
func confirmConsoleAuthorisation(csl *workloadsv1alpha1.Console, | |
service registry.Service, environment registry.Environment, target registry.Target, | |
) (bool, error) { | |
command, err := json.Marshal(csl.Spec.Command) | |
if err != nil { | |
return false, fmt.Errorf("failed to render console command: %w", err) | |
} | |
data := struct { | |
Console workloadsv1alpha1.Console | |
Template string | |
Service registry.Service | |
Environment registry.Environment | |
Target registry.Target | |
Command string | |
Age time.Duration | |
}{ | |
Console: *csl, | |
Template: csl.ObjectMeta.Labels["template"], | |
Service: service, | |
Environment: environment, | |
Target: target, | |
Command: string(command), | |
Age: time.Now().Sub(csl.ObjectMeta.CreationTimestamp.UTC()), | |
} | |
var buffer bytes.Buffer | |
if err := confirmConsoleAuthorisationTemplate.Execute(&buffer, data); err != nil { | |
return false, err | |
} | |
fmt.Println(buffer.String()) | |
prompt := promptui.Select{ | |
Label: "Authorise Console? [yes/no]", | |
Items: []string{"no", "yes"}, | |
} | |
_, result, err := prompt.Run() | |
if err != nil { | |
return false, fmt.Errorf("prompt failed %w", err) | |
} | |
return result == "yes", nil | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment