package docker import ( "encoding/base64" "encoding/json" "fmt" "os" "path" "path/filepath" "regexp" "strings" "github.com/SAP/jenkins-library/pkg/log" "github.com/SAP/jenkins-library/pkg/piperutils" "github.com/pkg/errors" cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" ) // AuthEntry defines base64 encoded username:password required inside a Docker config.json type AuthEntry struct { Auth string `json:"auth,omitempty"` } // CreateDockerConfigJSON creates / updates a Docker config.json with registry credentials func CreateDockerConfigJSON(registryURL, username, password, targetPath, configPath string, utils piperutils.FileUtils) (string, error) { if len(targetPath) == 0 { targetPath = configPath } dockerConfig := map[string]interface{}{} if exists, _ := utils.FileExists(configPath); exists { dockerConfigContent, err := utils.FileRead(configPath) if err != nil { return "", fmt.Errorf("failed to read file '%v': %w", configPath, err) } err = json.Unmarshal(dockerConfigContent, &dockerConfig) if err != nil { return "", fmt.Errorf("failed to unmarshal json file '%v': %w", configPath, err) } } credentialsBase64 := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", username, password))) dockerAuth := AuthEntry{Auth: credentialsBase64} if dockerConfig["auths"] == nil { dockerConfig["auths"] = map[string]AuthEntry{registryURL: dockerAuth} } else { authEntries, ok := dockerConfig["auths"].(map[string]interface{}) if !ok { return "", fmt.Errorf("failed to read authentication entries from file '%v': format invalid", configPath) } authEntries[registryURL] = dockerAuth dockerConfig["auths"] = authEntries } jsonResult, err := json.Marshal(dockerConfig) if err != nil { return "", fmt.Errorf("failed to marshal Docker config.json: %w", err) } //always create the target path directories if any before writing err = utils.MkdirAll(filepath.Dir(targetPath), 0777) if err != nil { return "", fmt.Errorf("failed to create directory path for the Docker config.json file %v:%w", targetPath, err) } err = utils.FileWrite(targetPath, jsonResult, 0666) if err != nil { return "", fmt.Errorf("failed to write Docker config.json: %w", err) } return targetPath, nil } // Client defines an docker client object type Client struct { imageName string registryURL string localPath string includeLayers bool imageFormat string } // ClientOptions defines the options to be set on the client type ClientOptions struct { ImageName string RegistryURL string LocalPath string ImageFormat string } //Download interface for download an image to a local path type Download interface { DownloadImage(imageSource, targetFile string) (v1.Image, error) DownloadImageContent(imageSource, targetDir string) (v1.Image, error) GetRemoteImageInfo(string) (v1.Image, error) } // SetOptions sets options used for the docker client func (c *Client) SetOptions(options ClientOptions) { c.imageName = options.ImageName c.registryURL = options.RegistryURL c.localPath = options.LocalPath c.imageFormat = options.ImageFormat } //DownloadImageContent downloads the image content into the given targetDir. Returns with an error if the targetDir doesnt exist func (c *Client) DownloadImageContent(imageSource, targetDir string) (v1.Image, error) { if fileInfo, err := os.Stat(targetDir); err != nil { return nil, err } else if !fileInfo.IsDir() { return nil, fmt.Errorf("specified target is not a directory: %s", targetDir) } noOpts := []crane.Option{} imageRef, err := c.getImageRef(imageSource) if err != nil { return nil, err } img, err := crane.Pull(imageRef.Name(), noOpts...) if err != nil { return nil, err } tmpFile, err := os.CreateTemp(".", ".piper-download-") if err != nil { return nil, err } defer os.Remove(tmpFile.Name()) args := []string{imageRef.Name(), tmpFile.Name()} exportCmd := cranecmd.NewCmdExport(&noOpts) exportCmd.SetArgs(args) if err := exportCmd.Execute(); err != nil { return nil, err } return img, piperutils.Untar(tmpFile.Name(), targetDir, 0) } // DownloadImage downloads the image and saves it as tar at the given path func (c *Client) DownloadImage(imageSource, targetFile string) (v1.Image, error) { noOpts := []crane.Option{} imageRef, err := c.getImageRef(imageSource) if err != nil { return nil, err } img, err := crane.Pull(imageRef.Name(), noOpts...) if err != nil { return nil, err } tmpFile, err := os.CreateTemp(".", ".piper-download-") if err != nil { return nil, err } craneCmd := cranecmd.NewCmdPull(&noOpts) craneCmd.SetOut(log.Writer()) craneCmd.SetErr(log.Writer()) craneCmd.SetArgs([]string{imageRef.Name(), tmpFile.Name(), "--format=" + c.imageFormat}) if err := craneCmd.Execute(); err != nil { defer os.Remove(tmpFile.Name()) return nil, err } if err := os.Rename(tmpFile.Name(), targetFile); err != nil { defer os.Remove(tmpFile.Name()) return nil, err } return img, nil } // GetRemoteImageInfo retrieves information about the image (e.g. digest) without actually downoading it func (c *Client) GetRemoteImageInfo(imageSource string) (v1.Image, error) { ref, err := c.getImageRef(imageSource) if err != nil { return nil, errors.Wrap(err, "parsing image reference") } return remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) } func (c *Client) getImageRef(image string) (name.Reference, error) { opts := []name.Option{} registry := "" if len(c.registryURL) > 0 { re := regexp.MustCompile(`(?i)^https?://`) registry = re.ReplaceAllString(c.registryURL, "") opts = append(opts, name.WithDefaultRegistry(registry)) } return name.ParseReference(path.Join(registry, image), opts...) } // ImageListWithFilePath compiles container image names based on all Dockerfiles found, considering excludes // according to following search pattern: **/Dockerfile* // Return value contains a map with image names and file path // Examples for image names with imageName testImage // * Dockerfile: `imageName` // * sub1/Dockerfile: `imageName-sub1` // * sub2/Dockerfile_proxy: `imageName-sub2-proxy` func ImageListWithFilePath(imageName string, excludes []string, trimDir string, utils piperutils.FileUtils) (map[string]string, error) { imageList := map[string]string{} pattern := "**/Dockerfile*" matches, err := utils.Glob(pattern) if err != nil || len(matches) == 0 { return imageList, fmt.Errorf("failed to retrieve Dockerfiles") } for _, dockerfilePath := range matches { // make sure that the path we have is relative // ToDo: needs rework //dockerfilePath = strings.ReplaceAll(dockerfilePath, cwd, ".") if piperutils.ContainsString(excludes, dockerfilePath) { log.Entry().Infof("Discard %v since it is in the exclude list %v", dockerfilePath, excludes) continue } if dockerfilePath == "Dockerfile" { imageList[imageName] = dockerfilePath } else { var finalName string if base := filepath.Base(dockerfilePath); base == "Dockerfile" { subName := strings.ReplaceAll(filepath.Dir(dockerfilePath), string(filepath.Separator), "-") if len(trimDir) > 0 { // allow to remove trailing sub directories // example .ci/app/Dockerfile // with trimDir = .ci/ imagename would only contain app part. subName = strings.TrimPrefix(subName, strings.ReplaceAll(trimDir, "/", "-")) // make sure that subName does not start with a - (e.g. due not configuring trailing slash for trimDir) subName = strings.TrimPrefix(subName, "-") } finalName = fmt.Sprintf("%v-%v", imageName, subName) } else { parts := strings.FieldsFunc(base, func(separator rune) bool { return separator == []rune("-")[0] || separator == []rune("_")[0] }) if len(parts) == 1 { return imageList, fmt.Errorf("wrong format of Dockerfile, must be inside a sub-folder or contain a separator") } parts[0] = imageName finalName = strings.Join(parts, "-") } imageList[finalName] = dockerfilePath } } return imageList, nil }