Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/cmd/install_k0s.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log"
"path/filepath"
"strings"

packageio "github.com/codesphere-cloud/cs-go/pkg/io"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -236,6 +237,7 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0
if err != nil {
return fmt.Errorf("failed to retrieve kubeconfig from k0sctl: %w", err)
}
kubeconfigContent = strings.TrimRight(kubeconfigContent, "\n\r")

vault, wasEncrypted, err := c.loadOrCreateVault()
if err != nil {
Expand Down
45 changes: 42 additions & 3 deletions cli/cmd/install_k0s_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ var _ = Describe("InstallK0sCmd", func() {
setupCommonMocks()
mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\n", nil)
mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(false)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool {
return !strings.Contains(string(data), "content: |+")
}), os.FileMode(0600)).Return(nil)

err := c.InstallK0s(mockPM, mockK0s, mockK0sctl)
Expect(err).NotTo(HaveOccurred())
Expand Down Expand Up @@ -290,7 +292,9 @@ var _ = Describe("InstallK0sCmd", func() {
setupCommonMocks()
mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\n", nil)
mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(true)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool {
return !strings.Contains(string(data), "content: |+")
}), os.FileMode(0600)).Return(nil)

err = c.InstallK0s(mockPM, mockK0s, mockK0sctl)
Expect(err).NotTo(HaveOccurred())
Expand Down Expand Up @@ -322,12 +326,47 @@ var _ = Describe("InstallK0sCmd", func() {
setupCommonMocks()
mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\nnew: true\n", nil)
mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(true)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool {
return !strings.Contains(string(data), "content: |+")
}), os.FileMode(0600)).Return(nil)

err = c.InstallK0s(mockPM, mockK0s, mockK0sctl)
Expect(err).NotTo(HaveOccurred())
})

It("trims trailing newlines from kubeconfig before storing in vault", func() {
c.Opts.InstallConfig = writeTestConfig(createTestConfig(true))
c.Opts.Package = "test-package.tar.gz"
c.Opts.Version = "v1.30.0+k0s.0"
c.Opts.Vault = filepath.Join(tempDir, "prod.vault.yaml")

setupCommonMocks()
// kubeconfig with multiple trailing newlines — should be stripped
mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").
Return("apiVersion: v1\nkind: Config\n\n\n", nil)
mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(false)
mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool {
// Must not contain |+ chomping — trailing newlines should be stripped
if strings.Contains(string(data), "content: |+") {
return false
}
// Verify the stored kubeconfig has no trailing newlines
var vault files.InstallVault
if err := yaml.Unmarshal(data, &vault); err != nil {
return false
}
for _, s := range vault.Secrets {
if s.Name == "kubeConfig" && s.File != nil {
return s.File.Content == "apiVersion: v1\nkind: Config"
}
}
return false
}), os.FileMode(0600)).Return(nil)

err := c.InstallK0s(mockPM, mockK0s, mockK0sctl)
Expect(err).NotTo(HaveOccurred())
})

It("fails when GetKubeconfig fails", func() {
c.Opts.InstallConfig = writeTestConfig(createTestConfig(true))
c.Opts.Package = "test-package.tar.gz"
Expand Down
29 changes: 27 additions & 2 deletions internal/installer/vault_encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"filippo.io/age"
sopsage "github.com/getsops/sops/v3/age"
"go.yaml.in/yaml/v3"
)

var (
Expand Down Expand Up @@ -158,7 +159,7 @@ func generateAgeKey(keyPath string) (string, error) {

// EncryptFileWithSOPS encrypts src with SOPS+age and writes ciphertext to target.
func EncryptFileWithSOPS(src, target, recipient string) error {
cmd := exec.Command("sops", "--encrypt", "--age", recipient, "--output", target, src)
cmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", target, src)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("sops encrypt failed: %w: %s", err, out)
Expand All @@ -169,7 +170,7 @@ func EncryptFileWithSOPS(src, target, recipient string) error {
// DecryptFileWithSOPS decrypts a SOPS-encrypted file and returns the plaintext bytes.
// If keyPath is non-empty, SOPS_AGE_KEY_FILE is set for the sops process.
func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) {
cmd := exec.Command("sops", "--decrypt", src)
cmd := exec.Command("sops", "--decrypt", "--input-type", "yaml", src)
if keyPath != "" {
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+keyPath)
}
Expand All @@ -182,3 +183,27 @@ func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) {
}
return out, nil
}

// unwrapSOPSData strips a top-level "data" literal block scalar wrapper if
// present. When SOPS encrypts with --input-type yaml, it
Comment thread
joka134 marked this conversation as resolved.
// wraps the entire document under a data: | key.
func unwrapSOPSData(data []byte) []byte {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return data
}
if len(doc.Content) == 0 {
return data
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode || len(root.Content) != 2 {
return data
}
keyNode := root.Content[0]
valNode := root.Content[1]
if keyNode.Value != "data" || valNode.Kind != yaml.ScalarNode {
return data
}
// The scalar value is the inner YAML content.
return []byte(valNode.Value)
}
77 changes: 77 additions & 0 deletions internal/installer/vault_encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,83 @@ var _ = Describe("VaultEncryption", func() {
Expect(err).To(HaveOccurred())
})
})

