diff --git a/.gitignore b/.gitignore index 711b1b8a..50950d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ website/node_modules *.iml *.test *.iml +.vscode website/vendor diff --git a/docker/provider.go b/docker/provider.go index b269371e..e0c048bf 100644 --- a/docker/provider.go +++ b/docker/provider.go @@ -105,13 +105,14 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ - "docker_container": resourceDockerContainer(), - "docker_image": resourceDockerImage(), - "docker_network": resourceDockerNetwork(), - "docker_volume": resourceDockerVolume(), - "docker_config": resourceDockerConfig(), - "docker_secret": resourceDockerSecret(), - "docker_service": resourceDockerService(), + "docker_container": resourceDockerContainer(), + "docker_image": resourceDockerImage(), + "docker_registry_image": resourceDockerRegistryImage(), + "docker_network": resourceDockerNetwork(), + "docker_volume": resourceDockerVolume(), + "docker_config": resourceDockerConfig(), + "docker_secret": resourceDockerSecret(), + "docker_service": resourceDockerService(), }, DataSourcesMap: map[string]*schema.Resource{ diff --git a/docker/resource_docker_registry_image.go b/docker/resource_docker_registry_image.go new file mode 100644 index 00000000..678dba87 --- /dev/null +++ b/docker/resource_docker_registry_image.go @@ -0,0 +1,285 @@ +package docker + +import ( + "os" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceDockerRegistryImage() *schema.Resource { + return &schema.Resource{ + Create: resourceDockerRegistryImageCreate, + Read: resourceDockerRegistryImageRead, + Delete: resourceDockerRegistryImageDelete, + Update: resourceDockerRegistryImageUpdate, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "keep_remotely": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + + "build": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "suppress_output": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "remote_context": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "no_cache": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "remove": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "force_remove": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "pull_parent": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "isolation": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "cpu_set_cpus": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "cpu_set_mems": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "cpu_shares": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "cpu_quota": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "cpu_period": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "memory": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "memory_swap": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "cgroup_parent": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "network_mode": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "shm_size": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "dockerfile": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "Dockerfile", + ForceNew: true, + }, + "ulimit": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "hard": &schema.Schema{ + Type: schema.TypeInt, + Required: true, + ForceNew: true, + }, + "soft": &schema.Schema{ + Type: schema.TypeInt, + Required: true, + ForceNew: true, + }, + }, + }, + }, + "build_args": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "auth_config": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "host_name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "user_name": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "password": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "auth": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "email": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "server_address": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "identity_token": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "registry_token": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "context": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + StateFunc: func(val interface{}) string { + // the context hash is stored to identify changes in the context files + dockerContextTarPath, _ := buildDockerImageContextTar(val.(string)) + defer os.Remove(dockerContextTarPath) + contextTarHash, _ := getDockerImageContextTarHash(dockerContextTarPath) + return val.(string) + ":" + contextTarHash + }, + }, + "labels": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "squash": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "cache_from": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "security_opt": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "extra_hosts": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "target": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "session_id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "platform": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "version": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "build_id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + // "output": &schema.Schema{ + // Type: schema.TypeString, + // Optional: true, + // }, + }, + }, + }, + + "sha256_digest": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} diff --git a/docker/resource_docker_registry_image_funcs.go b/docker/resource_docker_registry_image_funcs.go new file mode 100644 index 00000000..676f447e --- /dev/null +++ b/docker/resource_docker_registry_image_funcs.go @@ -0,0 +1,488 @@ +package docker + +import ( + "archive/tar" + "bufio" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/term" + "github.com/docker/go-units" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +type internalPushImageOptions struct { + Name string + FqName string + Registry string + NormalizedRegistry string + Repository string + Tag string +} + +func createImageBuildOptions(buildOptions map[string]interface{}) types.ImageBuildOptions { + + mapOfInterfacesToMapOfStrings := func(mapOfInterfaces map[string]interface{}) map[string]string { + mapOfStrings := make(map[string]string, len(mapOfInterfaces)) + for k, v := range mapOfInterfaces { + mapOfStrings[k] = fmt.Sprintf("%v", v) + } + return mapOfStrings + } + + interfaceArrayToStringArray := func(interfaceArray []interface{}) []string { + stringArray := make([]string, len(interfaceArray)) + for i, v := range interfaceArray { + stringArray[i] = fmt.Sprintf("%v", v) + } + return stringArray + } + + mapToBuildArgs := func(buildArgsOptions map[string]interface{}) map[string]*string { + buildArgs := make(map[string]*string, len(buildArgsOptions)) + for k, v := range buildArgsOptions { + value := v.(string) + buildArgs[k] = &value + } + return buildArgs + } + + readULimits := func(options []interface{}) []*units.Ulimit { + ulimits := make([]*units.Ulimit, len(options)) + for i, v := range options { + ulimitOption := v.(map[string]interface{}) + ulimit := units.Ulimit{ + Name: ulimitOption["name"].(string), + Hard: int64(ulimitOption["hard"].(int)), + Soft: int64(ulimitOption["soft"].(int)), + } + ulimits[i] = &ulimit + } + return ulimits + } + + readAuthConfigs := func(options []interface{}) map[string]types.AuthConfig { + authConfigs := make(map[string]types.AuthConfig, len(options)) + for _, v := range options { + authOptions := v.(map[string]interface{}) + auth := types.AuthConfig{ + Username: authOptions["user_name"].(string), + Password: authOptions["password"].(string), + Auth: authOptions["auth"].(string), + Email: authOptions["email"].(string), + ServerAddress: authOptions["server_address"].(string), + IdentityToken: authOptions["identity_token"].(string), + RegistryToken: authOptions["registry_token"].(string), + } + authConfigs[authOptions["host_name"].(string)] = auth + } + return authConfigs + } + + buildImageOptions := types.ImageBuildOptions{} + buildImageOptions.SuppressOutput = buildOptions["suppress_output"].(bool) + buildImageOptions.RemoteContext = buildOptions["remote_context"].(string) + buildImageOptions.NoCache = buildOptions["no_cache"].(bool) + buildImageOptions.Remove = buildOptions["remove"].(bool) + buildImageOptions.ForceRemove = buildOptions["force_remove"].(bool) + buildImageOptions.PullParent = buildOptions["pull_parent"].(bool) + buildImageOptions.Isolation = container.Isolation(buildOptions["isolation"].(string)) + buildImageOptions.CPUSetCPUs = buildOptions["cpu_set_cpus"].(string) + buildImageOptions.CPUSetMems = buildOptions["cpu_set_mems"].(string) + buildImageOptions.CPUShares = int64(buildOptions["cpu_shares"].(int)) + buildImageOptions.CPUQuota = int64(buildOptions["cpu_quota"].(int)) + buildImageOptions.CPUPeriod = int64(buildOptions["cpu_period"].(int)) + buildImageOptions.Memory = int64(buildOptions["memory"].(int)) + buildImageOptions.MemorySwap = int64(buildOptions["memory_swap"].(int)) + buildImageOptions.CgroupParent = buildOptions["cgroup_parent"].(string) + buildImageOptions.NetworkMode = buildOptions["network_mode"].(string) + buildImageOptions.ShmSize = int64(buildOptions["shm_size"].(int)) + buildImageOptions.Dockerfile = buildOptions["dockerfile"].(string) + buildImageOptions.Ulimits = readULimits(buildOptions["ulimit"].([]interface{})) + buildImageOptions.BuildArgs = mapToBuildArgs(buildOptions["build_args"].(map[string]interface{})) + buildImageOptions.AuthConfigs = readAuthConfigs(buildOptions["auth_config"].([]interface{})) + buildImageOptions.Labels = mapOfInterfacesToMapOfStrings(buildOptions["labels"].(map[string]interface{})) + buildImageOptions.Squash = buildOptions["squash"].(bool) + buildImageOptions.CacheFrom = interfaceArrayToStringArray(buildOptions["cache_from"].([]interface{})) + buildImageOptions.SecurityOpt = interfaceArrayToStringArray(buildOptions["security_opt"].([]interface{})) + buildImageOptions.ExtraHosts = interfaceArrayToStringArray(buildOptions["extra_hosts"].([]interface{})) + buildImageOptions.Target = buildOptions["target"].(string) + buildImageOptions.SessionID = buildOptions["session_id"].(string) + buildImageOptions.Platform = buildOptions["platform"].(string) + buildImageOptions.Version = types.BuilderVersion(buildOptions["version"].(string)) + buildImageOptions.BuildID = buildOptions["build_id"].(string) + // outputs + + return buildImageOptions +} + +func buildDockerImage(client *client.Client, buildOptions map[string]interface{}, fqName string) error { + + log.Printf("[DEBUG] Building docker image") + imageBuildOptions := createImageBuildOptions(buildOptions) + imageBuildOptions.Tags = []string{fqName} + + // the tar hash is passed only after the initial creation + buildContext := buildOptions["context"].(string) + if lastIndex := strings.LastIndexByte(buildContext, ':'); lastIndex > -1 { + buildContext = buildContext[:lastIndex] + } + dockerContextTarPath, err := buildDockerImageContextTar(buildContext) + defer os.Remove(dockerContextTarPath) + dockerBuildContext, err := os.Open(dockerContextTarPath) + defer dockerBuildContext.Close() + + buildResponse, err := client.ImageBuild(context.Background(), dockerBuildContext, imageBuildOptions) + if err != nil { + return err + } + defer buildResponse.Body.Close() + + termFd, isTerm := term.GetFdInfo(os.Stderr) + err = jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stderr, termFd, isTerm, nil) + if err != nil { + return err + } + + return nil +} + +func buildDockerImageContextTar(buildContext string) (string, error) { + // Create our Temp File: This will create a filename like /tmp/terraform-provider-docker-123456.tar + tmpFile, err := ioutil.TempFile(os.TempDir(), "terraform-provider-docker-*.tar") + if err != nil { + return "", fmt.Errorf("Cannot create temporary file - %v", err.Error()) + } + + defer tmpFile.Close() + + if _, err = os.Stat(buildContext); err != nil { + return "", fmt.Errorf("Unable to read build context - %v", err.Error()) + } + + tw := tar.NewWriter(tmpFile) + defer tw.Close() + + err = filepath.Walk(buildContext, func(file string, info os.FileInfo, err error) error { + + // return on any error + if err != nil { + return err + } + + // create a new dir/file header + header, err := tar.FileInfoHeader(info, info.Name()) + if err != nil { + return err + } + + // update the name to correctly reflect the desired destination when untaring + header.Name = strings.TrimPrefix(strings.Replace(file, buildContext, "", -1), string(filepath.Separator)) + + // write the header + if err := tw.WriteHeader(header); err != nil { + return err + } + + // return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update) + if !info.Mode().IsRegular() { + return nil + } + + // open files for taring + f, err := os.Open(file) + if err != nil { + return err + } + + // copy file data into tar writer + if _, err := io.Copy(tw, f); err != nil { + return err + } + + // manually close here after each file operation; defering would cause each file close + // to wait until all operations have completed. + f.Close() + + return nil + + }) + + return tmpFile.Name(), nil +} + +func getDockerImageContextTarHash(dockerContextTarPath string) (string, error) { + hasher := sha256.New() + s, err := ioutil.ReadFile(dockerContextTarPath) + if err != nil { + return "", err + } + hasher.Write(s) + contextHash := hex.EncodeToString(hasher.Sum(nil)) + return contextHash, nil +} + +func pushDockerRegistryImage(client *client.Client, pushOpts internalPushImageOptions, username string, password string) error { + pushOptions := types.ImagePushOptions{} + if username != "" { + auth := types.AuthConfig{Username: username, Password: password} + authBytes, err := json.Marshal(auth) + if err != nil { + return fmt.Errorf("Error creating push options: %s", err) + } + authBase64 := base64.URLEncoding.EncodeToString(authBytes) + pushOptions.RegistryAuth = authBase64 + } + + out, err := client.ImagePush(context.Background(), pushOpts.FqName, pushOptions) + if err != nil { + return err + } + defer out.Close() + + type ErrorMessage struct { + Error string + } + var errorMessage ErrorMessage + buffIOReader := bufio.NewReader(out) + for { + streamBytes, err := buffIOReader.ReadBytes('\n') + if err == io.EOF { + break + } + json.Unmarshal(streamBytes, &errorMessage) + if errorMessage.Error != "" { + return fmt.Errorf("Error pushing image: %s", errorMessage.Error) + } + } + log.Printf("[DEBUG] Pushed image: %s", pushOpts.FqName) + return nil +} + +func getDockerRegistryImageRegistryUserNameAndPassword( + pushOpts internalPushImageOptions, + providerConfig *ProviderConfig) (string, string) { + registry := pushOpts.NormalizedRegistry + username := "" + password := "" + if authConfig, ok := providerConfig.AuthConfigs.Configs[registry]; ok { + username = authConfig.Username + password = authConfig.Password + } + return username, password +} + +func deleteDockerRegistryImage(pushOpts internalPushImageOptions, sha256Digest, username, password string, fallback bool) error { + client := http.DefaultClient + + // Allow insecure registries only for ACC tests + // cuz we don't have a valid certs for this case + if env, okEnv := os.LookupEnv("TF_ACC"); okEnv { + if i, errConv := strconv.Atoi(env); errConv == nil && i >= 1 { + cfg := &tls.Config{ + InsecureSkipVerify: true, + } + client.Transport = &http.Transport{ + TLSClientConfig: cfg, + } + } + } + + req, err := http.NewRequest("DELETE", pushOpts.NormalizedRegistry+"/v2/"+pushOpts.Repository+"/manifests/"+sha256Digest, nil) + if err != nil { + return fmt.Errorf("Error deleting registry image: %s", err) + } + + if username != "" { + req.SetBasicAuth(username, password) + } + + // Set this header so that we get the v2 manifest back from the registry. + req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") + if fallback { + // Fallback to this header if the registry does not support the v2 manifest like gcr.io + req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v1+prettyjws") + } + + resp, err := client.Do(req) + + if err != nil { + return fmt.Errorf("Error during registry request: %s", err) + } + + switch resp.StatusCode { + // Basic auth was valid or not needed + case http.StatusOK, http.StatusAccepted, http.StatusNotFound: + return nil + + // Either OAuth is required or the basic auth creds were invalid + case http.StatusUnauthorized: + if strings.HasPrefix(resp.Header.Get("www-authenticate"), "Bearer") { + auth := parseAuthHeader(resp.Header.Get("www-authenticate")) + params := url.Values{} + params.Set("service", auth["service"]) + params.Set("scope", auth["scope"]) + tokenRequest, err := http.NewRequest("GET", auth["realm"]+"?"+params.Encode(), nil) + + if err != nil { + return fmt.Errorf("Error creating registry request: %s", err) + } + + if username != "" { + tokenRequest.SetBasicAuth(username, password) + } + + tokenResponse, err := client.Do(tokenRequest) + + if err != nil { + return fmt.Errorf("Error during registry request: %s", err) + } + + if tokenResponse.StatusCode != http.StatusOK { + return fmt.Errorf("Got bad response from registry: " + tokenResponse.Status) + } + + body, err := ioutil.ReadAll(tokenResponse.Body) + if err != nil { + return fmt.Errorf("Error reading response body: %s", err) + } + + token := &TokenResponse{} + err = json.Unmarshal(body, token) + if err != nil { + return fmt.Errorf("Error parsing OAuth token response: %s", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + oauthResp, err := client.Do(req) + switch oauthResp.StatusCode { + case http.StatusOK, http.StatusAccepted, http.StatusNotFound: + return nil + default: + return fmt.Errorf("Got bad response from registry: " + resp.Status) + } + + } + + return fmt.Errorf("Bad credentials: " + resp.Status) + + // Some unexpected status was given, return an error + default: + return fmt.Errorf("Got bad response from registry: " + resp.Status) + } +} + +func getImageDigestWithFallback(opts internalPushImageOptions, username, password string) (string, error) { + digest, err := getImageDigest(opts.Registry, opts.Repository, opts.Tag, username, password, false) + if err != nil { + digest, err = getImageDigest(opts.Registry, opts.Repository, opts.Tag, username, password, true) + if err != nil { + return "", fmt.Errorf("Unable to get digest: %s", err) + } + } + return digest, nil +} + +func createPushImageOptions(image string) internalPushImageOptions { + pullOpts := parseImageOptions(image) + if pullOpts.Registry == "" { + pullOpts.Registry = "registry.hub.docker.com" + } else { + pullOpts.Repository = strings.Replace(pullOpts.Repository, pullOpts.Registry+"/", "", 1) + } + pushOpts := internalPushImageOptions{ + Name: image, + Registry: pullOpts.Registry, + NormalizedRegistry: normalizeRegistryAddress(pullOpts.Registry), + Repository: pullOpts.Repository, + Tag: pullOpts.Tag, + FqName: fmt.Sprintf("%s/%s:%s", pullOpts.Registry, pullOpts.Repository, pullOpts.Tag), + } + return pushOpts +} + +func resourceDockerRegistryImageCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*ProviderConfig).DockerClient + providerConfig := meta.(*ProviderConfig) + name := d.Get("name").(string) + log.Printf("[DEBUG] Creating docker image %s", name) + + pushOpts := createPushImageOptions(name) + + if buildOptions, ok := d.GetOk("build"); ok { + buildOptionsMap := buildOptions.([]interface{})[0].(map[string]interface{}) + err := buildDockerImage(client, buildOptionsMap, pushOpts.FqName) + if err != nil { + return fmt.Errorf("Error building docker image: %s", err) + } + } + + username, password := getDockerRegistryImageRegistryUserNameAndPassword(pushOpts, providerConfig) + if err := pushDockerRegistryImage(client, pushOpts, username, password); err != nil { + return fmt.Errorf("Error pushing docker image: %s", err) + } + + digest, err := getImageDigestWithFallback(pushOpts, username, password) + if err != nil { + return fmt.Errorf("Unable to create image, image not found: %s", err) + } + d.SetId(digest) + d.Set("sha256_digest", digest) + return nil +} + +func resourceDockerRegistryImageRead(d *schema.ResourceData, meta interface{}) error { + providerConfig := meta.(*ProviderConfig) + name := d.Get("name").(string) + pushOpts := createPushImageOptions(name) + username, password := getDockerRegistryImageRegistryUserNameAndPassword(pushOpts, providerConfig) + digest, err := getImageDigestWithFallback(pushOpts, username, password) + if err != nil { + log.Printf("Got error getting registry image digest: %s", err) + d.SetId("") + return nil + } + d.Set("sha256_digest", digest) + return nil +} + +func resourceDockerRegistryImageDelete(d *schema.ResourceData, meta interface{}) error { + if d.Get("keep_remotely").(bool) { + return nil + } + providerConfig := meta.(*ProviderConfig) + name := d.Get("name").(string) + pushOpts := createPushImageOptions(name) + username, password := getDockerRegistryImageRegistryUserNameAndPassword(pushOpts, providerConfig) + digest := d.Get("sha256_digest").(string) + err := deleteDockerRegistryImage(pushOpts, digest, username, password, false) + if err != nil { + err = deleteDockerRegistryImage(pushOpts, pushOpts.Tag, username, password, true) + if err != nil { + return fmt.Errorf("Got error getting registry image digest: %s", err) + } + } + return nil +} + +func resourceDockerRegistryImageUpdate(d *schema.ResourceData, meta interface{}) error { + return resourceDockerRegistryImageRead(d, meta) +} diff --git a/docker/resource_docker_registry_image_funcs_test.go b/docker/resource_docker_registry_image_funcs_test.go new file mode 100644 index 00000000..a831239c --- /dev/null +++ b/docker/resource_docker_registry_image_funcs_test.go @@ -0,0 +1,289 @@ +package docker + +import ( + "fmt" + "regexp" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/go-units" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "gotest.tools/assert" + "gotest.tools/assert/cmp" +) + +func TestAccDockerRegistryImageResource_mapping(t *testing.T) { + dummyProvider := Provider().(*schema.Provider) + dummyResource := dummyProvider.ResourcesMap["docker_registry_image"] + dummyResource.Create = func(d *schema.ResourceData, meta interface{}) error { + build := d.Get("build").([]interface{})[0].(map[string]interface{}) + options := createImageBuildOptions(build) + + assert.Check(t, cmp.Equal(options.SuppressOutput, true)) + assert.Check(t, cmp.Equal(options.RemoteContext, "fooRemoteContext")) + assert.Check(t, cmp.Equal(options.NoCache, true)) + assert.Check(t, cmp.Equal(options.Remove, true)) + assert.Check(t, cmp.Equal(options.ForceRemove, true)) + assert.Check(t, cmp.Equal(options.PullParent, true)) + assert.Check(t, cmp.Equal(options.Isolation, container.Isolation("hyperv"))) + assert.Check(t, cmp.Equal(options.CPUSetCPUs, "fooCpuSetCpus")) + assert.Check(t, cmp.Equal(options.CPUSetMems, "fooCpuSetMems")) + assert.Check(t, cmp.Equal(options.CPUShares, int64(4))) + assert.Check(t, cmp.Equal(options.CPUQuota, int64(5))) + assert.Check(t, cmp.Equal(options.CPUPeriod, int64(6))) + assert.Check(t, cmp.Equal(options.Memory, int64(1))) + assert.Check(t, cmp.Equal(options.MemorySwap, int64(2))) + assert.Check(t, cmp.Equal(options.CgroupParent, "fooCgroupParent")) + assert.Check(t, cmp.Equal(options.NetworkMode, "fooNetworkMode")) + assert.Check(t, cmp.Equal(options.ShmSize, int64(3))) + assert.Check(t, cmp.Equal(options.Dockerfile, "fooDockerfile")) + assert.Check(t, cmp.Equal(len(options.Ulimits), 1)) + assert.Check(t, cmp.DeepEqual(*options.Ulimits[0], units.Ulimit{ + Name: "foo", + Hard: int64(1), + Soft: int64(2), + })) + assert.Check(t, cmp.Equal(len(options.BuildArgs), 1)) + assert.Check(t, cmp.Equal(*options.BuildArgs["HTTP_PROXY"], "http://10.20.30.2:1234")) + assert.Check(t, cmp.Equal(len(options.AuthConfigs), 1)) + assert.Check(t, cmp.DeepEqual(options.AuthConfigs["foo.host"], types.AuthConfig{ + Username: "fooUserName", + Password: "fooPassword", + Auth: "fooAuth", + Email: "fooEmail", + ServerAddress: "fooServerAddress", + IdentityToken: "fooIdentityToken", + RegistryToken: "fooRegistryToken", + })) + assert.Check(t, cmp.DeepEqual(options.Labels, map[string]string{"foo": "bar"})) + assert.Check(t, cmp.Equal(options.Squash, true)) + assert.Check(t, cmp.DeepEqual(options.CacheFrom, []string{"fooCacheFrom", "barCacheFrom"})) + assert.Check(t, cmp.DeepEqual(options.SecurityOpt, []string{"fooSecurityOpt", "barSecurityOpt"})) + assert.Check(t, cmp.DeepEqual(options.ExtraHosts, []string{"fooExtraHost", "barExtraHost"})) + assert.Check(t, cmp.Equal(options.Target, "fooTarget")) + assert.Check(t, cmp.Equal(options.SessionID, "fooSessionId")) + assert.Check(t, cmp.Equal(options.Platform, "fooPlatform")) + assert.Check(t, cmp.Equal(options.Version, types.BuilderVersion("1"))) + assert.Check(t, cmp.Equal(options.BuildID, "fooBuildId")) + // output + d.SetId("foo") + d.Set("sha256_digest", "bar") + return nil + } + dummyResource.Update = func(d *schema.ResourceData, meta interface{}) error { + return nil + } + dummyResource.Delete = func(d *schema.ResourceData, meta interface{}) error { + return nil + } + dummyResource.Read = func(d *schema.ResourceData, meta interface{}) error { + d.Set("sha256_digest", "bar") + return nil + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: map[string]terraform.ResourceProvider{"docker": dummyProvider}, + Steps: []resource.TestStep{ + { + Config: testBuildDockerRegistryImageMappingConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_registry_image.foo", "sha256_digest"), + ), + }, + }, + }) + +} + +func TestAccDockerRegistryImageResource_build(t *testing.T) { + pushOptions := createPushImageOptions("127.0.0.1:15000/tftest-dockerregistryimage:1.0") + context := "../scripts/testing/docker_registry_image_context" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testDockerRegistryImageNotInRegistry(pushOptions), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testBuildDockerRegistryImageNoKeepConfig, pushOptions.Registry, pushOptions.Name, context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_registry_image.foo", "sha256_digest"), + ), + }, + }, + }) +} + +func TestAccDockerRegistryImageResource_buildAndKeep(t *testing.T) { + pushOptions := createPushImageOptions("127.0.0.1:15000/tftest-dockerregistryimage:1.0") + context := "../scripts/testing/docker_registry_image_context" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testDockerRegistryImageInRegistry(pushOptions, true), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testBuildDockerRegistryImageKeepConfig, pushOptions.Registry, pushOptions.Name, context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_registry_image.foo", "sha256_digest"), + ), + }, + }, + }) +} + +func TestAccDockerRegistryImageResource_pushMissingImage(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testDockerRegistryImagePushMissingConfig, + ExpectError: regexp.MustCompile("An image does not exist locally"), + }, + }, + }) +} + +func testDockerRegistryImageNotInRegistry(pushOpts internalPushImageOptions) resource.TestCheckFunc { + return func(s *terraform.State) error { + providerConfig := testAccProvider.Meta().(*ProviderConfig) + username, password := getDockerRegistryImageRegistryUserNameAndPassword(pushOpts, providerConfig) + digest, _ := getImageDigestWithFallback(pushOpts, username, password) + if digest != "" { + return fmt.Errorf("image found") + } + return nil + } +} + +func testDockerRegistryImageInRegistry(pushOpts internalPushImageOptions, cleanup bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + providerConfig := testAccProvider.Meta().(*ProviderConfig) + username, password := getDockerRegistryImageRegistryUserNameAndPassword(pushOpts, providerConfig) + digest, err := getImageDigestWithFallback(pushOpts, username, password) + if err != nil || len(digest) < 1 { + return fmt.Errorf("image not found") + } + if cleanup { + err := deleteDockerRegistryImage(pushOpts, digest, username, password, false) + if err != nil { + return fmt.Errorf("Unable to remove test image. %s", err) + } + } + return nil + } +} + +const testBuildDockerRegistryImageMappingConfig = ` +resource "docker_registry_image" "foo" { + name = "localhost:15000/foo:1.0" + build { + suppress_output = true + remote_context = "fooRemoteContext" + no_cache = true + remove = true + force_remove = true + pull_parent = true + isolation = "hyperv" + cpu_set_cpus = "fooCpuSetCpus" + cpu_set_mems = "fooCpuSetMems" + cpu_shares = 4 + cpu_quota = 5 + cpu_period = 6 + memory = 1 + memory_swap = 2 + cgroup_parent = "fooCgroupParent" + network_mode = "fooNetworkMode" + shm_size = 3 + dockerfile = "fooDockerfile" + ulimit { + name = "foo" + hard = 1 + soft = 2 + } + auth_config { + host_name = "foo.host" + user_name = "fooUserName" + password = "fooPassword" + auth = "fooAuth" + email = "fooEmail" + server_address = "fooServerAddress" + identity_token = "fooIdentityToken" + registry_token = "fooRegistryToken" + + } + build_args = { + "HTTP_PROXY" = "http://10.20.30.2:1234" + } + context = "context" + labels = { + foo = "bar" + } + squash = true + cache_from = ["fooCacheFrom", "barCacheFrom"] + security_opt = ["fooSecurityOpt", "barSecurityOpt"] + extra_hosts = ["fooExtraHost", "barExtraHost"] + target = "fooTarget" + session_id = "fooSessionId" + platform = "fooPlatform" + version = "1" + build_id = "fooBuildId" + } +} +` + +const testBuildDockerRegistryImageNoKeepConfig = ` +provider "docker" { + alias = "private" + registry_auth { + address = "%s" + } +} +resource "docker_registry_image" "foo" { + provider = "docker.private" + name = "%s" + build { + context = "%s" + remove = true + force_remove = true + no_cache = true + } +} +` + +const testBuildDockerRegistryImageKeepConfig = ` +provider "docker" { + alias = "private" + registry_auth { + address = "%s" + } +} +resource "docker_registry_image" "foo" { + provider = "docker.private" + name = "%s" + keep_remotely = true + build { + context = "%s" + remove = true + force_remove = true + no_cache = true + } +} +` + +const testDockerRegistryImagePushMissingConfig = ` +provider "docker" { + alias = "private" + registry_auth { + address = "127.0.0.1:15000" + } +} +resource "docker_registry_image" "foo" { + provider = "docker.private" + name = "127.0.0.1:15000/nonexistent:1.0" +} +` diff --git a/go.mod b/go.mod index 13292385..764d364c 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/opencontainers/image-spec v0.0.0-20171125024018-577479e4dc27 // indirect github.com/pkg/errors v0.8.1 // indirect github.com/sirupsen/logrus v1.4.2 // indirect - gotest.tools v2.2.0+incompatible // indirect + gotest.tools v2.2.0+incompatible ) go 1.13 diff --git a/scripts/testacc_setup.sh b/scripts/testacc_setup.sh index 6441c748..b465a344 100755 --- a/scripts/testacc_setup.sh +++ b/scripts/testacc_setup.sh @@ -26,6 +26,7 @@ docker run -d -p 15000:5000 --rm --name private_registry \ -v "$(pwd)"/scripts/testing/certs:/certs \ -e "REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry_auth.crt" \ -e "REGISTRY_HTTP_TLS_KEY=/certs/registry_auth.key" \ + -e "REGISTRY_STORAGE_DELETE_ENABLED=true" \ registry:2 # wait a bit for travis... sleep 5 diff --git a/scripts/testing/docker_registry_image_context/Dockerfile b/scripts/testing/docker_registry_image_context/Dockerfile new file mode 100644 index 00000000..ef5380d1 --- /dev/null +++ b/scripts/testing/docker_registry_image_context/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +COPY empty /empty \ No newline at end of file diff --git a/scripts/testing/docker_registry_image_context/empty b/scripts/testing/docker_registry_image_context/empty new file mode 100644 index 00000000..e69de29b diff --git a/website/docker.erb b/website/docker.erb index 6739ac94..f8d92dc8 100644 --- a/website/docker.erb +++ b/website/docker.erb @@ -30,6 +30,10 @@ docker_image +