Skip to content

Commit

Permalink
adding run command with env loaded from remote config
Browse files Browse the repository at this point in the history
  • Loading branch information
devdinu committed Jun 30, 2023
1 parent ea01be9 commit 6aed4d6
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 39 deletions.
32 changes: 0 additions & 32 deletions cmd/dolores/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package main

import (
"context"
"fmt"
"os"

"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
Expand Down Expand Up @@ -65,36 +63,6 @@ func (c *ConfigCommand) encryptAction(ctx *cli.Context) error {
return nil
}

func parseDecryptConfig(ctx *cli.Context) (secrets.DecryptConfig, error) {
env := ctx.String("environment")
name := ctx.String("name")
req := secrets.DecryptConfig{
Environment: env,
Name: name,
Out: os.Stdout,
}
parseKeyConfig(ctx, &req)
if err := req.Valid(); err != nil {
return secrets.DecryptConfig{}, fmt.Errorf("pass appropriate key or key-file to decrypt: %w", err)
}

return req, nil
}

func parseKeyConfig(ctx *cli.Context, cfg *secrets.DecryptConfig) {
log.Trace().Msgf("parsing configuration required to decrypt config")
key := ctx.String("key")
keyFile := ctx.String("key-file")
if keyFile == "" {
keyFile = os.Getenv("DOLORES_SECRETS_KEY_FILE")
}
if key == "" {
key = os.Getenv("DOLORES_SECRETS_KEY")
}
cfg.KeyFile = keyFile
cfg.Key = key
}

func (c *ConfigCommand) decryptAction(ctx *cli.Context) error {
req, err := parseDecryptConfig(ctx)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions cmd/dolores/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func main() {
},
Commands: []*cli.Command{
NewConfig(newClient).Command,
NewRunner(newClient).Command,
NewInitCommand(newClient),
},
}
Expand Down
40 changes: 40 additions & 0 deletions cmd/dolores/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"fmt"
"os"

"github.com/rs/zerolog/log"
"github.com/scalescape/dolores/secrets"
"github.com/urfave/cli/v2"
)

func parseKeyConfig(ctx *cli.Context, cfg *secrets.DecryptConfig) {
log.Trace().Msgf("parsing configuration required to decrypt config")
key := ctx.String("key")
keyFile := ctx.String("key-file")
if keyFile == "" {
keyFile = os.Getenv("DOLORES_SECRETS_KEY_FILE")
}
if key == "" {
key = os.Getenv("DOLORES_SECRETS_KEY")
}
cfg.KeyFile = keyFile
cfg.Key = key
}

func parseDecryptConfig(ctx *cli.Context) (secrets.DecryptConfig, error) {
env := ctx.String("environment")
name := ctx.String("name")
req := secrets.DecryptConfig{
Environment: env,
Name: name,
Out: os.Stdout,
}
parseKeyConfig(ctx, &req)
if err := req.Valid(); err != nil {
return secrets.DecryptConfig{}, fmt.Errorf("pass appropriate key or key-file to decrypt: %w", err)
}

return req, nil
}
212 changes: 212 additions & 0 deletions cmd/dolores/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package main

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
"time"

"github.com/rs/zerolog/log"
"github.com/scalescape/dolores/client"
"github.com/scalescape/dolores/lib"
"github.com/scalescape/dolores/secrets"
"github.com/urfave/cli/v2"
)

type OutputType string

var ErrInvalidCommand = errors.New("invalid command")

const (
Stdout OutputType = "stdout"
Stderr OutputType = "stderr"
)

func handleReader(wg *sync.WaitGroup, reader io.Reader, mode OutputType) {
defer wg.Done()
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if err != nil && err != io.EOF {
log.Error().Msgf("Error reading from reader: %v", err)
return
}
if n == 0 {
break
}
if mode == Stdout {
os.Stdout.Write(buf[:n])
} else if mode == Stderr {
log.Error().Msgf("%s", string(buf[:n]))
}
}
}

func (c *Runner) environ(ctx context.Context, name string) ([]string, error) {
envs := os.Environ()
log := log.With().Str("cmd", "run").Str("environment", c.environment).Logger()
log.Debug().Msgf("loading configuration %s before running", name)
sec := secrets.NewSecertsManager(log, c.rcli(ctx))
if err := sec.Decrypt(c.DecryptConfig); err != nil {
return nil, err
}
scanner := bufio.NewScanner(c.configBuffer)
for scanner.Scan() {
v := scanner.Text()
envs = append(envs, v)
}
return envs, nil
}

func (c *Runner) runScript(ctx context.Context, cmdName string, args []string) error {
log.Trace().Msgf("executing cmd: %s with args: %s", cmdName, args)
cmd := exec.CommandContext(ctx, cmdName, args...)
if c.configName != "" {
vars, err := c.environ(ctx, c.configName)
if err != nil {
return err
}
cmd.Env = vars
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("error creating stdout pipe: %w", err)
}
defer stdout.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("error creating stderr pipe: %w", err)
}
defer stderr.Close()
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting command: %w", err)
}
c.wg.Add(2)
go handleReader(c.wg, stdout, Stdout)
go handleReader(c.wg, stderr, Stderr)
c.wg.Wait()