Describe("LoadVaultData", func() {
var tmpDir string

BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "load-vault-test-*")
Expect(err).ToNot(HaveOccurred())
})

AfterEach(func() {
Expect(os.RemoveAll(tmpDir)).To(Succeed())
})

It("parses a plain vault file without data: wrapper", func() {
vaultPath := filepath.Join(tmpDir, "plain.vault.yaml")
plainYAML := "secrets:\n - name: test-secret\n fields:\n password: hunter2\n"
Expect(os.WriteFile(vaultPath, []byte(plainYAML), 0644)).To(Succeed())

vault, err := installer.LoadVaultData(vaultPath, "")
Expect(err).ToNot(HaveOccurred())
Expect(vault.Secrets).To(HaveLen(1))
Expect(vault.Secrets[0].Name).To(Equal("test-secret"))
Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2"))
})

It("unwraps a plain file with data: | wrapper (SOPS whole-file format edge case)", func() {
vaultPath := filepath.Join(tmpDir, "wrapped.vault.yaml")
wrappedYAML := "data: |\n secrets:\n - name: test-secret\n fields:\n password: hunter2\n"
Expect(os.WriteFile(vaultPath, []byte(wrappedYAML), 0644)).To(Succeed())

vault, err := installer.LoadVaultData(vaultPath, "")
Expect(err).ToNot(HaveOccurred())
Expect(vault.Secrets).To(HaveLen(1))
Expect(vault.Secrets[0].Name).To(Equal("test-secret"))
Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2"))
})

It("loads and decrypts a SOPS-encrypted vault end-to-end", func() {
if !sopsAndAgeAvailable() {
Skip("sops and age-keygen not available")
}

// Generate an age keypair.
ageKeyPath := filepath.Join(tmpDir, "age_key.txt")
out, err := exec.Command("age-keygen", "-o", ageKeyPath).CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))

// Extract the public key (recipient).
recipient, _, err := installer.ResolveAgeKey(ageKeyPath, tmpDir)
Expect(err).ToNot(HaveOccurred())

// Write a plain vault file.
plainPath := filepath.Join(tmpDir, "plain.vault.yaml")
plainYAML := "secrets:\n - name: sops-secret\n fields:\n password: s3cr3t\n"
Expect(os.WriteFile(plainPath, []byte(plainYAML), 0644)).To(Succeed())

// Encrypt with SOPS using --input-type yaml (whole-file mode,
// which wraps content under data: |).
vaultPath := filepath.Join(tmpDir, "encrypted.vault.yaml")
encryptCmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", vaultPath, plainPath)
encOut, err := encryptCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(encOut))

// LoadVaultData should detect SOPS, decrypt, unwrap data: |, and parse.
vault, err := installer.LoadVaultData(vaultPath, ageKeyPath)
Expect(err).ToNot(HaveOccurred())
Expect(vault.Secrets).To(HaveLen(1))
Expect(vault.Secrets[0].Name).To(Equal("sops-secret"))
Expect(vault.Secrets[0].Fields.Password).To(Equal("s3cr3t"))
})

It("returns an error for a non-existent file", func() {
_, err := installer.LoadVaultData(filepath.Join(tmpDir, "missing.yaml"), "")
Expect(err).To(HaveOccurred())
})
})
})

func splitLines(s string) []string {
Expand Down
47 changes: 47 additions & 0 deletions internal/installer/vault_encryption_unexported_test.go
Comment thread
OliverTrautvetter marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Codesphere Inc.
// SPDX-License-Identifier: Apache-2.0

package installer

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("unwrapSOPSData", func() {
It("returns data unchanged when there is no data: wrapper", func() {
input := []byte("secrets:\n - name: foo\n fields:\n password: bar\n")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal(string(input)))
})

It("strips a top-level data: | wrapper and returns inner content", func() {
input := []byte("data: |\n secrets:\n - name: foo\n fields:\n password: bar\n")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal("secrets:\n - name: foo\n fields:\n password: bar\n"))
})

It("returns data unchanged for an empty document", func() {
input := []byte("")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal(string(input)))
})

It("returns data unchanged when root has multiple keys", func() {
input := []byte("data: some-value\nsops:\n key: val\n")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal(string(input)))
})

It("returns data unchanged for invalid YAML", func() {
input := []byte("not: valid: yaml: [[")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal(string(input)))
})

It("returns data unchanged when data is not a scalar", func() {
input := []byte("data:\n nested: value\n")
output := unwrapSOPSData(input)
Expect(string(output)).To(Equal(string(input)))
})
})
2 changes: 2 additions & 0 deletions internal/installer/vault_templating_secret_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ func isSOPSEncryptedYAML(data []byte) (bool, error) {
}

func parseVaultData(data []byte) (*files.InstallVault, error) {
data = unwrapSOPSData(data)

vault := &files.InstallVault{}
if err := vault.Unmarshal(data); err != nil {
return nil, err
Expand Down
Loading