Skip to content

Commit

Permalink
Add possibility for pipe-tasks to pipe env-files (#1484)
Browse files Browse the repository at this point in the history
Summary:
When creating `ConfigMap`s or `Secret`s k8s allows for taking an env-file using the `--from-env-file` option. Env-files contain a list of environment variables.

These syntax rules apply:
 - Each line in an env file has to be in VAR=VAL format.
 - Lines beginning with # (i.e. comments) are ignored.
 - Blank lines are ignored.
 - There is no special handling of quotation marks (i.e. they will be part of the ConfigMap value))

 New pipe-task `fnvFile` field can be used instead of `file` to signal that the pipe-file should be treated as an env-file:

 ```yaml
spec:
  pipe:
    - envFile: /tmp/foo.env
      kind: ConfigMap
      key: foo
```

Note, that **either** `file` or `envFile` can be used but not both.

For more information on env-files see [k8s documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#create-configmaps-from-files)

Fixes: #1394
Signed-off-by: Aleksey Dukhovniy <alex.dukhovniy@googlemail.com>
  • Loading branch information
Aleksey Dukhovniy committed Apr 29, 2020
1 parent 2eecbfe commit b92b50c
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 29 deletions.
2 changes: 2 additions & 0 deletions config/crds/kudo.dev_operatorversions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ spec:
description: PipeSpec describes how a file generated by
a PipeTask is stored and referenced
properties:
envFile:
type: string
file:
type: string
key:
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/kudo/v1beta1/operatorversion_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ type PipeSpec struct {
// +optional
File string `json:"file"`
// +optional
EnvFile string `json:"envFile"`
// +optional
Kind string `json:"kind"`
// +optional
Key string `json:"key"`
Expand Down
77 changes: 77 additions & 0 deletions pkg/engine/task/env_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package task

// This file has been adopted from kubectl/pkg/generate/versioned/env_file.go and used to read files containing
// env var pairs in pipe-tasks.

import (
"bufio"
"bytes"
"fmt"
"strings"
"unicode"
"unicode/utf8"

"k8s.io/apimachinery/pkg/util/validation"
)

var utf8bom = []byte{0xEF, 0xBB, 0xBF}

// proccessEnvFileLine returns a blank key if the line is empty or a comment.
// The value will be retrieved from the environment if necessary.
func proccessEnvFileLine(line []byte, currentLine int) (key, value string, err error) {

if !utf8.Valid(line) {
return ``, ``, fmt.Errorf("invalid utf8 bytes at line %d: %v", currentLine+1, line)
}

// We trim UTF8 BOM from the first line of the file but no others
if currentLine == 0 {
line = bytes.TrimPrefix(line, utf8bom)
}

// trim the line from all leading whitespace first
line = bytes.TrimLeftFunc(line, unicode.IsSpace)

// If the line is empty or a comment, we return a blank key/value pair.
if len(line) == 0 || line[0] == '#' {
return ``, ``, nil
}

data := strings.SplitN(string(line), "=", 2)
if len(data) != 2 {
return ``, ``, fmt.Errorf("%q is not a valid env var definition (KEY=VAL)", line)
}

key = data[0]
if errs := validation.IsEnvVarName(key); len(errs) != 0 {
return ``, ``, fmt.Errorf("%q is not a valid key name: %s", key, strings.Join(errs, ";"))
}
value = data[1]

return key, value, nil
}

// addFromEnvFile processes an env file allows a generic addTo to handle the
// collection of key value pairs or returns an error.
func addFromEnvFile(data []byte, addTo func(key, value string)) error {
r := bytes.NewReader(data)
scanner := bufio.NewScanner(r)
currentLine := 0
for scanner.Scan() {
// Process the current line, retrieving a key/value pair if possible.
scannedBytes := scanner.Bytes()
key, value, err := proccessEnvFileLine(scannedBytes, currentLine)
if err != nil {
return err
}
currentLine++

if len(key) == 0 {
// no key means line was empty or a comment
continue
}

addTo(key, value)
}
return nil
}
9 changes: 6 additions & 3 deletions pkg/engine/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func newPipe(task *v1beta1.Task) (Tasker, error) {

var pipeFiles []PipeFile
for _, pp := range task.Spec.PipeTaskSpec.Pipe {
pf := PipeFile{File: pp.File, Kind: PipeFileKind(pp.Kind), Key: pp.Key}
pf := PipeFile{File: pp.File, EnvFile: pp.EnvFile, Kind: PipeFileKind(pp.Kind), Key: pp.Key}
// validate pipe file
if err := validPipeFile(pf); err != nil {
return nil, err
Expand Down Expand Up @@ -151,9 +151,12 @@ var (
)

func validPipeFile(pf PipeFile) error {
if pf.File == "" {
return fmt.Errorf("task validation error: pipe file is empty: %v", pf)
fl := pf.File != ""
efl := pf.EnvFile != ""
if fl == efl {
return fmt.Errorf("task validation error: pipe file %v must have either 'file' or 'envFile' field set but not both", pf)
}

if pf.Kind != PipeFileKindSecret && pf.Kind != PipeFileKindConfigMap {
return fmt.Errorf("task validation error: invalid pipe kind (must be Secret or ConfigMap): %v", pf)
}
Expand Down
69 changes: 53 additions & 16 deletions pkg/engine/task/task_pipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,19 @@ type PipeTask struct {
}

type PipeFile struct {
File string
Kind PipeFileKind
Key string
File string
EnvFile string
Kind PipeFileKind
Key string
}

// fileSource return either File or EnvFile depending on which one of them is set. Note that only one can be set at a
// time which is enforced by the validation.
func (pf PipeFile) fileSource() string {
if pf.File != "" {
return pf.File
}
return pf.EnvFile
}

func (pt PipeTask) Run(ctx Context) (bool, error) {
Expand Down Expand Up @@ -218,11 +228,11 @@ func validate(pod *corev1.Pod, ff []PipeFile) error {

// check if all referenced pipe files are children of the container mountPath
for _, f := range ff {
if !isRelative(mountPath, f.File) {
return fmt.Errorf("pipe file %s should be a child of %s mount path", f.File, mountPath)
if !isRelative(mountPath, f.fileSource()) {
return fmt.Errorf("pipe file %s should be a child of %s mount path", f.fileSource(), mountPath)
}

fileName := path.Base(f.File)
fileName := path.Base(f.fileSource())
// Same as k8s we use file names as ConfigMap data keys. A valid key name for a ConfigMap must consist
// of alphanumeric characters, '-', '_' or '.' (e.g. 'key.name', or 'KEY_NAME', or 'key-name', regex
// used for validation is '[-._a-zA-Z0-9]+')
Expand Down Expand Up @@ -271,12 +281,12 @@ func copyFiles(fs afero.Fs, ff []PipeFile, pod *corev1.Pod, ctx Context) error {

for _, f := range ff {
f := f
log.Printf("PipeTask: %s/%s copying pipe file %s", ctx.Meta.InstanceNamespace, ctx.Meta.InstanceName, f.File)
log.Printf("PipeTask: %s/%s copying pipe file %s", ctx.Meta.InstanceNamespace, ctx.Meta.InstanceName, f.fileSource())
g.Go(func() error {
// Check the size of the pipe file first. K87 has a inherent limit on the size of
// Secret/ConfigMap, so we avoid unnecessary copying of files that are too big by
// checking its size first.
size, err := podexec.FileSize(f.File, pod, pipePodContainerName, ctx.Config)
size, err := podexec.FileSize(f.fileSource(), pod, pipePodContainerName, ctx.Config)
if err != nil {
// Any remote command exit code > 0 is treated as a fatal error since retrying it doesn't make sense
if podexec.HasCommandFailed(err) {
Expand All @@ -286,10 +296,10 @@ func copyFiles(fs afero.Fs, ff []PipeFile, pod *corev1.Pod, ctx Context) error {
}

if size > maxPipeFileSize {
return fatalExecutionError(fmt.Errorf("pipe file %s size %d exceeds maximum file size of %d bytes", f.File, size, maxPipeFileSize), pipeTaskError, ctx.Meta)
return fatalExecutionError(fmt.Errorf("pipe file %s size %d exceeds maximum file size of %d bytes", f.fileSource(), size, maxPipeFileSize), pipeTaskError, ctx.Meta)
}

if err = podexec.DownloadFile(fs, f.File, pod, pipePodContainerName, ctx.Config); err != nil {
if err = podexec.DownloadFile(fs, f.fileSource(), pod, pipePodContainerName, ctx.Config); err != nil {
// Any remote command exit code > 0 is treated as a fatal error since retrying it doesn't make sense
if podexec.HasCommandFailed(err) {
return fatalExecutionError(err, pipeTaskError, ctx.Meta)
Expand All @@ -309,9 +319,9 @@ func createArtifacts(fs afero.Fs, files []PipeFile, meta renderer.Metadata) (map
artifacts := map[string]string{}

for _, pf := range files {
data, err := afero.ReadFile(fs, pf.File)
data, err := afero.ReadFile(fs, pf.fileSource())
if err != nil {
return nil, fmt.Errorf("error opening pipe file %s", pf.File)
return nil, fmt.Errorf("error opening pipe file %s", pf.fileSource())
}

var art string
Expand All @@ -337,7 +347,7 @@ func createArtifacts(fs afero.Fs, files []PipeFile, meta renderer.Metadata) (map
// as Secret data key. Secret name will be of the form <instance>.<plan>.<phase>.<step>.<task>-<PipeFile.Key>
func createSecret(pf PipeFile, data []byte, meta renderer.Metadata) (string, error) {
name := PipeArtifactName(meta, pf.Key)
key := path.Base(pf.File)
key := path.Base(pf.fileSource())

secret := corev1.Secret{
TypeMeta: metav1.TypeMeta{
Expand All @@ -347,10 +357,23 @@ func createSecret(pf PipeFile, data []byte, meta renderer.Metadata) (string, err
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Data: map[string][]byte{key: data},
Data: map[string][]byte{},
Type: corev1.SecretTypeOpaque,
}

if pf.File != "" {
secret.Data = map[string][]byte{key: data}
}
if pf.EnvFile != "" {
err := addFromEnvFile(data, func(key, value string) {
secret.Data[key] = []byte(value)
})

if err != nil {
return "", fmt.Errorf("failed to read env var file %q: %v", pf.fileSource(), err)
}
}

b, err := yaml.Marshal(secret)
if err != nil {
return "", fmt.Errorf("failed to marshal pipe secret for pipe file %s: %v", pf.File, err)
Expand All @@ -363,7 +386,7 @@ func createSecret(pf PipeFile, data []byte, meta renderer.Metadata) (string, err
// as ConfigMap data key. ConfigMap name will be of the form <instance>.<plan>.<phase>.<step>.<task>-<PipeFile.Key>
func createConfigMap(pf PipeFile, data []byte, meta renderer.Metadata) (string, error) {
name := PipeArtifactName(meta, pf.Key)
key := path.Base(pf.File)
key := path.Base(pf.fileSource())

configMap := corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Expand All @@ -373,7 +396,21 @@ func createConfigMap(pf PipeFile, data []byte, meta renderer.Metadata) (string,
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
BinaryData: map[string][]byte{key: data},
Data: map[string]string{},
BinaryData: map[string][]byte{},
}

if pf.File != "" {
configMap.BinaryData = map[string][]byte{key: data}
}
if pf.EnvFile != "" {
err := addFromEnvFile(data, func(key, value string) {
configMap.Data[key] = value
})

if err != nil {
return "", fmt.Errorf("failed to read env var file %q: %v", pf.fileSource(), err)
}
}

b, err := yaml.Marshal(configMap)
Expand Down
54 changes: 52 additions & 2 deletions pkg/engine/task/task_pipe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func Test_pipeFiles(t *testing.T) {
wantErr bool
}{
{
name: "pipe a secret",
name: "pipe a file to a secret",
data: map[string]string{"/tmp/foo.txt": "foo"},
file: PipeFile{
File: "/tmp/foo.txt",
Expand All @@ -400,7 +400,32 @@ func Test_pipeFiles(t *testing.T) {
wantErr: false,
},
{
name: "pipe a configMap",
name: "pipe an env file to a secret",
data: map[string]string{"/tmp/foo.env": `
enemies=aliens
lives=3
allowed="true"
`},
file: PipeFile{
EnvFile: "/tmp/foo.env",
Kind: PipeFileKindSecret,
Key: "Foo",
},
meta: meta,
wantArtifact: v1.Secret{
TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"},
ObjectMeta: metav1.ObjectMeta{Name: "fooinstance.deploy.first.step.genfiles.foo"},
Data: map[string][]byte{
"enemies": []byte("aliens"),
"lives": []byte("3"),
"allowed": []byte("\"true\""),
},
Type: v1.SecretTypeOpaque,
},
wantErr: false,
},
{
name: "pipe a file to a configMap",
data: map[string]string{"/tmp/bar.txt": "bar"},
file: PipeFile{
File: "/tmp/bar.txt",
Expand All @@ -415,6 +440,30 @@ func Test_pipeFiles(t *testing.T) {
},
wantErr: false,
},
{
name: "pipe an env file to a configMap",
data: map[string]string{"/tmp/bar.env": `
enemies=aliens
lives=3
allowed="true"
`},
file: PipeFile{
EnvFile: "/tmp/bar.env",
Kind: PipeFileKindConfigMap,
Key: "Bar",
},
meta: meta,
wantArtifact: v1.ConfigMap{
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
ObjectMeta: metav1.ObjectMeta{Name: "fooinstance.deploy.first.step.genfiles.bar"},
Data: map[string]string{
"enemies": "aliens",
"lives": "3",
"allowed": "\"true\"",
},
},
wantErr: false,
},
{
name: "return an error for an invalid pipe",
data: map[string]string{"nope.txt": ""},
Expand All @@ -428,6 +477,7 @@ func Test_pipeFiles(t *testing.T) {
wantErr: true,
},
}

for _, tt := range tests {
tt := tt

Expand Down
Loading

0 comments on commit b92b50c

Please sign in to comment.