From a01190c8148ec618a77aa222da67a56f6241eb76 Mon Sep 17 00:00:00 2001 From: Ryan D Date: Wed, 15 Mar 2023 21:55:35 +1100 Subject: [PATCH] Implemented new command --- README.md | 49 +++++++++++++----- main.go | 129 +++++++++++++++++++++++++++++++++--------------- main_test.go | 36 +++++++------- prompt.go | 82 +++++++++++++++++++++++++++--- prompt_test.go | 21 ++++++-- sealedsecret.go | 20 ++++++-- 6 files changed, 253 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 413a2a7..b64c847 100644 --- a/README.md +++ b/README.md @@ -61,19 +61,10 @@ Or you can build/install using standard [Go](https://go.dev/doc/tutorial/compile ## Usage -### Rotate +### Loading values from files -Rotate secrets in an existing SealedSecret: - -``` -kubesealplus rotate templates/secret-password.production.yaml -``` +When using the `new` or `rotate` commands please note the following for values: -You'll be prompted to input secret values for each existing key then confirm -before the file is written to; -* Enter a value and press return (newline) to complete the value -* If you want to skip rotating some keys, don't enter anything for that key and - press return (newline) * When a string literal is used as the value, white space will be trimmed including leading and trailing spaces, tabs, and newline characters (per Go's strings.TrimSpace) @@ -86,6 +77,36 @@ before the file is written to; and the auto-detection will not run (as first char will not be `/`, and the leading space will be trimmed) +### New command + +Create a new SealedSecret from scratch: + +``` +kubesealplus new templates/secret-password.production.yaml +``` + +You'll be prompted to: +1. Enter the namespace to scope your SealedSecret to. +2. Enter the key-value pairs per line, separated by an equals sign (`=`). +3. Once done entering key-value pairs, press enter on a blank line. +4. Review your entered key-value pairs and ensure they were parsed correctly + (e.g. per the rules/logic noted above) + +### Rotate commnad + +Rotate secrets in an existing SealedSecret: + +``` +kubesealplus rotate templates/secret-password.production.yaml +``` + +You'll be prompted to input secret values for each existing key then confirm +before the file is written to; +* Enter a value and press return (newline) to complete the value +* If you want to skip rotating some keys, don't enter anything for that key and + press return (newline) +* Take note of the rules/logic noted above + ### Config Configure the Sealed Secret public key/cert URL for the `production` @@ -95,6 +116,12 @@ environment: kubesealplus config production cert https://sealed-secrets-controller.production.example.com/v1/cert.pem ``` +Otherwise configure it from a local file: + +``` +kubesealplus config production cert /path/to/cert.pem +``` + ## Sharing your Public Cert/Key Per the `config ... cert ...` usage instructions above, this tool can fetch the diff --git a/main.go b/main.go index 6f2bc56..58e1c7a 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "path/filepath" "regexp" "strings" - "time" ) func nameAndEnvFromFilename(path string) (name string, environment string, err error) { @@ -40,6 +39,12 @@ func main() { command = os.Args[1] } switch command { + case "new": + if len(os.Args) != 3 || len(os.Args[2]) == 0 { + fmt.Printf("Usage:\n\tkubesealplus new (sealedsecret-filename.yaml)\n") + os.Exit(1) + } + new(os.Args[2]) case "rotate": if len(os.Args) != 3 || len(os.Args[2]) == 0 { fmt.Printf("Usage:\n\tkubesealplus rotate (sealedsecret-filename.yaml)\n") @@ -102,27 +107,21 @@ func configure(environment string, configKey string, configValue string) { fmt.Printf("Cert value '%s'\nfor environment '%s'\nsuccessfully saved to config file '%s'\n", configValue, environment, configFile) } -func rotate(filename string) { - secretName, environment, err := nameAndEnvFromFilename(filename) - if err != nil { - fmt.Printf("%s\n", err) - os.Exit(1) - } - +func loadConfig(environment string) (certFilename string, err error) { configFile, err := ConfigFileDefaultPath("") if err != nil { panic(err) } configDoc := ConfigDoc{} if !configDoc.Exists(configFile) { - fmt.Printf("Config for environment '%s' not found. Run this:\n"+ + err = fmt.Errorf("Config for environment '%s' not found. Run this:\n"+ "kubesealplus config %s cert (your-cert-file)", environment, environment) - os.Exit(1) + return } err = configDoc.Load(configFile) if err != nil { - fmt.Printf("Error loading config file %s: %s\n", configFile, err) - os.Exit(1) + err = fmt.Errorf("Error loading config file %s: %s\n", configFile, err) + return } certConfigValue := configDoc.Environments[environment]["cert"] @@ -130,44 +129,106 @@ func rotate(filename string) { // we probably only need to download it at most once per hour (or day?) cert, err := CertLoad(certConfigValue) if err != nil { - fmt.Printf("Unable to load cert '%s':\n%s\n", certConfigValue, err) + err = fmt.Errorf("unable to load cert '%s':\n%s\n", certConfigValue, err) + return + } + certFilename, err = ConfigWriteCert(environment, cert) + if err != nil { + err = fmt.Errorf("Unable to write latest cert to disk:\n%s\n", err) + return + } + return +} + +func new(filename string) { + fileInfo, err := os.Stat(filename) + if err == nil && fileInfo != nil { + fmt.Printf("Error: cannot create new file as file already exists\n\t%s\n", filename) + os.Exit(1) + } + + secretName, environment, err := nameAndEnvFromFilename(filename) + if err != nil { + fmt.Printf("%s\n", err) os.Exit(1) } - certFilename, err := ConfigWriteCert(environment, cert) + secrets := PromptSecrets{} + namespace, err := secrets.Namespace(os.Stdin, os.Stdout) if err != nil { - fmt.Printf("Unable to write latest cert to disk:\n%s\n", err) + fmt.Printf("%s\n", err) os.Exit(1) } + sealedSecret := SealedSecret{Environment: environment} + sealedSecret.Init(secretName, namespace) + + rotateAndNew(&sealedSecret, secrets) + + file, err := os.Create(filename) + if err != nil { + fmt.Printf("Error creating new file: %s\n", err) + os.Exit(1) + } + defer file.Close() + out, err := sealedSecret.ToTemplate(file, environment) + if err != nil { + fmt.Printf("error writing SealedSecret file %s: %s\n", filename, err) + os.Exit(1) + } + fmt.Printf("Created SealedSecret file '%s' with content:\n%s", filename, out.String()) +} + +func rotate(filename string) { file, err := os.OpenFile(filename, os.O_RDWR, 0644) if err != nil { fmt.Printf("Cannot open file: %s\n", filename) os.Exit(1) } defer file.Close() - template, err := io.ReadAll(file) if err != nil { fmt.Printf("Cannot read file: %s\n", filename) os.Exit(1) } - + _, environment, err := nameAndEnvFromFilename(filename) + if err != nil { + fmt.Printf("%s\n", err) + os.Exit(1) + } sealedSecret, err := sealedSecretFromTemplate(filename, environment, string(template)) if err != nil { fmt.Printf("%s\n", err) os.Exit(1) } - - keys := []string{} + secrets := PromptSecrets{} for k := range sealedSecret.Spec.EncryptedData { - keys = append(keys, k) + secrets.InitKey(k) + } + rotateAndNew(&sealedSecret, secrets) + + out, err := sealedSecret.ToTemplate(file, environment) + if err != nil { + fmt.Printf("error writing SealedSecret file %s: %s\n", filename, err) + os.Exit(1) + } + fmt.Printf("Updated SealedSecret file '%s' with content:\n%s", filename, out.String()) +} + +func rotateAndNew(sealedSecret *SealedSecret, secrets PromptSecrets) { + certFilename, err := loadConfig(sealedSecret.Environment) + if err != nil { + fmt.Printf("%s\n", err) + os.Exit(1) } - secrets := PromptSecrets{} - secrets.InitKeys(keys) redo := 0 for { - err = secrets.Enter(redo, os.Stdin, os.Stdout) + var err error + if len(secrets.secrets) > 0 { + err = secrets.Update(redo, os.Stdin, os.Stdout) + } else { + err = secrets.Enter(os.Stdin, os.Stdout) + } if err != nil { fmt.Printf("%s\n", err) os.Exit(1) @@ -183,18 +244,7 @@ func rotate(filename string) { } PromptClear(os.Stdout) - // TODO: support creating new sealed secrets from scratch newSecrets := secrets.ToValues() - if len(sealedSecret.Spec.Template.Metadata) == 0 { - timestamp := time.Now().UTC().Format(time.RFC3339) - // TODO: get namespace for new secrets - secretNamespace := "" - sealedSecret.Spec.Template.Metadata = map[string]*string{ - "creationTimestamp": ×tamp, - "name": &secretName, - "namespace": &secretNamespace, - } - } secretYAML, err := createSecretYAML( sealedSecret.Spec.Template.Metadata, newSecrets, @@ -203,6 +253,7 @@ func rotate(filename string) { fmt.Printf("error creating Secret:\n%s\n", err) os.Exit(1) } + newSealedSecrets, err := createSealedSecrets(secretYAML, certFilename) if err != nil { fmt.Printf("error creating SealedSecret via kubeseal:\n%s\n", err) @@ -213,14 +264,10 @@ func rotate(filename string) { "number of secrets returned do not match number given") os.Exit(1) } + if sealedSecret.Spec.EncryptedData == nil { + sealedSecret.Spec.EncryptedData = map[string]string{} + } for k, v := range newSealedSecrets { sealedSecret.Spec.EncryptedData[k] = v } - out, err := sealedSecret.ToTemplate(file, environment) - if err != nil { - fmt.Printf("error writing SealedSecret to template %s:\n%s\n", filename, err) - os.Exit(1) - } - fmt.Printf("Wrote new SealedSecret to file\n%s\nwith content:\n%s", filename, out.String()) - } diff --git a/main_test.go b/main_test.go index 52af09b..19f5d60 100644 --- a/main_test.go +++ b/main_test.go @@ -13,26 +13,26 @@ func TestEnvFromFilename(t *testing.T) { expectError bool }{ { - filename: "/path/to/templates/secret-example.production.yaml", + filename: "/path/to/templates/secret-example.testing.yaml", expectName: "example", - expectEnv: "production", + expectEnv: "testing", }, { - filename: "./templates/secret-example.production.yaml", + filename: "./templates/secret-example.testing.yaml", expectName: "example", - expectEnv: "production", + expectEnv: "testing", }, { - filename: "templates/secret-example.production.yaml", + filename: "templates/secret-example.testing.yaml", expectName: "example", - expectEnv: "production", + expectEnv: "testing", }, { - filename: "secret-example.production.yaml", + filename: "secret-example.testing.yaml", expectName: "example", - expectEnv: "production", + expectEnv: "testing", }, { - filename: "secret-example.test.production.yaml", + filename: "secret-example.test.testing.yaml", expectError: true, }, { @@ -48,7 +48,7 @@ func TestEnvFromFilename(t *testing.T) { expectError: true, }, { - filename: "secret-example.production.yml", + filename: "secret-example.testing.yml", expectError: true, }, } @@ -75,7 +75,7 @@ func TestSealedSecretFromTemplate(t *testing.T) { { expectError: false, data: ` -{{- if eq .Values.environment "production" }} +{{- if eq .Values.environment "testing" }} apiVersion: bitnami.com/v1alpha1 kind: SealedSecret metadata: @@ -95,9 +95,9 @@ spec: }, { expectError: false, - expectSealedSecret: &SealedSecret{ApiVersion: "bitnami.com/v1alpha1"}, + expectSealedSecret: &SealedSecret{Environment: "testing", ApiVersion: "bitnami.com/v1alpha1"}, data: ` -{{- if eq .Values.environment "production" }} +{{- if eq .Values.environment "testing" }} apiVersion: "bitnami.com/v1alpha1" {{- end }}`, }, @@ -108,7 +108,7 @@ apiVersion: "bitnami.com/v1alpha1" { expectError: true, data: ` - {{- if eq .Values.environment "production" }} + {{- if eq .Values.environment "testing" }} {{- end }} `, }, @@ -120,17 +120,17 @@ apiVersion: "bitnami.com/v1alpha1" `, }, } - filename := "templates/secret-example.production.yaml" - environment := "production" + filename := "templates/secret-example.testing.yaml" + environment := "testing" i := 0 for _, test := range tests { i++ sealedSecret, err := sealedSecretFromTemplate(filename, environment, test.data) if err != nil && !test.expectError { - t.Errorf("Unexpected error in test %d: %s", i, err) + t.Errorf("(Test %d)Unexpected error: %s", i, err) } if err == nil && test.expectSealedSecret != nil && !reflect.DeepEqual(*test.expectSealedSecret, sealedSecret) { - t.Errorf("Parsed SealedSecret does not match.\nExpected:\n%+v\nGot:\n%+v", *test.expectSealedSecret, sealedSecret) + t.Errorf("(Test %d) Parsed SealedSecret does not match.\nExpected:\n%+v\nGot:\n%+v", i, *test.expectSealedSecret, sealedSecret) } } } diff --git a/prompt.go b/prompt.go index 7a4907f..894f5bc 100644 --- a/prompt.go +++ b/prompt.go @@ -28,13 +28,13 @@ const PromptSecretInput_Kind_File PromptSecretInput_Kind = "file" const PromptSecretInput_Kind_String PromptSecretInput_Kind = "string" const PromptSecretInput_Kind_None PromptSecretInput_Kind = "none" -func (s *PromptSecrets) InitKeys(keys []string) { - s.secrets = []PromptSecretInput{} - for _, k := range keys { - s.secrets = append(s.secrets, PromptSecretInput{ - key: k, - }) +func (s *PromptSecrets) InitKey(key string) { + if s.secrets == nil { + s.secrets = []PromptSecretInput{} } + s.secrets = append(s.secrets, PromptSecretInput{ + key: key, + }) } func (s PromptSecrets) ToValues() map[string]string { @@ -52,7 +52,75 @@ func (s PromptSecrets) ToValues() map[string]string { return values } -func (s *PromptSecrets) Enter(redo int, input io.Reader, output io.Writer) (err error) { +func (s *PromptSecrets) Namespace(input io.Reader, output io.Writer) (namespace string, err error) { + fmt.Fprintf( + output, + "%s%s\n\nWhat namespace will this Sealed Secret be scoped to?:\n", + ANSI_ESCAPE_CLEAR, + strings.Repeat(`-`, 80), + ) + reader := bufio.NewReader(input) + for { + fmt.Fprintf(output, "namespace=") + var line string + line, err = reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSuffix(line, "\n") + if line == "" { + fmt.Fprintf(output, "WARNING: Invalid namespace values are ignored, please re-enter a namespace.\n") + continue + } + return line, nil + } +} + +func (s *PromptSecrets) Enter(input io.Reader, output io.Writer) (err error) { + fmt.Fprintf( + output, + "%s%s\n\nEnter a key and value separated by =, leave blank and press enter when finished:\n", + ANSI_ESCAPE_CLEAR, + strings.Repeat(`-`, 80), + ) + reader := bufio.NewReader(input) + i := 0 + for { + var line string + line, err = reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSuffix(line, "\n") + if line == "" { + break + } + lineSplit := strings.SplitN(line, "=", 2) + if len(lineSplit) != 2 || strings.TrimSpace(lineSplit[0]) == "" || strings.TrimSpace(lineSplit[1]) == "" { + fmt.Fprintf(output, "WARNING: Lines not containing key and value separated by '=' are ignored\n") + continue + } + key := strings.TrimSpace(lineSplit[0]) + value := lineSplit[1] + s.secrets = append(s.secrets, PromptSecretInput{ + key: key, + value: strings.TrimSpace(value), + }) + valueFromFile, readFileErr := os.ReadFile(value) + if value == "" { + s.secrets[i].kind = PromptSecretInput_Kind_None + } else if readFileErr == nil { + s.secrets[i].kind = PromptSecretInput_Kind_File + s.secrets[i].valueFromFile = string(valueFromFile) + } else { + s.secrets[i].kind = PromptSecretInput_Kind_String + } + i++ + } + return +} + +func (s *PromptSecrets) Update(redo int, input io.Reader, output io.Writer) (err error) { fmt.Fprintf( output, "%s%s\n\nPlease enter your secrets for each key then press enter:\n", diff --git a/prompt_test.go b/prompt_test.go index 440ca7a..0775b25 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -6,12 +6,27 @@ import ( ) func TestPromptEnter(t *testing.T) { + in := bytes.Buffer{} + out := bytes.Buffer{} + in.WriteString("SECRET1=test\n\n") + secrets := PromptSecrets{} + err := secrets.Enter(&in, &out) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } else if len(secrets.secrets) != 1 || + secrets.secrets[0].key != "SECRET1" || + secrets.secrets[0].value != "test" { + t.Errorf("Expected SECRET1=test, got:\n%s=%s", secrets.secrets[0].key, secrets.secrets[0].value) + } +} + +func TestPromptUpdate(t *testing.T) { in := bytes.Buffer{} out := bytes.Buffer{} in.WriteString("test\n") secrets := PromptSecrets{} - secrets.InitKeys([]string{"SECRET1"}) - err := secrets.Enter(0, &in, &out) + secrets.InitKey("SECRET1") + err := secrets.Update(0, &in, &out) if err != nil { t.Errorf("Unexpected error: %s", err) } else if len(secrets.secrets) != 1 || @@ -25,7 +40,7 @@ func TestPromptConfirm(t *testing.T) { out := bytes.Buffer{} in.WriteString("Y\n") secrets := PromptSecrets{} - secrets.InitKeys([]string{"SECRET1"}) + secrets.InitKey("SECRET1") redo, err := secrets.Confirm(&in, &out) if err != nil { t.Errorf("Unexpected error: %s", err) diff --git a/sealedsecret.go b/sealedsecret.go index d353426..f08d962 100644 --- a/sealedsecret.go +++ b/sealedsecret.go @@ -15,10 +15,11 @@ import ( ) type SealedSecret struct { - ApiVersion string `json:"apiVersion" yaml:"apiVersion"` - Kind string `json:"kind" yaml:"kind"` - Metadata map[string]*string `json:"metadata" yaml:"metadata"` - Spec struct { + Environment string `json:"-" yaml:"-"` + ApiVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata map[string]*string `json:"metadata" yaml:"metadata"` + Spec struct { EncryptedData map[string]string `json:"encryptedData,omitempty" yaml:"encryptedData,omitempty"` Template struct { Data *map[string]*string `json:"data" yaml:"data"` @@ -58,6 +59,16 @@ func createSealedSecrets(secretYAML string, certFilename string) (sealedSecrets const firstLineTemplate = "{{- if eq .Values.environment \"%s\" }}" const lastLineTemplate = `{{- end }}` +func (s *SealedSecret) Init(name string, namespace string) { + s.ApiVersion = "bitnami.com/v1alpha1" + s.Kind = "SealedSecret" + s.Metadata = map[string]*string{ + "name": &name, + "namespace": &namespace, + } + s.Spec.Template.Metadata = s.Metadata +} + func sealedSecretFromTemplate(filename string, environment string, template string) (sealedSecret SealedSecret, err error) { expectFirstLine := fmt.Sprintf(firstLineTemplate, environment) const expectLastLine = lastLineTemplate @@ -95,6 +106,7 @@ func sealedSecretFromTemplate(filename string, environment string, template stri return } + sealedSecret.Environment = environment return }