mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-02 08:19:23 +02:00
Update core-zero import client
This commit is contained in:
parent
77665603ce
commit
35e4b782ea
15 changed files with 122 additions and 780 deletions
|
@ -3,7 +3,6 @@ package zero
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -13,6 +12,7 @@ import (
|
|||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/pomerium/pomerium/internal/zero/apierror"
|
||||
connect_mux "github.com/pomerium/pomerium/internal/zero/connect-mux"
|
||||
"github.com/pomerium/pomerium/internal/zero/grpcconn"
|
||||
|
@ -121,13 +121,16 @@ func (api *API) GetClusterResourceBundles(ctx context.Context) (*cluster_api.Get
|
|||
)
|
||||
}
|
||||
|
||||
func (api *API) ImportConfig(ctx context.Context, cfg *configpb.Config) (*cluster_api.EmptyResponse, error) {
|
||||
func (api *API) ImportConfig(ctx context.Context, cfg *configpb.Config) (*cluster_api.ImportResponse, error) {
|
||||
data, err := proto.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var compressedData bytes.Buffer
|
||||
w := gzip.NewWriter(&compressedData)
|
||||
w, err := zstd.NewWriter(&compressedData, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bug: %v", err))
|
||||
}
|
||||
_, err = io.Copy(w, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
"github.com/pomerium/pomerium/pkg/envoy/files"
|
||||
"github.com/pomerium/pomerium/pkg/zero/importutil"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
@ -69,21 +70,24 @@ func BuildImportCmd() *cobra.Command {
|
|||
cfg := src.GetConfig()
|
||||
|
||||
client := zeroClientFromContext(cmd.Context())
|
||||
quotas, err := client.GetQuotas(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting quotas: %w", err)
|
||||
}
|
||||
converted := cfg.Options.ToProto()
|
||||
ui := NewImportUI(converted, quotas)
|
||||
if err := ui.Run(cmd.Context()); err != nil {
|
||||
return err
|
||||
for i, name := range importutil.GenerateRouteNames(converted.Routes) {
|
||||
converted.Routes[i].Name = name
|
||||
}
|
||||
ui.ApplySelections(converted)
|
||||
_, err = client.ImportConfig(cmd.Context(), converted)
|
||||
resp, err := client.ImportConfig(cmd.Context(), converted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error importing config: %w", err)
|
||||
}
|
||||
cmd.PrintErrln("config imported successfully")
|
||||
if resp.Warnings != nil {
|
||||
for _, warn := range *resp.Warnings {
|
||||
cmd.Printf("warning: %s\n", warn)
|
||||
}
|
||||
}
|
||||
if resp.Messages != nil {
|
||||
for _, msg := range *resp.Messages {
|
||||
cmd.Printf("✔ %s\n", msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import "github.com/charmbracelet/huh"
|
||||
|
||||
func (ui *ImportUI) XForm() *huh.Form {
|
||||
return ui.form
|
||||
}
|
|
@ -1,553 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
http_connection_managerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
configpb "github.com/pomerium/pomerium/pkg/grpc/config"
|
||||
cluster_api "github.com/pomerium/pomerium/pkg/zero/cluster"
|
||||
"github.com/pomerium/pomerium/pkg/zero/importutil"
|
||||
"github.com/pomerium/protoutil/fieldmasks"
|
||||
"github.com/pomerium/protoutil/paths"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/reflect/protopath"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
type onCursorUpdate struct {
|
||||
Field interface{ Hovered() (string, bool) }
|
||||
}
|
||||
|
||||
func (u onCursorUpdate) Hash() (uint64, error) {
|
||||
op, ok := u.Field.Hovered()
|
||||
if !ok {
|
||||
return ^uint64(0), nil
|
||||
}
|
||||
return xxhash.Sum64String(op), nil
|
||||
}
|
||||
|
||||
var (
|
||||
yellowText = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(3))
|
||||
faintText = lipgloss.NewStyle().Faint(true).UnsetForeground()
|
||||
redText = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(1))
|
||||
)
|
||||
|
||||
func errText(err error) string {
|
||||
return redText.Render(fmt.Sprintf("(error: %v)", err))
|
||||
}
|
||||
|
||||
func certInfoFromSettingsCertificate(v protoreflect.Value) string {
|
||||
switch v := v.Interface().(type) {
|
||||
case protoreflect.List:
|
||||
buf := bytes.Buffer{}
|
||||
for i, l := 0, v.Len(); i < l; i++ {
|
||||
crtBytes := string(v.Get(i).Message().Interface().(*configpb.Settings_Certificate).GetCertBytes())
|
||||
buf.WriteString(crtBytes)
|
||||
if i < l-1 {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
return certInfoFromBytes(buf.Bytes())
|
||||
case protoreflect.Message:
|
||||
crtBytes := string(v.Interface().(*configpb.Settings_Certificate).GetCertBytes())
|
||||
return certInfoFromBytes([]byte(crtBytes))
|
||||
default:
|
||||
panic(fmt.Sprintf("bug: unexpected value type %T", v))
|
||||
}
|
||||
}
|
||||
|
||||
func certInfoFromBase64(v protoreflect.Value) string {
|
||||
crtBytes, err := base64.StdEncoding.DecodeString(v.String())
|
||||
if err != nil {
|
||||
return errText(err)
|
||||
}
|
||||
return certInfoFromBytes(crtBytes)
|
||||
}
|
||||
|
||||
func certInfoFromBytes(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return faintText.Render("(empty)")
|
||||
}
|
||||
block, rest := pem.Decode(b)
|
||||
if block == nil {
|
||||
return errText(errors.New("no PEM data found"))
|
||||
}
|
||||
extraBlocks := []*pem.Block{}
|
||||
for len(rest) > 0 {
|
||||
block, rest = pem.Decode(rest)
|
||||
if block != nil {
|
||||
extraBlocks = append(extraBlocks, block)
|
||||
}
|
||||
}
|
||||
blockType := block.Type
|
||||
var info string
|
||||
switch block.Type {
|
||||
case "X509 CRL":
|
||||
crl, err := x509.ParseRevocationList(block.Bytes)
|
||||
if err != nil {
|
||||
return errText(err)
|
||||
}
|
||||
info = fmt.Sprintf("%d entries", len(crl.RevokedCertificateEntries))
|
||||
default:
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return errText(err)
|
||||
}
|
||||
info = *importutil.GenerateCertName(cert)
|
||||
}
|
||||
out := yellowText.Render(fmt.Sprintf("(%s: %s)", blockType, info))
|
||||
if len(extraBlocks) > 0 {
|
||||
s := ""
|
||||
if len(extraBlocks) != 1 {
|
||||
s = "s"
|
||||
}
|
||||
out += faintText.Render(fmt.Sprintf(" ...+%d block%s", len(extraBlocks), s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func secret(s protoreflect.Value) string {
|
||||
length := len(s.String())
|
||||
return yellowText.Render(fmt.Sprintf("(secret: %d bytes)", length))
|
||||
}
|
||||
|
||||
var customSettingsInfoByPath = map[string]func(v protoreflect.Value) string{
|
||||
"(pomerium.config.Settings).metrics_certificate": certInfoFromSettingsCertificate,
|
||||
"(pomerium.config.Settings).metrics_client_ca": certInfoFromBase64,
|
||||
"(pomerium.config.Settings).certificates": certInfoFromSettingsCertificate,
|
||||
"(pomerium.config.Settings).certificate_authority": certInfoFromBase64,
|
||||
"(pomerium.config.Settings).downstream_mtls.ca": certInfoFromBase64,
|
||||
"(pomerium.config.Settings).downstream_mtls.crl": certInfoFromBase64,
|
||||
"(pomerium.config.Settings).shared_secret": secret,
|
||||
"(pomerium.config.Settings).cookie_secret": secret,
|
||||
"(pomerium.config.Settings).google_cloud_serverless_authentication_service_account": secret,
|
||||
"(pomerium.config.Settings).idp_client_secret": secret,
|
||||
"(pomerium.config.Settings).databroker_storage_connection_string": secret,
|
||||
}
|
||||
|
||||
type ImportHints struct {
|
||||
// Indicates that the field is ignored during Zero import
|
||||
Ignored bool
|
||||
// Indicates that the field is entirely unsupported by Zero, and will likely
|
||||
// break an existing configuration if imported. If any of these fields are
|
||||
// selected, an error will be displayed.
|
||||
Unsupported bool
|
||||
// An optional note explaining why a field is ignored or unsupported, if
|
||||
// additional context would be helpful. This message will be user facing.
|
||||
Note string
|
||||
// Indicates that the field is treated as a secret, and will be encrypted.
|
||||
Secret bool
|
||||
}
|
||||
|
||||
const (
|
||||
noteSplitService = "split-service mode"
|
||||
noteEnterpriseOnly = "enterprise only"
|
||||
noteFeatureNotYetAvailable = "feature not yet available"
|
||||
)
|
||||
|
||||
func noteCertificate(n int) string {
|
||||
suffix := ""
|
||||
if n != 1 {
|
||||
suffix = "s"
|
||||
}
|
||||
return fmt.Sprintf("+%d certificate%s", n, suffix)
|
||||
}
|
||||
|
||||
func notePolicy(n int) string {
|
||||
suffix := "y"
|
||||
if n != 1 {
|
||||
suffix = "ies"
|
||||
}
|
||||
return fmt.Sprintf("+%d polic%s", n, suffix)
|
||||
}
|
||||
|
||||
func computeSettingsImportHints(cfg *configpb.Config) map[string]ImportHints {
|
||||
m := map[string]ImportHints{
|
||||
"authenticate_callback_path": {Ignored: true},
|
||||
"shared_secret": {Ignored: true},
|
||||
"cookie_secret": {Ignored: true},
|
||||
"signing_key": {Ignored: true},
|
||||
"authenticate_internal_service_url": {Unsupported: true, Note: noteSplitService},
|
||||
"authorize_internal_service_url": {Unsupported: true, Note: noteSplitService},
|
||||
"databroker_internal_service_url": {Unsupported: true, Note: noteSplitService},
|
||||
"derive_tls": {Unsupported: true, Note: noteSplitService},
|
||||
"audit_key": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"primary_color": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"secondary_color": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"darkmode_primary_color": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"darkmode_secondary_color": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"logo_url": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"favicon_url": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"error_message_first_paragraph": {Unsupported: true, Note: noteEnterpriseOnly},
|
||||
"use_proxy_protocol": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"programmatic_redirect_domain_whitelist": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"grpc_client_timeout": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"grpc_client_dns_roundrobin": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"envoy_bind_config_freebind": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"envoy_bind_config_source_address": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"google_cloud_serverless_authentication_service_account": {Secret: true},
|
||||
"idp_client_secret": {Secret: true},
|
||||
"databroker_storage_connection_string": {Secret: true},
|
||||
"metrics_certificate": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"metrics_client_ca": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
// "metrics_certificate": {Note: noteCertificate(1)},
|
||||
// "metrics_client_ca": {Note: noteCertificate(1)},
|
||||
"certificate_authority": {Note: noteCertificate(1)},
|
||||
"certificates": {Note: noteCertificate(len(cfg.GetSettings().GetCertificates()))},
|
||||
"downstream_mtls.crl": {Unsupported: true, Note: noteFeatureNotYetAvailable},
|
||||
"downstream_mtls.ca": {Note: noteCertificate(1)},
|
||||
}
|
||||
if dm := cfg.GetSettings().GetDownstreamMtls(); dm != nil {
|
||||
if dm.Enforcement != nil {
|
||||
switch *dm.Enforcement {
|
||||
case configpb.MtlsEnforcementMode_POLICY:
|
||||
case configpb.MtlsEnforcementMode_POLICY_WITH_DEFAULT_DENY:
|
||||
case configpb.MtlsEnforcementMode_REJECT_CONNECTION:
|
||||
// this is a special case - zero does not support this mode, but we cannot continue
|
||||
// with a partial import because it fundamentally changes the behavior of all routes
|
||||
// and policies in the system
|
||||
log.Fatal().Msg("downstream mtls enforcement mode 'reject_connection' is not supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.GetSettings().GetServices() != "all" {
|
||||
m["services"] = ImportHints{Ignored: true, Note: `only "all" is supported`}
|
||||
}
|
||||
if cfg.GetSettings().GetCodecType() != http_connection_managerv3.HttpConnectionManager_AUTO {
|
||||
m["codec_type"] = ImportHints{Ignored: true, Note: `only "auto" is supported`}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type ImportUI struct {
|
||||
form *huh.Form
|
||||
selectedSettings []string
|
||||
selectedRoutes []string
|
||||
}
|
||||
|
||||
func NewImportUI(cfg *configpb.Config, quotas *cluster_api.ConfigQuotas) *ImportUI {
|
||||
settingsImportHints := computeSettingsImportHints(cfg)
|
||||
|
||||
presentSettings := fieldmasks.Leaves(
|
||||
fieldmasks.Diff(
|
||||
config.NewDefaultOptions().ToProto().GetSettings().ProtoReflect(),
|
||||
cfg.GetSettings().ProtoReflect(),
|
||||
),
|
||||
cfg.Settings.ProtoReflect().Descriptor(),
|
||||
)
|
||||
slices.Sort(presentSettings.Paths)
|
||||
settingsOptions := huh.NewOptions(presentSettings.Paths...)
|
||||
|
||||
ui := &ImportUI{
|
||||
selectedSettings: slices.Clone(presentSettings.Paths),
|
||||
}
|
||||
|
||||
for i, value := range presentSettings.Paths {
|
||||
if hints, ok := settingsImportHints[value]; ok {
|
||||
switch {
|
||||
case hints.Ignored:
|
||||
note := ""
|
||||
if hints.Note != "" {
|
||||
note = fmt.Sprintf(": %s", hints.Note)
|
||||
}
|
||||
settingsOptions[i].Key = fmt.Sprintf("\x1b[9m%s\x1b[29m \x1b[2m(ignored%s)\x1b[22m", settingsOptions[i].Key, note)
|
||||
ui.selectedSettings[i] = ""
|
||||
case hints.Unsupported:
|
||||
note := ""
|
||||
if hints.Note != "" {
|
||||
note = fmt.Sprintf(": %s", hints.Note)
|
||||
}
|
||||
settingsOptions[i].Key = fmt.Sprintf("\x1b[9m%s\x1b[29m \x1b[2m(unsupported%s)\x1b[22m", settingsOptions[i].Key, note)
|
||||
ui.selectedSettings[i] = ""
|
||||
case hints.Secret:
|
||||
settingsOptions[i].Key += " \x1b[2m(secret)\x1b[22m"
|
||||
default:
|
||||
if hints.Note != "" {
|
||||
settingsOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", hints.Note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.selectedSettings = slices.DeleteFunc(ui.selectedSettings, func(s string) bool {
|
||||
return s == ""
|
||||
})
|
||||
settingsSelect := huh.NewMultiSelect[string]().
|
||||
Filterable(false).
|
||||
Title("Import Settings").
|
||||
Description("Choose settings to import from your existing configuration").
|
||||
Options(settingsOptions...).
|
||||
Validate(func(selected []string) error {
|
||||
var unsupportedCount int
|
||||
for _, s := range selected {
|
||||
if hints, ok := settingsImportHints[s]; ok && hints.Unsupported {
|
||||
unsupportedCount++
|
||||
}
|
||||
}
|
||||
if unsupportedCount == 1 {
|
||||
return fmt.Errorf("1 selected setting is unsupported")
|
||||
} else if unsupportedCount > 1 {
|
||||
return fmt.Errorf("%d selected settings are unsupported", unsupportedCount)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Value(&ui.selectedSettings)
|
||||
settingsSelect.Focus()
|
||||
|
||||
escapeNoteText := strings.NewReplacer(
|
||||
"*", "\\*",
|
||||
"_", "\\_",
|
||||
"`", "\\`",
|
||||
)
|
||||
settingsNoteDescription := func(value string) string {
|
||||
path, err := paths.ParseFrom(cfg.Settings.ProtoReflect().Descriptor(), "."+value)
|
||||
if err != nil {
|
||||
return errText(err)
|
||||
}
|
||||
val, err := paths.Evaluate(cfg.Settings, path)
|
||||
if err != nil {
|
||||
return errText(err)
|
||||
}
|
||||
if infoFunc, ok := customSettingsInfoByPath[path.String()]; ok {
|
||||
return escapeNoteText.Replace(infoFunc(val))
|
||||
}
|
||||
return escapeNoteText.Replace(formatValue(path, val))
|
||||
}
|
||||
settingsNote := huh.NewNote().
|
||||
Title(fmt.Sprintf("Value: %s", presentSettings.Paths[0])).
|
||||
TitleFunc(func() string {
|
||||
field, ok := settingsSelect.Hovered()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Value: %s", field)
|
||||
}, onCursorUpdate{settingsSelect}).
|
||||
Description(settingsNoteDescription(presentSettings.Paths[0])).
|
||||
DescriptionFunc(func() string {
|
||||
field, ok := settingsSelect.Hovered()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return settingsNoteDescription(field)
|
||||
}, onCursorUpdate{settingsSelect}).
|
||||
Height(3)
|
||||
settingsNote.Focus()
|
||||
|
||||
routeNames := make([]string, len(cfg.Routes))
|
||||
routesByName := make(map[string]*configpb.Route)
|
||||
for i, name := range importutil.GenerateRouteNames(cfg.Routes) {
|
||||
routeNames[i] = name
|
||||
cfg.Routes[i].Name = name
|
||||
routesByName[name] = cfg.Routes[i]
|
||||
}
|
||||
routeOptions := huh.NewOptions(routeNames...)
|
||||
for i, name := range routeNames {
|
||||
if i < quotas.Routes {
|
||||
ui.selectedRoutes = append(ui.selectedRoutes, name)
|
||||
}
|
||||
if n := includedCertificatesInRoute(cfg.Routes[i]); n > 0 {
|
||||
routeOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", noteCertificate(n))
|
||||
}
|
||||
if n := includedPoliciesInRoute(cfg.Routes[i]); n > 0 {
|
||||
routeOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", notePolicy(n))
|
||||
}
|
||||
}
|
||||
|
||||
routesSelectDescription := func() string {
|
||||
return fmt.Sprintf(`
|
||||
Choose routes to import from your existing configuration. Policies and
|
||||
certificates associated with selected routes will also be imported.
|
||||
|
||||
Pomerium Zero routes require unique names. We've generated default names
|
||||
from the contents of each route, but these can always be changed later on.
|
||||
|
||||
Selected: %d/%d`[1:], len(ui.selectedRoutes), quotas.Routes)
|
||||
}
|
||||
topMarginLines := 1 + len(strings.Split(routesSelectDescription(), "\n"))
|
||||
routesSelect := huh.NewMultiSelect[string]().
|
||||
Filterable(true).
|
||||
Title("Import Routes").
|
||||
Description(routesSelectDescription()).
|
||||
DescriptionFunc(routesSelectDescription, &ui.selectedRoutes).
|
||||
Height(min(30, len(cfg.Routes)) + topMarginLines).
|
||||
Options(routeOptions...).
|
||||
Validate(func(_ []string) error {
|
||||
if len(ui.selectedRoutes) > quotas.Routes {
|
||||
return fmt.Errorf("A maximum of %d routes can be imported", quotas.Routes) //nolint:stylecheck
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Value(&ui.selectedRoutes)
|
||||
|
||||
var (
|
||||
labelFrom = yellowText.Render(" from: ")
|
||||
labelPath = yellowText.Render(" path: ")
|
||||
labelPrefix = yellowText.Render(" prefix: ")
|
||||
labelRegex = yellowText.Render(" regex: ")
|
||||
labelTo = yellowText.Render(" to: ")
|
||||
labelRedirect = yellowText.Render("redirect: ")
|
||||
labelResponse = yellowText.Render("response: ")
|
||||
)
|
||||
routesNoteDescription := func(selected *configpb.Route) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(labelFrom)
|
||||
b.WriteString(selected.From)
|
||||
switch {
|
||||
case selected.Path != "":
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelPath)
|
||||
b.WriteString(selected.Path)
|
||||
case selected.Prefix != "":
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelPrefix)
|
||||
b.WriteString(selected.Prefix)
|
||||
case selected.Regex != "":
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelRegex)
|
||||
b.WriteString(selected.Regex)
|
||||
}
|
||||
switch {
|
||||
case len(selected.To) > 0:
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelTo)
|
||||
b.WriteString(selected.To[0])
|
||||
for _, t := range selected.To[1:] {
|
||||
b.WriteString(", ")
|
||||
b.WriteString(t)
|
||||
}
|
||||
case selected.Redirect != nil:
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelRedirect)
|
||||
b.WriteString(selected.Redirect.String())
|
||||
case selected.Response != nil:
|
||||
b.WriteRune('\n')
|
||||
b.WriteString(labelResponse)
|
||||
b.WriteString(fmt.Sprint(selected.Response.Status))
|
||||
b.WriteRune(' ')
|
||||
b.WriteString(strconv.Quote(selected.Response.Body))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
routesNote := huh.NewNote().
|
||||
Title("Route Info").
|
||||
Description(routesNoteDescription(cfg.Routes[0])).
|
||||
DescriptionFunc(func() string {
|
||||
name, ok := routesSelect.Hovered()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return routesNoteDescription(routesByName[name])
|
||||
}, onCursorUpdate{routesSelect}).Height(3)
|
||||
routesNote.Focus()
|
||||
|
||||
ui.form = huh.NewForm(
|
||||
huh.NewGroup(settingsSelect, settingsNote),
|
||||
huh.NewGroup(routesSelect, routesNote),
|
||||
).WithTheme(huh.ThemeBase16())
|
||||
return ui
|
||||
}
|
||||
|
||||
func (ui *ImportUI) Run(ctx context.Context) error {
|
||||
if lipgloss.ColorProfile() == termenv.Ascii &&
|
||||
!termenv.EnvNoColor() && os.Getenv("TERM") != "dumb" {
|
||||
lipgloss.SetColorProfile(termenv.ANSI)
|
||||
}
|
||||
return ui.form.RunWithContext(ctx)
|
||||
}
|
||||
|
||||
func (ui *ImportUI) ApplySelections(cfg *configpb.Config) {
|
||||
fieldmasks.ExclusiveKeep(cfg.Settings, &fieldmaskpb.FieldMask{
|
||||
Paths: ui.selectedSettings,
|
||||
})
|
||||
cfg.Routes = slices.DeleteFunc(cfg.Routes, func(route *configpb.Route) bool {
|
||||
return !slices.Contains(ui.selectedRoutes, route.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func includedCertificatesInRoute(route *configpb.Route) int {
|
||||
n := 0
|
||||
if route.TlsClientCert != "" && route.TlsClientKey != "" {
|
||||
n++
|
||||
}
|
||||
if route.TlsCustomCa != "" {
|
||||
n++
|
||||
}
|
||||
if route.TlsDownstreamClientCa != "" {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func includedPoliciesInRoute(route *configpb.Route) int {
|
||||
n := 0
|
||||
for _, policy := range route.PplPolicies {
|
||||
// skip over common generated policies
|
||||
switch string(policy.Raw) {
|
||||
case `[{"allow":{"or":[{"accept":true}]}}]`:
|
||||
case `[{"allow":{"or":[{"authenticated_user":true}]}}]`:
|
||||
case `[{"allow":{"or":[{"cors_preflight":true}]}}]`:
|
||||
default:
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formatValue(path protopath.Path, val protoreflect.Value) string {
|
||||
switch vi := val.Interface().(type) {
|
||||
case protoreflect.Message:
|
||||
jsonData, err := protojson.Marshal(vi.Interface())
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return string(jsonData)
|
||||
case protoreflect.List:
|
||||
values := []string{}
|
||||
for i := 0; i < vi.Len(); i++ {
|
||||
values = append(values, formatValue(path, vi.Get(i)))
|
||||
}
|
||||
return renderStringSlice(values)
|
||||
case protoreflect.Map:
|
||||
values := []string{}
|
||||
vi.Range(func(mk protoreflect.MapKey, v protoreflect.Value) bool {
|
||||
values = append(values, mk.String()+yellowText.Render("=")+formatValue(path, v))
|
||||
return true
|
||||
})
|
||||
slices.Sort(values)
|
||||
return renderStringSlice(values)
|
||||
case protoreflect.EnumNumber:
|
||||
var field protoreflect.FieldDescriptor
|
||||
switch step := path.Index(-1); step.Kind() {
|
||||
case protopath.FieldAccessStep:
|
||||
field = step.FieldDescriptor()
|
||||
case protopath.ListIndexStep, protopath.MapIndexStep:
|
||||
field = path.Index(-2).FieldDescriptor()
|
||||
}
|
||||
if field != nil {
|
||||
return strings.ToLower(string(field.Enum().Values().ByNumber(vi).Name()))
|
||||
}
|
||||
return fmt.Sprint(vi)
|
||||
default:
|
||||
return val.String()
|
||||
}
|
||||
}
|
||||
|
||||
func renderStringSlice(values []string) string {
|
||||
return yellowText.Render("[") + strings.Join(values, yellowText.Render(", ")) + yellowText.Render("]")
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
package cmd_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/pkg/envoy/files"
|
||||
"github.com/pomerium/pomerium/pkg/zero/cluster"
|
||||
"github.com/pomerium/pomerium/pkg/zero/importutil"
|
||||
"github.com/pomerium/protoutil/fieldmasks"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
"github.com/pomerium/pomerium/internal/zero/cmd"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
func TestImportUI(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
require.NoError(t, os.CopyFS(tmp, testdata))
|
||||
dir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
defer os.Chdir(dir)
|
||||
os.Chdir(filepath.Join(tmp, "testdata"))
|
||||
|
||||
src, err := config.NewFileOrEnvironmentSource("config.yaml", files.FullVersion())
|
||||
require.NoError(t, err)
|
||||
|
||||
cfgC := make(chan *config.Config, 1)
|
||||
src.OnConfigChange(context.Background(), func(_ context.Context, cfg *config.Config) {
|
||||
cfgC <- cfg
|
||||
})
|
||||
if cfg := src.GetConfig(); cfg != nil {
|
||||
cfgC <- cfg
|
||||
}
|
||||
cfg := (<-cfgC).Options.ToProto()
|
||||
|
||||
b, err := proto.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
var compressed bytes.Buffer
|
||||
w := gzip.NewWriter(&compressed)
|
||||
require.NoError(t, err)
|
||||
w.Write(b)
|
||||
w.Close()
|
||||
size := len(compressed.Bytes())
|
||||
t.Logf("payload size: %d kB", size/1024)
|
||||
|
||||
ui := cmd.NewImportUI(cfg, &cluster.ConfigQuotas{
|
||||
Certificates: 10,
|
||||
Policies: 10,
|
||||
Routes: 10,
|
||||
})
|
||||
|
||||
form := ui.XForm()
|
||||
form.SubmitCmd = tea.Quit
|
||||
form.CancelCmd = tea.Quit
|
||||
|
||||
tm := teatest.NewTestModel(t, form, teatest.WithInitialTermSize(80, 80))
|
||||
|
||||
presentSettings := fieldmasks.Leaves(
|
||||
fieldmasks.Diff(
|
||||
config.NewDefaultOptions().ToProto().GetSettings().ProtoReflect(),
|
||||
cfg.GetSettings().ProtoReflect(),
|
||||
),
|
||||
cfg.Settings.ProtoReflect().Descriptor(),
|
||||
)
|
||||
slices.Sort(presentSettings.Paths)
|
||||
|
||||
for i, setting := range presentSettings.Paths {
|
||||
if i > 0 {
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
|
||||
}
|
||||
var foundSelect bool
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
str := ansi.Strip(string(bts))
|
||||
if !foundSelect {
|
||||
if strings.Contains(str, fmt.Sprintf("> [•] %s", setting)) ||
|
||||
strings.Contains(str, fmt.Sprintf("> [ ] %s", setting)) {
|
||||
foundSelect = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return strings.Contains(str, fmt.Sprintf("Value: %s", setting))
|
||||
}, teatest.WithDuration(1*time.Second), teatest.WithCheckInterval(1*time.Millisecond))
|
||||
}
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyTab})
|
||||
names := importutil.GenerateRouteNames(cfg.Routes)
|
||||
for i, route := range cfg.Routes {
|
||||
if i > 0 {
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
|
||||
}
|
||||
var foundSelect bool
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
str := ansi.Strip(string(bts))
|
||||
if !foundSelect {
|
||||
if strings.Contains(str, fmt.Sprintf("> [•] %s", names[i])) ||
|
||||
strings.Contains(str, fmt.Sprintf("> [ ] %s", names[i])) {
|
||||
foundSelect = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if i == 0 || cfg.Routes[i-1].From != route.From {
|
||||
return strings.Contains(str, fmt.Sprintf("from: %s", route.From))
|
||||
}
|
||||
return true
|
||||
}, teatest.WithDuration(1*time.Second), teatest.WithCheckInterval(1*time.Millisecond))
|
||||
}
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
tm.WaitFinished(t)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue