Skip to content

Commit

Permalink
Cosmetics + E2E test
Browse files Browse the repository at this point in the history
  • Loading branch information
bastjan committed Jan 23, 2024
1 parent b8b08d1 commit 7ab74b0
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ jobs:
${{ runner.os }}-go-
- name: Run tests
run: make test
run: make test test-e2e
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ test: ## Run tests
go test ./... -coverprofile cover.tmp.out
cat cover.tmp.out | grep -v "zz_generated.deepcopy.go" > cover.out

.PHONY: test-e2e
test-e2e: build ## Run e2e tests
(cd e2e && ./interactive.tcl)

.PHONY: build
build: generate fmt vet $(BIN_FILENAME) ## Build manager binary

Expand Down
59 changes: 59 additions & 0 deletions e2e/interactive.tcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env expect -f

source ./lib/common.tcl

set timeout 60

set cluster_id "c-appuio-lab-cloudscale-rma-0"
set api_endpoint "https://api.lab-cloudscale-rma-0.appuio.cloud:6443"

set passphrase [getenv_or_die "E2E_PASSBOLT_PASSPHRASE"]
set private_key [getenv_or_die "E2E_PASSBOLT_PRIVATE_KEY"]

proc expect_prompt {prompt} {
expect -exact "$prompt"
expect -exact "> "
sleep .5
}

# The script assumes vi is used to enter the provate key
set ::env(EDITOR) "vi"
file delete -force config.yaml
set ::env(EMR_CONFIG_DIR) [pwd]

log "Starting tool"
spawn ../emergency-credentials-receive
expect -exact "Welcome"

log "Expecting private key prompt in editor"
expect -exact "Paste your Passbolt private key"
sleep .1
send -- "i"
send -- "$private_key"
# Escape key
send -- "\x1b"
send -- ":x\r"

log "Expecting passphrase prompt"
expect_prompt "Passbolt passphrase"
send -- "$passphrase"
send -- "\r"

log "Expecting cluster ID prompt"
expect_prompt "Enter your cluster ID"
send -- "$cluster_id"
send -- "\r"

log "Expecting to valid credentials"
expect -exact "2 buckets with credentials found"
expect -exact "Emergency credentials found"

log "Expecting API endpoint prompt"
expect_prompt "Provide API endpoint"
send "$api_endpoint"
send -- "\r"
expect eof

test_kubeconfig "em-$cluster_id"

log "Test successful"
26 changes: 26 additions & 0 deletions e2e/lib/common.tcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@

proc log {msg} {
send_user "\n\[TEST\]\t$msg...\n"
}

proc test_kubeconfig {kubeconfig} {
log "Testing kubeconfig $kubeconfig"
set ::env(KUBECONFIG) "$kubeconfig"

log "Testing kubeconfig is allowed to get nodes"
spawn kubectl get nodes
expect -- "master*Ready*master"
expect eof

log "Testing kubeconfig is allowed to delete nodes"
spawn kubectl auth can-i delete nodes
expect -- "yes"
expect eof
}

