Skip to content

Commit

Permalink
Merge pull request #384 from nerdalize/feature/private-images
Browse files Browse the repository at this point in the history
Add option to use private images
  • Loading branch information
advanderveer committed Mar 23, 2018
2 parents 429e6c6 + 2e86a72 commit a7e9147
Show file tree
Hide file tree
Showing 17 changed files with 791 additions and 17 deletions.
76 changes: 69 additions & 7 deletions cmd/job_run.go
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
Expand All @@ -18,13 +19,14 @@ import (

//JobRun command
type JobRun struct {
Name string `long:"name" short:"n" description:"assign a name to the job"`
Env []string `long:"env" short:"e" description:"environment variables to use"`
Memory string `long:"memory" short:"m" description:"memory to use for this job, expressed in gigabytes" default:"3"`
VCPU string `long:"vcpu" description:"number of vcpus to use for this job" default:"2"`
Inputs []string `long:"input" description:"specify one or more inputs that will be used for the job using the following format: <DIR|DATASET_NAME>:<JOB_DIR>"`
Outputs []string `long:"output" description:"specify one or more output folders that will be stored as datasets after the job is finished using the following format: <DATASET_NAME>:<JOB_DIR>"`

Name string `long:"name" short:"n" description:"assign a name to the job"`
Env []string `long:"env" short:"e" description:"environment variables to use"`
Memory string `long:"memory" short:"m" description:"memory to use for this job, expressed in gigabytes" default:"3"`
VCPU string `long:"vcpu" description:"number of vcpus to use for this job" default:"2"`
Inputs []string `long:"input" description:"specify one or more inputs that will be used for the job using the following format: <DIR|DATASET_NAME>:<JOB_DIR>"`
Outputs []string `long:"output" description:"specify one or more output folders that will be stored as datasets after the job is finished using the following format: <DATASET_NAME>:<JOB_DIR>"`
Private bool `long:"private" description:"use this flag with a private image, a prompt will ask for your username and password of the repository that stores the image. If NERD_IMAGE_USERNAME and/or NERD_IMAGE_PASSWORD environment variables are set, those values are used instead."`
CleanCreds bool `long:"clean-creds" description:"to be used with the '--private' flag, a prompt will ask again for your image repository username and password. If NERD_IMAGE_USERNAME and/or NERD_IMAGE_PASSWORD environment variables are provided, they will be used as values to update the secret."`
*command
}

Expand Down Expand Up @@ -260,6 +262,44 @@ func (cmd *JobRun) Execute(args []string) (err error) {
Memory: fmt.Sprintf("%sGi", cmd.Memory),
VCPU: cmd.VCPU,
}
if cmd.Private {
secrets, err := kube.ListSecrets(ctx, &svc.ListSecretsInput{})
if err != nil {
return renderServiceError(err, "failed to list secrets")
}
_, _, registry := svc.ExtractRegistry(in.Image)
for _, secret := range secrets.Items {
if secret.Details.Image == in.Image {
if cmd.CleanCreds {
username, password, err := cmd.getCredentials(registry)
if err != nil {
return err
}
_, err = kube.UpdateSecret(ctx, &svc.UpdateSecretInput{Name: secret.Name, Username: username, Password: password})
if err != nil {
return renderServiceError(err, "failed to update secret")
}
}
in.Secret = secret.Name
break
}
}
if in.Secret == "" {
username, password, err := cmd.getCredentials(registry)
if err != nil {
return err
}
secret, err := kube.CreateSecret(ctx, &svc.CreateSecretInput{
Image: in.Image,
Username: username,
Password: password,
})
if err != nil {
return renderServiceError(err, "failed to create secret")
}
in.Secret = secret.Name
}
}

for _, vol := range vols {
in.Volumes = append(in.Volumes, *vol)
Expand Down Expand Up @@ -318,6 +358,28 @@ func (cmd *JobRun) rollbackDatasets(ctx context.Context, mgr transfer.Manager, i
return err
}

func (cmd *JobRun) getCredentials(registry string) (username, password string, err error) {
if registry == "index.docker.io" {
registry = "Docker Hub"
}
cmd.out.Infof("Please provide credentials for the %s repository that stores the private image:", registry)
username = os.Getenv("NERD_IMAGE_USERNAME")
if username == "" {
username, err = cmd.out.Ask("Username: ")
if err != nil {
return username, password, err
}
}
password = os.Getenv("NERD_IMAGE_PASSWORD")
if password == "" {
password, err = cmd.out.AskSecret("Password: ")
if err != nil {
return username, password, err
}
}
return username, password, err
}

func updateDatasets(ctx context.Context, kube *svc.Kube, inputs, outputs []dsHandle, name string) error {
//add job to each dataset's InputFor
for _, input := range inputs {
Expand Down
19 changes: 12 additions & 7 deletions pkg/kubevisor/visor.go
Expand Up @@ -49,6 +49,9 @@ var (

//ResourceTypeQuota can be used to retrieve quota information
ResourceTypeQuota = ResourceType("resourcequotas")

//ResourceTypeSecrets can be used to get secret information
ResourceTypeSecrets = ResourceType("secrets")
)

//ManagedNames allows for Nerd to transparently manage resources based on names and there prefixes
Expand Down Expand Up @@ -112,6 +115,8 @@ func (k *Visor) GetResource(ctx context.Context, t ResourceType, v ManagedNames,
switch t {
case ResourceTypeJobs:
c = k.api.BatchV1().RESTClient()
case ResourceTypeSecrets:
c = k.api.CoreV1().RESTClient()
case ResourceTypeDatasets:
c = k.crd.NerdalizeV1().RESTClient()
default:
Expand Down Expand Up @@ -146,7 +151,8 @@ func (k *Visor) DeleteResource(ctx context.Context, t ResourceType, name string)
c = k.api.BatchV1().RESTClient()
case ResourceTypeDatasets:
c = k.crd.NerdalizeV1().RESTClient()

case ResourceTypeSecrets:
c = k.api.CoreV1().RESTClient()
default:
return errors.Errorf("unknown Kubernetes resource type provided for deletion: '%s'", t)
}
Expand Down Expand Up @@ -212,6 +218,9 @@ func (k *Visor) CreateResource(ctx context.Context, t ResourceType, v ManagedNam
case ResourceTypeJobs:
c = k.api.BatchV1().RESTClient()
genfix = "j-"
case ResourceTypeSecrets:
c = k.api.CoreV1().RESTClient()
genfix = "s-"
case ResourceTypeDatasets:
c = k.crd.NerdalizeV1().RESTClient()
genfix = "d-"
Expand Down Expand Up @@ -262,7 +271,7 @@ func (k *Visor) UpdateResource(ctx context.Context, t ResourceType, v ManagedNam
switch t {
case ResourceTypeJobs:
c = k.api.BatchV1().RESTClient()
case ResourceTypePods:
case ResourceTypePods, ResourceTypeSecrets:
c = k.api.CoreV1().RESTClient()
case ResourceTypeDatasets:
c = k.crd.NerdalizeV1().RESTClient()
Expand Down Expand Up @@ -303,11 +312,7 @@ func (k *Visor) ListResources(ctx context.Context, t ResourceType, v ListTranfor
switch t {
case ResourceTypeJobs:
c = k.api.BatchV1().RESTClient()
case ResourceTypePods:
c = k.api.CoreV1().RESTClient()
case ResourceTypeEvents:
c = k.api.CoreV1().RESTClient()
case ResourceTypeQuota:
case ResourceTypePods, ResourceTypeEvents, ResourceTypeQuota, ResourceTypeSecrets:
c = k.api.CoreV1().RESTClient()
case ResourceTypeDatasets:
c = k.crd.NerdalizeV1().RESTClient()
Expand Down
12 changes: 12 additions & 0 deletions spec.json
Expand Up @@ -443,6 +443,18 @@
"description": "specify one or more output folders that will be stored as datasets after the job is finished using the following format: \u003cDATASET_NAME\u003e:\u003cJOB_DIR\u003e",
"default_value": null,
"choices": null
},
{
"long_name": "private",
"description": "use this flag with a private image, a prompt will ask for your username and password of the repository that stores the image. If NERD_IMAGE_USERNAME and/or NERD_IMAGE_PASSWORD environment variables are set, those values are used instead.",
"default_value": null,
"choices": null
},
{
"long_name": "clean-creds",
"description": "to be used with the '--private' flag, a prompt will ask again for your image repository username and password. If NERD_IMAGE_USERNAME and/or NERD_IMAGE_PASSWORD environment variables are provided, they will be used as values to update the secret.",
"default_value": null,
"choices": null
}
]
}
Expand Down
103 changes: 103 additions & 0 deletions svc/kube_create_secret.go
@@ -0,0 +1,103 @@
package svc

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"

"github.com/nerdalize/nerd/pkg/kubevisor"
"github.com/pkg/errors"

"k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

//CreateSecretInput is the input to CreateSecret
type CreateSecretInput struct {
Image string `validate:"printascii"`
Registry string `validate:"required"`
Project string
Username string `validate:"required"`
Password string `validate:"required"`
}

//CreateSecretOutput is the output to CreateSecret
type CreateSecretOutput struct {
Name string
}

//CreateSecret will create a secret on kubernetes
func (k *Kube) CreateSecret(ctx context.Context, in *CreateSecretInput) (out *CreateSecretOutput, err error) {
if err = k.checkInput(ctx, in); err != nil {
return nil, err
}

secret := &v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"image": in.Image, "project": in.Project, "registry": in.Registry},
},
Type: v1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
}

secret.Data[v1.DockerConfigJsonKey], err = transformCredentials(in.Username, in.Password, in.Registry)
if err != nil {
return nil, err
}

err = k.visor.CreateResource(ctx, kubevisor.ResourceTypeSecrets, secret, "")
if err != nil {
return nil, err
}

return &CreateSecretOutput{
Name: secret.Name,
}, nil
}

func transformCredentials(username, password, registry string) (dockereCfg []byte, err error) {
var dockerCfg []byte
auths := map[string]interface{}{}
cfg := map[string]interface{}{
"auths": auths,
"HttpHeaders": map[string]interface{}{
"User-Agent": "Docker-Client/1.11.2 (linux)",
},
}
authStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
auths[fmt.Sprintf("https://%s/v1/", registry)] = map[string]string{
"auth": authStr,
}
auths[fmt.Sprintf("%s", registry)] = map[string]string{
"auth": authStr,
}
if dockerCfg, err = json.Marshal(cfg); err != nil {
return dockerCfg, errors.Wrap(err, "failed to serialize docker secret cfg")
}
return dockerCfg, nil
}

// ExtractRegistry takes a string as input and divides it in image, project, registry
func ExtractRegistry(image string) (string, string, string) {
// Supported registries:
// - project/image -> index.docker.io
// - ACCOUNT.dkr.ecr.REGION.amazonaws.com/image -> aws
// - azurecr.io/image -> azure
// - quay.io/project/image -> quay.io
// - gcr.io/project/image -> gcr
// gitlab?? other providers?

parts := strings.Split(image, "/")
switch len(parts) {
case 2:
if !strings.Contains(parts[0], ".") {
return parts[1], parts[0], "index.docker.io"
}
return parts[1], "", parts[0]
case 3:
return parts[2], parts[1], parts[0]
}
return "", "", ""
}
62 changes: 62 additions & 0 deletions svc/kube_create_secret_test.go
@@ -0,0 +1,62 @@
package svc_test

import (
"context"
"fmt"
"reflect"
"runtime"
"strings"
"testing"
"time"

"github.com/nerdalize/nerd/svc"
)

func TestCreateSecret(t *testing.T) {
for _, c := range []struct {
Name string
Timeout time.Duration
Input *svc.CreateSecretInput
IsOutput func(tb testing.TB, out *svc.CreateSecretOutput)
IsErr func(error) bool
}{
{
Name: "when a zero value input is provided it should return a validation error",
Timeout: time.Second * 5,
Input: nil,
IsErr: svc.IsValidationErr,
IsOutput: func(t testing.TB, out *svc.CreateSecretOutput) {
assert(t, out == nil, "output should be nil")
},
},
{
Name: "when a valid input is provided it should return a secret with a unique name",
Timeout: time.Second * 5,
Input: &svc.CreateSecretInput{Image: "smoketest", Project: "nerdalize", Registry: "quay.io", Username: "test", Password: "test"},
IsErr: nil,
IsOutput: func(t testing.TB, out *svc.CreateSecretOutput) {
assert(t, out != nil, "output should not be nil")
assert(t, strings.Contains(out.Name, "s-"), "secret name should be generated and prefixed")
},
},
} {
t.Run(c.Name, func(t *testing.T) {
di, clean := testDI(t)
defer clean()

ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

kube := svc.NewKube(di)
out, err := kube.CreateSecret(ctx, c.Input)
if c.IsErr != nil {
assert(t, c.IsErr(err), fmt.Sprintf("unexpected '%#v' to match: %#v", err, runtime.FuncForPC(reflect.ValueOf(c.IsErr).Pointer()).Name()))
}

if c.IsOutput != nil {
c.IsOutput(t, out)
}
})
}
}
11 changes: 11 additions & 0 deletions svc/kube_delete_dataset_test.go
Expand Up @@ -8,7 +8,10 @@ import (
"testing"
"time"

"github.com/nerdalize/nerd/pkg/transfer/archiver"

"github.com/nerdalize/nerd/pkg/kubevisor"
"github.com/nerdalize/nerd/pkg/transfer/store"
"github.com/nerdalize/nerd/svc"
)

Expand Down Expand Up @@ -37,6 +40,14 @@ func TestDeleteDataset(t *testing.T) {
Output: &svc.DeleteDatasetOutput{},
IsErr: kubevisor.IsNotExistsErr,
},
{
Name: "when a valid dataset is deleted it should return no error",
Timeout: time.Second * 5,
Datasets: []*svc.CreateDatasetInput{{Name: "test", StoreOptions: transferstore.StoreOptions{}, ArchiverOptions: transferarchiver.ArchiverOptions{}}},
Input: &svc.DeleteDatasetInput{Name: "test"},
Output: &svc.DeleteDatasetOutput{},
IsErr: nil,
},
} {
t.Run(c.Name, func(t *testing.T) {
di, clean := testDI(t)
Expand Down

0 comments on commit a7e9147

Please sign in to comment.