diff --git a/cmd/job_run.go b/cmd/job_run.go index 9fda6fc1a..33ead717c 100755 --- a/cmd/job_run.go +++ b/cmd/job_run.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "os" "path/filepath" "strconv" "strings" @@ -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: :"` - 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: :"` - + 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: :"` + 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: :"` + 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 } @@ -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) @@ -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 { diff --git a/pkg/kubevisor/visor.go b/pkg/kubevisor/visor.go index ca84c1680..fabe42989 100644 --- a/pkg/kubevisor/visor.go +++ b/pkg/kubevisor/visor.go @@ -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 @@ -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: @@ -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) } @@ -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-" @@ -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() @@ -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() diff --git a/spec.json b/spec.json index c54f63b0f..88b8a647b 100644 --- a/spec.json +++ b/spec.json @@ -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 } ] } diff --git a/svc/kube_create_secret.go b/svc/kube_create_secret.go new file mode 100644 index 000000000..4926dea2a --- /dev/null +++ b/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 "", "", "" +} diff --git a/svc/kube_create_secret_test.go b/svc/kube_create_secret_test.go new file mode 100644 index 000000000..632cd3bde --- /dev/null +++ b/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) + } + }) + } +} diff --git a/svc/kube_delete_dataset_test.go b/svc/kube_delete_dataset_test.go index 0a2aa68f3..8dad65a16 100644 --- a/svc/kube_delete_dataset_test.go +++ b/svc/kube_delete_dataset_test.go @@ -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" ) @@ -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) diff --git a/svc/kube_delete_secret.go b/svc/kube_delete_secret.go new file mode 100644 index 000000000..523dc86c6 --- /dev/null +++ b/svc/kube_delete_secret.go @@ -0,0 +1,29 @@ +package svc + +import ( + "context" + + "github.com/nerdalize/nerd/pkg/kubevisor" +) + +//DeleteSecretInput is the input to DeleteSecret +type DeleteSecretInput struct { + Name string `validate:"min=1,printascii"` +} + +//DeleteSecretOutput is the output to DeleteSecret +type DeleteSecretOutput struct{} + +//DeleteSecret will create a dataset on kubernetes +func (k *Kube) DeleteSecret(ctx context.Context, in *DeleteSecretInput) (out *DeleteSecretOutput, err error) { + if err = k.checkInput(ctx, in); err != nil { + return nil, err + } + + err = k.visor.DeleteResource(ctx, kubevisor.ResourceTypeSecrets, in.Name) + if err != nil { + return nil, err + } + + return &DeleteSecretOutput{}, nil +} diff --git a/svc/kube_delete_secret_test.go b/svc/kube_delete_secret_test.go new file mode 100644 index 000000000..bd95fe011 --- /dev/null +++ b/svc/kube_delete_secret_test.go @@ -0,0 +1,91 @@ +package svc_test + +import ( + "context" + "fmt" + "reflect" + "runtime" + "testing" + "time" + + "github.com/nerdalize/nerd/pkg/kubevisor" + "github.com/nerdalize/nerd/svc" +) + +func TestDeleteSecret(t *testing.T) { + for _, c := range []struct { + Name string + Timeout time.Duration + Secrets []*svc.CreateSecretInput + Input *svc.DeleteSecretInput + Output *svc.DeleteSecretOutput + Listing *svc.ListSecretsOutput + IsOutput func(tb testing.TB, out *svc.DeleteSecretOutput, l *svc.ListSecretsOutput) + IsErr func(error) bool + }{ + { + Name: "when no name is provided it should provide a validation error", + Timeout: time.Second * 5, + Input: &svc.DeleteSecretInput{}, + Output: &svc.DeleteSecretOutput{}, + IsErr: svc.IsValidationErr, + }, + { + Name: "when a non-existing secret is deleted it should return NotExists error", + Timeout: time.Second * 5, + Input: &svc.DeleteSecretInput{Name: "foo"}, + Output: &svc.DeleteSecretOutput{}, + IsErr: kubevisor.IsNotExistsErr, + }, + } { + 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) + for _, secret := range c.Secrets { + _, err := kube.CreateSecret(ctx, secret) + ok(t, err) + } + + out, err := kube.DeleteSecret(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())) + } + + list, err := kube.ListSecrets(ctx, &svc.ListSecretsInput{}) + ok(t, err) + + if c.IsOutput != nil { + c.IsOutput(t, out, list) + } + }) + } +} + +func TestDeleteSpecificSecret(t *testing.T) { + timeout := time.Minute + + if testing.Short() { + t.Skipf("skipping long test with contex timeout: %s", timeout) + } + + di, clean := testDI(t) + defer clean() + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + kube := svc.NewKube(di) + secret, err := kube.CreateSecret(ctx, &svc.CreateSecretInput{Image: "smoketest", Project: "nerdalize", Registry: "quay.io", Username: "test", Password: "test"}) + ok(t, err) + + out, err := kube.DeleteSecret(ctx, &svc.DeleteSecretInput{Name: secret.Name}) + ok(t, err) + assert(t, out != nil, "expected to find a DeleteSecretOutput") +} diff --git a/svc/kube_get_dataset.go b/svc/kube_get_dataset.go index 82dce94d9..f6f6d61bc 100644 --- a/svc/kube_get_dataset.go +++ b/svc/kube_get_dataset.go @@ -27,7 +27,7 @@ type GetDatasetOutput struct { ArchiverOptions transferarchiver.ArchiverOptions } -//GetDataset will create a dataset on kubernetes +//GetDataset will retrieve a dataset from kubernetes func (k *Kube) GetDataset(ctx context.Context, in *GetDatasetInput) (out *GetDatasetOutput, err error) { if err = k.checkInput(ctx, in); err != nil { return nil, err diff --git a/svc/kube_get_secret.go b/svc/kube_get_secret.go new file mode 100644 index 000000000..7b4be17b0 --- /dev/null +++ b/svc/kube_get_secret.go @@ -0,0 +1,46 @@ +package svc + +import ( + "context" + "path" + "time" + + "github.com/nerdalize/nerd/pkg/kubevisor" + "k8s.io/api/core/v1" +) + +//GetSecretInput is the input to GetSecret +type GetSecretInput struct { + Name string `validate:"printascii"` +} + +//GetSecretOutput is the output to GetSecret +type GetSecretOutput struct { + Name string + Size int + Image string + CreatedAt time.Time + Type string +} + +//GetSecret will retrieve the secret matching the provided name from kubernetes +func (k *Kube) GetSecret(ctx context.Context, in *GetSecretInput) (out *GetSecretOutput, err error) { + if err = k.checkInput(ctx, in); err != nil { + return nil, err + } + + secret := &v1.Secret{} + err = k.visor.GetResource(ctx, kubevisor.ResourceTypeSecrets, secret, in.Name) + if err != nil { + return nil, err + } + + return &GetSecretOutput{ + Name: secret.Name, + Type: string(secret.Type), + Size: secret.Size(), + CreatedAt: secret.CreationTimestamp.Local(), + Image: path.Join(secret.Labels["registry"], secret.Labels["project"], secret.Labels["image"]), + }, nil + +} diff --git a/svc/kube_get_secret_test.go b/svc/kube_get_secret_test.go new file mode 100644 index 000000000..e4a7a887c --- /dev/null +++ b/svc/kube_get_secret_test.go @@ -0,0 +1,98 @@ +package svc_test + +import ( + "context" + "fmt" + "reflect" + "runtime" + "testing" + "time" + + "github.com/nerdalize/nerd/pkg/kubevisor" + "github.com/nerdalize/nerd/svc" +) + +func TestGetSecret(t *testing.T) { + for _, c := range []struct { + Name string + Timeout time.Duration + Secrets []*svc.CreateSecretInput + Input *svc.GetSecretInput + IsOutput func(tb testing.TB, out *svc.GetSecretOutput) bool + IsErr func(error) bool + }{ + { + Name: "when a zero value input is provided it should return a validation error", + Timeout: time.Second * 5, + Secrets: nil, + Input: nil, + IsErr: svc.IsValidationErr, + IsOutput: func(t testing.TB, out *svc.GetSecretOutput) bool { + return true + }, + }, + { + Name: "when secret doesnt exist it should return an error", + Timeout: time.Second * 5, + Input: &svc.GetSecretInput{Name: "my-secret"}, + IsErr: kubevisor.IsNotExistsErr, + IsOutput: func(t testing.TB, out *svc.GetSecretOutput) bool { + return true + }, + }, + } { + t.Run(c.Name, func(t *testing.T) { + if c.Timeout > time.Second*5 && testing.Short() { + t.Skipf("skipping long test with contex timeout: %s", c.Timeout) + } + + di, clean := testDI(t) + defer clean() + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + + kube := svc.NewKube(di) + for _, secrets := range c.Secrets { + _, err := kube.CreateSecret(ctx, secrets) + ok(t, err) + } + + out, err := kube.GetSecret(ctx, c.Input) + if c.IsErr != nil { //if c.IsErr is nil we dont care about errors + 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) + } + }) + } +} + +func TestGetSpecificSecret(t *testing.T) { + image := "quay.io/nerdalize/smoketest" + timeout := time.Minute + + if testing.Short() { + t.Skipf("skipping long test with contex timeout: %s", timeout) + } + + di, clean := testDI(t) + defer clean() + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + kube := svc.NewKube(di) + secret, err := kube.CreateSecret(ctx, &svc.CreateSecretInput{Image: "smoketest", Project: "nerdalize", Registry: "quay.io", Username: "test", Password: "test"}) + ok(t, err) + + out, err := kube.GetSecret(ctx, &svc.GetSecretInput{Name: secret.Name}) + ok(t, err) + assert(t, out != nil, "expected to find a secret") + assert(t, out.Image == image, "expected to find the base image in the secret") + +} diff --git a/svc/kube_list_secret.go b/svc/kube_list_secret.go new file mode 100644 index 000000000..26aeafab8 --- /dev/null +++ b/svc/kube_list_secret.go @@ -0,0 +1,86 @@ +package svc + +import ( + "context" + "path" + "time" + + "github.com/nerdalize/nerd/pkg/kubevisor" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +//SecretDetails tells us more about the secret by looking at underlying resources +type SecretDetails struct { + CreatedAt time.Time + Size int + Type string + Image string +} + +//ListSecretItem is a secret listing item +type ListSecretItem struct { + Name string + Details SecretDetails +} + +//ListSecretsInput is the input to ListSecrets +type ListSecretsInput struct { + Labels []string +} + +//ListSecretsOutput is the output to ListSecrets +type ListSecretsOutput struct { + Items []*ListSecretItem +} + +//ListSecrets will create a secret on kubernetes +func (k *Kube) ListSecrets(ctx context.Context, in *ListSecretsInput) (out *ListSecretsOutput, err error) { + if err = k.checkInput(ctx, in); err != nil { + return nil, err + } + + //Step 0: Get all the secrets under nerd-app=cli + secrets := &secrets{} + err = k.visor.ListResources(ctx, kubevisor.ResourceTypeSecrets, secrets, in.Labels, nil) + if err != nil { + return nil, err + } + + //Step 1: Analyse secret structure and formulate our output items + out = &ListSecretsOutput{} + mapping := map[types.UID]*ListSecretItem{} + for _, secret := range secrets.Items { + if secret.Labels["registry"] == "index.docker.io" { + secret.Labels["registry"] = "" + } + item := &ListSecretItem{ + Name: secret.GetName(), + Details: SecretDetails{ + Type: string(secret.Type), + Size: secret.Size(), + CreatedAt: secret.CreationTimestamp.Local(), + Image: path.Join(secret.Labels["registry"], secret.Labels["project"], secret.Labels["image"]), + }, + } + + mapping[secret.UID] = item + out.Items = append(out.Items, item) + } + + return out, nil +} + +//secrets implements the list transformer interface to allow the kubevisor to manage names for us +type secrets struct{ *v1.SecretList } + +func (secrets *secrets) Transform(fn func(in kubevisor.ManagedNames) (out kubevisor.ManagedNames)) { + for i, d1 := range secrets.SecretList.Items { + secrets.Items[i] = *(fn(&d1).(*v1.Secret)) + } +} + +func (secrets *secrets) Len() int { + return len(secrets.SecretList.Items) +} diff --git a/svc/kube_list_secrets_test.go b/svc/kube_list_secrets_test.go new file mode 100644 index 000000000..e4c19cfd2 --- /dev/null +++ b/svc/kube_list_secrets_test.go @@ -0,0 +1,87 @@ +package svc_test + +import ( + "context" + "fmt" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/nerdalize/nerd/svc" +) + +func TestListSecrets(t *testing.T) { + for _, c := range []struct { + Name string + Timeout time.Duration + Secrets []*svc.CreateSecretInput + Input *svc.ListSecretsInput + IsOutput func(tb testing.TB, out *svc.ListSecretsOutput) bool + IsErr func(error) bool + }{ + { + Name: "when a zero value input is provided it should return a validation error", + Timeout: time.Second * 5, + Secrets: nil, + Input: nil, + IsErr: svc.IsValidationErr, + IsOutput: func(t testing.TB, out *svc.ListSecretsOutput) bool { + return true + }, + }, + { + Name: "when no secrets have been created the output should be empty", + Timeout: time.Second * 5, + Input: &svc.ListSecretsInput{}, + IsErr: isNilErr, + IsOutput: func(t testing.TB, out *svc.ListSecretsOutput) bool { + assert(t, len(out.Items) == 0, "expected zero secrets to be listed") + return true + }, + }, + { + Name: "when one correct secret was created it should be listed", + Timeout: time.Minute, + Secrets: []*svc.CreateSecretInput{{Image: "smoketest", Project: "nerdalize", Registry: "quay.io", Username: "test", Password: "test"}}, + Input: &svc.ListSecretsInput{}, + IsErr: isNilErr, + IsOutput: func(t testing.TB, out *svc.ListSecretsOutput) bool { + assert(t, len(out.Items) == 1, "expected one secret to be listed") + assert(t, !out.Items[0].Details.CreatedAt.IsZero(), "created at time should not be zero") + assert(t, out.Items[0].Details.Image == "quay.io/nerdalize/smoketest", "expected to find complete image name") + assert(t, strings.HasPrefix(out.Items[0].Name, "s-"), "expected secret name to be prefixed has expected") + return true + }, + }, + } { + t.Run(c.Name, func(t *testing.T) { + if c.Timeout > time.Second*5 && testing.Short() { + t.Skipf("skipping long test with contex timeout: %s", c.Timeout) + } + + di, clean := testDI(t) + defer clean() + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + + kube := svc.NewKube(di) + for _, secret := range c.Secrets { + _, err := kube.CreateSecret(ctx, secret) + ok(t, err) + } + + out, err := kube.ListSecrets(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) + } + }) + } +} diff --git a/svc/kube_run_job.go b/svc/kube_run_job.go index 82e67555f..95f89c862 100755 --- a/svc/kube_run_job.go +++ b/svc/kube_run_job.go @@ -28,6 +28,7 @@ type RunJobInput struct { Volumes []JobVolume Memory string VCPU string + Secret string } //JobVolumeType determines if its content will be uploaded or downloaded @@ -130,6 +131,12 @@ func (k *Kube) RunJob(ctx context.Context, in *RunJobInput) (out *RunJobOutput, }) } + if in.Secret != "" { + job.Spec.Template.Spec.ImagePullSecrets = append(job.Spec.Template.Spec.ImagePullSecrets, v1.LocalObjectReference{ + Name: kubevisor.DefaultPrefix + in.Secret, + }) + } + err = k.visor.CreateResource(ctx, kubevisor.ResourceTypeJobs, job, in.Name) if err != nil { return nil, err diff --git a/svc/kube_update_dataset.go b/svc/kube_update_dataset.go index f79bdce6c..2d84e2c86 100755 --- a/svc/kube_update_dataset.go +++ b/svc/kube_update_dataset.go @@ -2,7 +2,6 @@ package svc import ( "context" - "log" "github.com/nerdalize/nerd/pkg/kubevisor" @@ -47,7 +46,6 @@ func (k *Kube) UpdateDataset(ctx context.Context, in *UpdateDatasetInput) (out * err = k.visor.UpdateResource(ctx, kubevisor.ResourceTypeDatasets, dataset, in.Name) if err != nil { - log.Println(err) return nil, err } return &UpdateDatasetOutput{ diff --git a/svc/kube_update_secret.go b/svc/kube_update_secret.go new file mode 100644 index 000000000..92813c430 --- /dev/null +++ b/svc/kube_update_secret.go @@ -0,0 +1,42 @@ +package svc + +import ( + "context" + + "github.com/nerdalize/nerd/pkg/kubevisor" + "k8s.io/api/core/v1" +) + +// UpdateSecretInput is the input for UpdateSecret +type UpdateSecretInput struct { + Name string `validate:"printascii"` + Username string + Password string +} + +// UpdateSecretOutput is the output for UpdateSecret +type UpdateSecretOutput struct { + Name string +} + +// UpdateSecret will update a secret resource. +// Fields that can be updated: name, input, output and size. Input and output are the jobs the secret is used for or coming from. +func (k *Kube) UpdateSecret(ctx context.Context, in *UpdateSecretInput) (out *UpdateSecretOutput, err error) { + secret := &v1.Secret{} + err = k.visor.GetResource(ctx, kubevisor.ResourceTypeSecrets, secret, in.Name) + if err != nil { + return nil, err + } + + secret.Data[v1.DockerConfigJsonKey], err = transformCredentials(in.Username, in.Password, secret.Labels["registry"]) + if err != nil { + return nil, err + } + err = k.visor.UpdateResource(ctx, kubevisor.ResourceTypeSecrets, secret, in.Name) + if err != nil { + return nil, err + } + return &UpdateSecretOutput{ + Name: secret.Name, + }, nil +} diff --git a/svc/kube_update_secret_test.go b/svc/kube_update_secret_test.go new file mode 100644 index 000000000..a9a94b4fc --- /dev/null +++ b/svc/kube_update_secret_test.go @@ -0,0 +1,35 @@ +package svc_test + +import ( + "context" + "testing" + "time" + + "github.com/nerdalize/nerd/svc" +) + +func TestUpdateSecret(t *testing.T) { + di, clean := testDI(t) + defer clean() + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + + kube := svc.NewKube(di) + out, err := kube.CreateSecret(ctx, &svc.CreateSecretInput{ + Image: "smoketest", + Project: "nerdalize", + Registry: "quay.io", + Username: "test", + Password: "test", + }) + ok(t, err) + + _, err = kube.UpdateSecret(ctx, &svc.UpdateSecretInput{ + Name: out.Name, + Username: "newtest", + Password: "newtest", + }) + ok(t, err) +}