if err := cmd.Wait(); err != nil {
if err, ok := err.(*exec.ExitError); ok { // nolint:errorlint
if status, ok := err.Sys().(syscall.WaitStatus); ok {
// TODO: Report this error exit status
c.exitStatus = status.ExitStatus()
}
}
return fmt.Errorf("exited with error: %w", err)
}
return nil
}

type Runner struct {
*cli.Command
rcli GetClient
execCommand
exitStatus int
wg *sync.WaitGroup
configName string
configBuffer *bytes.Buffer
environment string
secrets.DecryptConfig
}

type execCommand struct {
Script string
Command string
Args []string
}

func (c *execCommand) Valid() error {
if c.Script == "" && c.Command == "" {
return fmt.Errorf("please pass script flag or a valid command to run: %w", ErrInvalidCommand)
}
if c.Script != "" {
if _, err := os.Stat(c.Script); err != nil {
return fmt.Errorf("invalid script file: %s %w", c.Script, err)
}
}
return nil
}

func (c *Runner) parse(ctx *cli.Context) error {
req := execCommand{}
if script := ctx.String("script"); script == "" {
req.Command = ctx.Args().First()
req.Args = ctx.Args().Tail()
} else {
req.Args = append(req.Args, lib.AbsPath(script))
req.Args = append(req.Args, ctx.Args().Slice()...)
req.Command = "/bin/bash"
}
c.configName = ctx.String("with-config")
c.environment = ctx.String("environment")
if c.configName != "" && c.environment == "" {
return fmt.Errorf("pass environment: %w", ErrInvalidEnvironment)
}
if c.configName != "" && c.environment != "" {
c.DecryptConfig = secrets.DecryptConfig{
Name: c.configName,
Environment: c.environment,
Out: c.configBuffer,
}
parseKeyConfig(ctx, &c.DecryptConfig)
if err := c.DecryptConfig.Valid(); err != nil {
return err
}
}
if err := req.Valid(); err != nil {
return err
}
c.execCommand = req
return nil
}

func (c *Runner) runAction(ctx *cli.Context) error {
startT := time.Now()
defer func() {
log.Info().Msgf("total elapsed time: %s", time.Since(startT))
}()
if err := c.parse(ctx); err != nil {
return err
}
if err := c.runScript(ctx.Context, c.execCommand.Command, c.execCommand.Args); err != nil {
return err
}
return nil
}

func NewRunner(client func(context.Context) *client.Client) Runner {
cmd := Runner{
rcli: client,
wg: new(sync.WaitGroup),
configBuffer: bytes.NewBuffer(make([]byte, 0)),
Command: &cli.Command{
Name: "run",
Usage: "execute a script or command with secrets loaded",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "script",
},
&cli.StringFlag{
Name: "with-config",
Usage: "load the secrets config before running",
},
&cli.StringFlag{
Name: "key-file",
},
&cli.StringFlag{
Name: "key",
},
},
},
}
cmd.Action = cmd.runAction
return cmd
}
13 changes: 13 additions & 0 deletions lib/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"os/exec"
"path/filepath"
"time"

"github.com/rs/zerolog/log"
)

var ErrNoEditorFound = errors.New("no editor found")
Expand Down Expand Up @@ -69,3 +71,14 @@ func getEditor() (string, error) {
}
return editor, nil
}

func AbsPath(fname string) string {
if !filepath.IsAbs(fname) {
cwd, err := os.Getwd()
if err != nil {
log.Error().Msgf("Error getting current working directory: %v", err)
}
fname = filepath.Join(cwd, fname)
}
return fname
}
23 changes: 16 additions & 7 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dolores --env production init

Enter the GCS bucket name where you want to store the application configuration

## Encrypt
## Encrypt a plain env file

To encrypt a plain env file `backend.env` for production environments and upload it to GCS bucket, run the following

Expand All @@ -25,17 +25,26 @@ dolores --env production config encrypt -f backend.env --name backend-01
```
Once the file is encrypted successfully, you can remove the local plaintext file.

## Decrypt

To decrypt a remote encrypted file locally, run the following
## Edit config
You can edit the remote config file with the following command

```bash
dolores --environment production config decrypt --name backend-01 -key-file $HOME/.config/dolores/production.key
dolores --environment production config edit --name backend-01 -key-file $HOME/.config/dolores/production.key
```

## Edit
You can edit the configuration file without need for decrypting, so it'll be updated remotely
### Decrypt config

Prefer to use edit and run over decrypt as required, In case of you need to have env var file locally, decrypt the config with the following command

```bash
dolores --environment production config edit --name backend-01 -key-file $HOME/.config/dolores/production.key
dolores --environment production config decrypt --name backend-01 -key-file $HOME/.config/dolores/production.key
```

## Run commands with config

You can run a bash command or script and pre-load required config, so it's limited to the command's process.

```
dolores --env production run --with-config backend-01 -key-file $HOME/.config/dolores/production.key
```

0 comments on commit 6aed4d6

Please sign in to comment.