All checks were successful
continuous-integration/drone/push Build is passing
This implements proper modifier support in the env file using this new fork of the godotenv library. The modifier implementation is quite basic for but can be improved later if needed. See this commit for the actual implementation. Because we are now using proper modifer parsing, it does not affect the parsing of value, so this is possible again: ``` MY_VAR="#foo" ``` Closes coop-cloud/organising#535
266 lines
6.9 KiB
Go
266 lines
6.9 KiB
Go
// Package secret provides functionality for generating and storing secrets
|
|
// both in a remote swarm and locally within supported storage such as pass
|
|
// stores.
|
|
package secret
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"coopcloud.tech/abra/pkg/client"
|
|
"coopcloud.tech/abra/pkg/config"
|
|
"coopcloud.tech/abra/pkg/upstream/stack"
|
|
loader "coopcloud.tech/abra/pkg/upstream/stack"
|
|
"github.com/decentral1se/passgen"
|
|
"github.com/docker/docker/api/types"
|
|
dockerClient "github.com/docker/docker/client"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// SecretValue represents a parsed `SECRET_FOO=v1 # length=bar` env var config
|
|
// secret definition.
|
|
type SecretValue struct {
|
|
Version string
|
|
Length int
|
|
}
|
|
|
|
// GeneratePasswords generates passwords.
|
|
func GeneratePasswords(count, length uint) ([]string, error) {
|
|
passwords, err := passgen.GeneratePasswords(
|
|
count,
|
|
length,
|
|
passgen.AlphabetDefault,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logrus.Debugf("generated %s", strings.Join(passwords, ", "))
|
|
|
|
return passwords, nil
|
|
}
|
|
|
|
// GeneratePassphrases generates human readable and rememberable passphrases.
|
|
func GeneratePassphrases(count uint) ([]string, error) {
|
|
passphrases, err := passgen.GeneratePassphrases(
|
|
count,
|
|
passgen.PassphraseWordCountDefault,
|
|
rune('-'),
|
|
passgen.PassphraseCasingDefault,
|
|
passgen.WordListDefault,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logrus.Debugf("generated %s", strings.Join(passphrases, ", "))
|
|
|
|
return passphrases, nil
|
|
}
|
|
|
|
// ReadSecretsConfig reads secret names/versions from the recipe config. The
|
|
// function generalises appEnv/composeFiles because some times you have an app
|
|
// and some times you don't (as the caller). We need to be able to handle the
|
|
// "app new" case where we pass in the .env.sample and the "secret generate"
|
|
// case where the app is created.
|
|
func ReadSecretsConfig(appEnvPath string, composeFiles []string, recipeName string) (map[string]SecretValue, error) {
|
|
appEnv, appModifiers, err := config.ReadEnvWithModifiers(appEnvPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
opts := stack.Deploy{Composefiles: composeFiles}
|
|
config, err := loader.LoadComposefile(opts, appEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
configWithoutEnv, err := loader.LoadComposefile(opts, map[string]string{}, loader.SkipInterpolation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var enabledSecrets []string
|
|
for _, service := range config.Services {
|
|
for _, secret := range service.Secrets {
|
|
enabledSecrets = append(enabledSecrets, secret.Source)
|
|
}
|
|
}
|
|
|
|
if len(enabledSecrets) == 0 {
|
|
logrus.Debugf("not generating app secrets, none enabled in recipe config")
|
|
return nil, nil
|
|
}
|
|
|
|
secretValues := map[string]SecretValue{}
|
|
for secretId, secretConfig := range config.Secrets {
|
|
if string(secretConfig.Name[len(secretConfig.Name)-1]) == "_" {
|
|
return nil, fmt.Errorf("missing version for secret? (%s)", secretId)
|
|
}
|
|
|
|
if !(slices.Contains(enabledSecrets, secretId)) {
|
|
logrus.Warnf("%s not enabled in recipe config, skipping", secretId)
|
|
continue
|
|
}
|
|
|
|
lastIdx := strings.LastIndex(secretConfig.Name, "_")
|
|
secretVersion := secretConfig.Name[lastIdx+1:]
|
|
value := SecretValue{Version: secretVersion}
|
|
|
|
// Check if the length modifier is set for this secret.
|
|
for k, v := range appModifiers {
|
|
// configWithoutEnv contains the raw name as defined in the compose.yaml
|
|
if !strings.Contains(configWithoutEnv.Secrets[secretId].Name, k) {
|
|
continue
|
|
}
|
|
lengthRaw, ok := v["length"]
|
|
if ok {
|
|
length, err := strconv.Atoi(lengthRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
value.Length = length
|
|
}
|
|
break
|
|
}
|
|
secretValues[secretId] = value
|
|
}
|
|
|
|
return secretValues, nil
|
|
}
|
|
|
|
// GenerateSecrets generates secrets locally and sends them to a remote server for storage.
|
|
func GenerateSecrets(cl *dockerClient.Client, secrets map[string]SecretValue, appName, server string) (map[string]string, error) {
|
|
secretsGenerated := map[string]string{}
|
|
var mutex sync.Mutex
|
|
var wg sync.WaitGroup
|
|
ch := make(chan error, len(secrets))
|
|
for n, v := range secrets {
|
|
wg.Add(1)
|
|
|
|
go func(secretName string, secret SecretValue) {
|
|
defer wg.Done()
|
|
|
|
secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secret.Version)
|
|
logrus.Debugf("attempting to generate and store %s on %s", secretRemoteName, server)
|
|
|
|
if secret.Length > 0 {
|
|
passwords, err := GeneratePasswords(1, uint(secret.Length))
|
|
if err != nil {
|
|
ch <- err
|
|
return
|
|
}
|
|
|
|
if err := client.StoreSecret(cl, secretRemoteName, passwords[0], server); err != nil {
|
|
if strings.Contains(err.Error(), "AlreadyExists") {
|
|
logrus.Warnf("%s already exists, moving on...", secretRemoteName)
|
|
ch <- nil
|
|
} else {
|
|
ch <- err
|
|
}
|
|
return
|
|
}
|
|
|
|
mutex.Lock()
|
|
defer mutex.Unlock()
|
|
secretsGenerated[secretName] = passwords[0]
|
|
} else {
|
|
passphrases, err := GeneratePassphrases(1)
|
|
if err != nil {
|
|
ch <- err
|
|
return
|
|
}
|
|
|
|
if err := client.StoreSecret(cl, secretRemoteName, passphrases[0], server); err != nil {
|
|
if strings.Contains(err.Error(), "AlreadyExists") {
|
|
logrus.Warnf("%s already exists, moving on...", secretRemoteName)
|
|
ch <- nil
|
|
} else {
|
|
ch <- err
|
|
}
|
|
return
|
|
}
|
|
|
|
mutex.Lock()
|
|
defer mutex.Unlock()
|
|
secretsGenerated[secretName] = passphrases[0]
|
|
}
|
|
ch <- nil
|
|
}(n, v)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
for range secrets {
|
|
err := <-ch
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
logrus.Debugf("generated and stored %v on %s", secrets, server)
|
|
|
|
return secretsGenerated, nil
|
|
}
|
|
|
|
type secretStatus struct {
|
|
LocalName string
|
|
RemoteName string
|
|
Version string
|
|
CreatedOnRemote bool
|
|
}
|
|
|
|
type secretStatuses []secretStatus
|
|
|
|
// PollSecretsStatus checks status of secrets by comparing the local recipe
|
|
// config and deploymend server state.
|
|
func PollSecretsStatus(cl *dockerClient.Client, app config.App) (secretStatuses, error) {
|
|
var secStats secretStatuses
|
|
|
|
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
|
|
if err != nil {
|
|
return secStats, err
|
|
}
|
|
|
|
secretsConfig, err := ReadSecretsConfig(app.Path, composeFiles, app.Recipe)
|
|
if err != nil {
|
|
return secStats, err
|
|
}
|
|
|
|
filters, err := app.Filters(false, false)
|
|
if err != nil {
|
|
return secStats, err
|
|
}
|
|
|
|
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
|
|
if err != nil {
|
|
return secStats, err
|
|
}
|
|
|
|
remoteSecretNames := make(map[string]bool)
|
|
for _, cont := range secretList {
|
|
remoteSecretNames[cont.Spec.Annotations.Name] = true
|
|
}
|
|
|
|
for secretName, val := range secretsConfig {
|
|
createdRemote := false
|
|
|
|
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version)
|
|
if _, ok := remoteSecretNames[secretRemoteName]; ok {
|
|
createdRemote = true
|
|
}
|
|
|
|
secStats = append(secStats, secretStatus{
|
|
LocalName: secretName,
|
|
RemoteName: secretRemoteName,
|
|
Version: val.Version,
|
|
CreatedOnRemote: createdRemote,
|
|
})
|
|
}
|
|
|
|
return secStats, nil
|
|
}
|