proc getenv_or_die {var} {
if {![info exists ::env($var)]} {
error "Missing environment variable $var"
}
return "$::env($var)"
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/google/go-jsonnet v0.20.0
github.com/minio/minio-go/v7 v7.0.66
github.com/passbolt/go-passbolt v0.7.0
golang.org/x/term v0.16.0
gopkg.in/yaml.v3 v3.0.1
sigs.k8s.io/yaml v1.1.0
)
Expand Down Expand Up @@ -63,7 +64,6 @@ require (
golang.org/x/net v0.20.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/term v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
Expand Down
123 changes: 95 additions & 28 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"strings"
"text/template"

Expand All @@ -20,6 +24,7 @@ import (
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/passbolt/go-passbolt/api"
"golang.org/x/term"
"gopkg.in/yaml.v3"
ky "sigs.k8s.io/yaml"

Expand All @@ -30,6 +35,17 @@ import (
//go:embed kubectl-config-tmpl.jsonnet
var kubectlTemplate string

var sampleConfig = `
passbolt_key: |
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: OpenPGP.js v4.10.9
Comment: https://openpgpjs.org
[...]
-----END PGP PRIVATE KEY BLOCK-----
`

const (
defaultEndpoint = "https://cloud.passbolt.com/vshn"
defaultKubernetesEndpoint = "https://kubernetes.default.svc:6443"
Expand All @@ -40,6 +56,8 @@ const (

var (
tokenOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575"))
boldStyle = lipgloss.NewStyle().Bold(true)
isTerminal = term.IsTerminal(int(os.Stdout.Fd()))
)

func main() {
Expand All @@ -53,14 +71,17 @@ func main() {

var saveConfig bool
lln("Welcome to the Emergency Credentials Receive tool!")
lln("This tool will help you receive your cluster emergency credentials from Passbolt.")
lf("This tool will help you receive your cluster emergency credentials from Passbolt.\n\n")

c, err := config.RetrieveConfig()
if err != nil {
if err != nil && errors.Is(err, fs.ErrNotExist) {
lf("No config file found at %q.\n", config.ConfigFile())
lln("File will be created after a successful login.")
} else if err != nil {
lln("Error retrieving config: ", err)
}

if c.PassboltKey == "" {
if c.PassboltKey == "" && isTerminal {
k, err := surveyext.Edit("", "", "\n\n# Paste your Passbolt private key from\n# https://cloud.passbolt.com/vshn/app/settings/keys\n", os.Stdin, os.Stdout, os.Stderr)
if err != nil {
lln("Error retrieving passbolt key: ", err)
Expand All @@ -69,17 +90,25 @@ func main() {
saveConfig = true
c.PassboltKey = k
}
if c.PassboltKey == "" {
lf("Passbolt key cannot be empty. Please provide interactively or create a config file at %q:\n%s", config.ConfigFile(), sampleConfig)
os.Exit(1)
}

passphrase := os.Getenv("EMR_PASSPHRASE")
if passphrase == "" {
if passphrase == "" && isTerminal {
pf, err := inputs.PassphraseInput("Enter your Passbolt passphrase", "")
if err != nil {
lln("Error retrieving passbolt passphrase: ", err)
os.Exit(1)
}
passphrase = pf
} else if passphrase == "" {
lln("Passphrase cannot be empty.")
lln("Provide interactively or set EMR_PASSPHRASE environment variable.")
os.Exit(1)
} else {
lln("Using passphrase from EMR_PASSPHRASE environment variable")
lln("Using passphrase from EMR_PASSPHRASE environment variable.")
}

if clusterId == "" {
Expand All @@ -91,20 +120,24 @@ func main() {
clusterId = cid
}
if clusterId == "" {
lln("Cluster ID cannot be empty")
lln("Cluster ID cannot be empty.")
lln("Provide interactively or as argument.")
os.Exit(1)
}

client, err := api.NewClient(nil, userAgent, defaultEndpoint, c.PassboltKey, passphrase)
if err != nil {
panic(err)
lf("Error creating passbolt client: %v\n", err)
os.Exit(1)
}

lln("Logging in...")
ctx := context.TODO()
lln("Logging into passbolt...")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

if err := client.Login(ctx); err != nil {
panic(err)
lf("Error logging into passbolt: %v\n", err)
os.Exit(1)
}

if saveConfig {
Expand All @@ -113,10 +146,10 @@ func main() {
}
}

lln("Logged in. Retrieving bucket configuration...")
lf("Logged in. Retrieving bucket configuration from %q...\n", defaultEmergencyCredentialsBucketConfigName)
res, err := client.GetResources(ctx, &api.GetResourcesOptions{})
if err != nil {
panic(err)
lln("Error retrieving resources from passbolt: ", err)
}

var resource api.Resource
Expand All @@ -127,32 +160,37 @@ func main() {
}
}
if resource.ID == "" {
panic(fmt.Errorf("could not find resource %q", defaultEmergencyCredentialsBucketConfigName))
lln("Error retrieving bucket configuration from passbolt: ", fmt.Errorf("could not find resource %q", defaultEmergencyCredentialsBucketConfigName))
os.Exit(1)
}
lln(" Retrieving bucket secret...")
secret, err := client.GetSecret(ctx, resource.ID)
if err != nil {
panic(err)
lln("Error retrieving bucket secret from passbolt: ", err)
os.Exit(1)
}

lln(" Decrypting bucket secret...")
conf, err := client.DecryptMessage(secret.Data)
if err != nil {
panic(err)
lln("Error decrypting bucket secret in passbolt: ", err)
os.Exit(1)
}

lln(" Parsing passbolt secret...")
var pbsc api.SecretDataTypePasswordAndDescription
if err := json.Unmarshal([]byte(conf), &pbsc); err != nil {
panic(err)
lln("Error parsing the decrypted passbolt secret: ", err)
os.Exit(1)
}
lln(" Parsing bucket secret...")
lln(" Parsing bucket configuration from secret...")
var bc bucketConfig
if err := yaml.Unmarshal([]byte(pbsc.Password), &bc); err != nil {
panic(err)
lln("Error parsing bucket configuration from passbolt secrets password field: ", err)
os.Exit(1)
}

lf("%d buckets with credentials found found\n", len(bc.Buckets))
lf("%d buckets with credentials found\n", len(bc.Buckets))

var emcreds []string
for _, b := range bc.Buckets {
Expand Down Expand Up @@ -191,14 +229,17 @@ func main() {
}
lf(" Downloading %q...\n", objectName)

object, err := mc.GetObject(ctx, b.Bucket, objectName, minio.GetObjectOptions{})
// fully read object into memory, otherwise the error message can be very confusing
// it says something like "JSON unmarshal error: not found"
buf, err := minioGetReadAll(ctx, mc, b.Bucket, objectName)
if err != nil {
lln(" Error downloading object: ", err)
continue
}

lln(" Parsing object...")
var et encryptedToken
if err := json.NewDecoder(object).Decode(&et); err != nil {
if err := json.Unmarshal(buf, &et); err != nil {
lln(" Error parsing object: ", err)
continue
}
Expand Down Expand Up @@ -231,20 +272,46 @@ func main() {
fmt.Println("# ", tokenOutputStyle.Render(c))
}

kep, err := inputs.LineInput("Provide API endpoint to render kubeconfig", defaultKubernetesEndpoint)
if err != nil {
lln("Error retrieving kubernetes endpoint: ", err)
kep := os.Getenv("EMR_KUBERNETES_ENDPOINT")
if kep == "" && isTerminal {
k, err := inputs.LineInput("Provide API endpoint to render kubeconfig", defaultKubernetesEndpoint)
if err != nil {
lln("Error retrieving kubernetes endpoint: ", err)
os.Exit(1)
}
kep = k
}
if kep == "" {
lln("Assuming default kubernetes endpoint.")
kep = defaultKubernetesEndpoint
}

kubeconfig, err := renderKubeconfig(kep, emcreds)
if err != nil {
panic(err)
lln("Error rendering kubeconfig: ", err)
lln("The tokens printed above should continue to work, but you will have to create the kubeconfig manually.")
os.Exit(1)
}
fmt.Println("---")
fmt.Println(kubeconfig)

kcFileName := "em-" + clusterId
if err := os.WriteFile(kcFileName, []byte("# Generated by emergency-credentials-receive\n"+kubeconfig), 0600); err != nil {
lln("Error writing kubeconfig: ", err)
lln("The tokens printed above should continue to work, but you will have to create the kubeconfig manually.")
os.Exit(1)
}
lf("Wrote kubeconfig to %q. Use with:\n\n", kcFileName)
lln(boldStyle.Render(fmt.Sprintf("export KUBECONFIG=%q", kcFileName)))
lln(boldStyle.Render("kubectl get nodes"))
}

// minioGetReadAll is a helper function to fully download a S3 object into memory.
func minioGetReadAll(ctx context.Context, mc *minio.Client, bucket, objectName string) ([]byte, error) {
object, err := mc.GetObject(ctx, bucket, objectName, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
defer object.Close()

return io.ReadAll(object)
}

func lf(format string, a ...any) {
Expand Down
Loading

0 comments on commit 7ab74b0

Please sign in to comment.