github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/pkg/docker/docker.go (about)

     1  package docker
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"os"
     9  	"path"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strings"
    13  
    14  	"github.com/SAP/jenkins-library/pkg/log"
    15  	"github.com/SAP/jenkins-library/pkg/piperutils"
    16  	"github.com/pkg/errors"
    17  
    18  	"github.com/docker/cli/cli/config"
    19  	"github.com/docker/cli/cli/config/configfile"
    20  
    21  	cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd"
    22  	"github.com/google/go-containerregistry/pkg/authn"
    23  	"github.com/google/go-containerregistry/pkg/crane"
    24  	"github.com/google/go-containerregistry/pkg/name"
    25  	v1 "github.com/google/go-containerregistry/pkg/v1"
    26  	"github.com/google/go-containerregistry/pkg/v1/remote"
    27  )
    28  
    29  // AuthEntry defines base64 encoded username:password required inside a Docker config.json
    30  type AuthEntry struct {
    31  	Auth string `json:"auth,omitempty"`
    32  }
    33  
    34  // MergeDockerConfigJSON merges two docker config.json files.
    35  func MergeDockerConfigJSON(sourcePath, targetPath string, utils piperutils.FileUtils) error {
    36  	if exists, _ := utils.FileExists(sourcePath); !exists {
    37  		return fmt.Errorf("source dockerConfigJSON file %q does not exist", sourcePath)
    38  	}
    39  
    40  	sourceReader, err := utils.Open(sourcePath)
    41  	if err != nil {
    42  		return errors.Wrapf(err, "failed to open file %q", sourcePath)
    43  	}
    44  	defer sourceReader.Close()
    45  
    46  	sourceConfig, err := config.LoadFromReader(sourceReader)
    47  	if err != nil {
    48  		return errors.Wrapf(err, "failed to read file %q", sourcePath)
    49  	}
    50  
    51  	var targetConfig *configfile.ConfigFile
    52  	if exists, _ := utils.FileExists(targetPath); !exists {
    53  		log.Entry().Warnf("target dockerConfigJSON file %q does not exist, creating a new one", sourcePath)
    54  		targetConfig = configfile.New(targetPath)
    55  	} else {
    56  		targetReader, err := utils.Open(targetPath)
    57  		if err != nil {
    58  			return errors.Wrapf(err, "failed to open file %q", targetReader)
    59  		}
    60  		defer targetReader.Close()
    61  		targetConfig, err = config.LoadFromReader(targetReader)
    62  		if err != nil {
    63  			return errors.Wrapf(err, "failed to read file %q", targetPath)
    64  		}
    65  	}
    66  
    67  	for registry, auth := range sourceConfig.GetAuthConfigs() {
    68  		targetConfig.AuthConfigs[registry] = auth
    69  	}
    70  
    71  	buf := bytes.NewBuffer(nil)
    72  	err = targetConfig.SaveToWriter(buf)
    73  	if err != nil {
    74  		return errors.Wrapf(err, "failed to save file %q", targetPath)
    75  	}
    76  
    77  	err = utils.MkdirAll(filepath.Dir(targetPath), 0777)
    78  	if err != nil {
    79  		return fmt.Errorf("failed to create directory path for the file %q: %w", targetPath, err)
    80  	}
    81  	err = utils.FileWrite(targetPath, buf.Bytes(), 0666)
    82  	if err != nil {
    83  		return fmt.Errorf("failed to write %q: %w", targetPath, err)
    84  	}
    85  
    86  	return nil
    87  }
    88  
    89  // CreateDockerConfigJSON creates / updates a Docker config.json with registry credentials
    90  func CreateDockerConfigJSON(registryURL, username, password, targetPath, configPath string, utils piperutils.FileUtils) (string, error) {
    91  
    92  	if len(targetPath) == 0 {
    93  		targetPath = configPath
    94  	}
    95  
    96  	dockerConfig := map[string]interface{}{}
    97  	if exists, _ := utils.FileExists(configPath); exists {
    98  		dockerConfigContent, err := utils.FileRead(configPath)
    99  		if err != nil {
   100  			return "", fmt.Errorf("failed to read file '%v': %w", configPath, err)
   101  		}
   102  
   103  		err = json.Unmarshal(dockerConfigContent, &dockerConfig)
   104  		if err != nil {
   105  			return "", fmt.Errorf("failed to unmarshal json file '%v': %w", configPath, err)
   106  		}
   107  	}
   108  
   109  	credentialsBase64 := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", username, password)))
   110  	dockerAuth := AuthEntry{Auth: credentialsBase64}
   111  
   112  	if dockerConfig["auths"] == nil {
   113  		dockerConfig["auths"] = map[string]AuthEntry{registryURL: dockerAuth}
   114  	} else {
   115  		authEntries, ok := dockerConfig["auths"].(map[string]interface{})
   116  		if !ok {
   117  			return "", fmt.Errorf("failed to read authentication entries from file '%v': format invalid", configPath)
   118  		}
   119  		authEntries[registryURL] = dockerAuth
   120  		dockerConfig["auths"] = authEntries
   121  	}
   122  
   123  	jsonResult, err := json.Marshal(dockerConfig)
   124  	if err != nil {
   125  		return "", fmt.Errorf("failed to marshal Docker config.json: %w", err)
   126  	}
   127  
   128  	//always create the target path directories if any before writing
   129  	err = utils.MkdirAll(filepath.Dir(targetPath), 0777)
   130  	if err != nil {
   131  		return "", fmt.Errorf("failed to create directory path for the Docker config.json file %v:%w", targetPath, err)
   132  	}
   133  	err = utils.FileWrite(targetPath, jsonResult, 0666)
   134  	if err != nil {
   135  		return "", fmt.Errorf("failed to write Docker config.json: %w", err)
   136  	}
   137  
   138  	return targetPath, nil
   139  }
   140  
   141  // Client defines an docker client object
   142  type Client struct {
   143  	imageName     string
   144  	registryURL   string
   145  	localPath     string
   146  	includeLayers bool
   147  	imageFormat   string
   148  }
   149  
   150  // ClientOptions defines the options to be set on the client
   151  type ClientOptions struct {
   152  	ImageName   string
   153  	RegistryURL string
   154  	LocalPath   string
   155  	ImageFormat string
   156  }
   157  
   158  // Download interface for download an image to a local path
   159  type Download interface {
   160  	DownloadImage(imageSource, targetFile string) (v1.Image, error)
   161  	DownloadImageContent(imageSource, targetDir string) (v1.Image, error)
   162  	GetRemoteImageInfo(string) (v1.Image, error)
   163  }
   164  
   165  // SetOptions sets options used for the docker client
   166  func (c *Client) SetOptions(options ClientOptions) {
   167  	c.imageName = options.ImageName
   168  	c.registryURL = options.RegistryURL
   169  	c.localPath = options.LocalPath
   170  	c.imageFormat = options.ImageFormat
   171  }
   172  
   173  // DownloadImageContent downloads the image content into the given targetDir. Returns with an error if the targetDir doesnt exist
   174  func (c *Client) DownloadImageContent(imageSource, targetDir string) (v1.Image, error) {
   175  	if fileInfo, err := os.Stat(targetDir); err != nil {
   176  		return nil, err
   177  	} else if !fileInfo.IsDir() {
   178  		return nil, fmt.Errorf("specified target is not a directory: %s", targetDir)
   179  	}
   180  
   181  	noOpts := []crane.Option{}
   182  
   183  	imageRef, err := c.getImageRef(imageSource)
   184  	if err != nil {
   185  		return nil, err
   186  	}
   187  
   188  	img, err := crane.Pull(imageRef.Name(), noOpts...)
   189  	if err != nil {
   190  		return nil, err
   191  	}
   192  
   193  	tmpFile, err := os.CreateTemp(".", ".piper-download-")
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  	defer os.Remove(tmpFile.Name())
   198  
   199  	args := []string{imageRef.Name(), tmpFile.Name()}
   200  
   201  	exportCmd := cranecmd.NewCmdExport(&noOpts)
   202  	exportCmd.SetArgs(args)
   203  
   204  	if err := exportCmd.Execute(); err != nil {
   205  		return nil, err
   206  	}
   207  
   208  	return img, piperutils.Untar(tmpFile.Name(), targetDir, 0)
   209  }
   210  
   211  // DownloadImage downloads the image and saves it as tar at the given path
   212  func (c *Client) DownloadImage(imageSource, targetFile string) (v1.Image, error) {
   213  	noOpts := []crane.Option{}
   214  
   215  	imageRef, err := c.getImageRef(imageSource)
   216  	if err != nil {
   217  		return nil, err
   218  	}
   219  
   220  	img, err := crane.Pull(imageRef.Name(), noOpts...)
   221  	if err != nil {
   222  		return nil, err
   223  	}
   224  
   225  	tmpFile, err := os.CreateTemp(".", ".piper-download-")
   226  	if err != nil {
   227  		return nil, err
   228  	}
   229  
   230  	craneCmd := cranecmd.NewCmdPull(&noOpts)
   231  	craneCmd.SetOut(log.Writer())
   232  	craneCmd.SetErr(log.Writer())
   233  	craneCmd.SetArgs([]string{imageRef.Name(), tmpFile.Name(), "--format=" + c.imageFormat})
   234  
   235  	if err := craneCmd.Execute(); err != nil {
   236  		defer os.Remove(tmpFile.Name())
   237  		return nil, err
   238  	}
   239  
   240  	if err := os.Rename(tmpFile.Name(), targetFile); err != nil {
   241  		defer os.Remove(tmpFile.Name())
   242  		return nil, err
   243  	}
   244  
   245  	return img, nil
   246  }
   247  
   248  // GetRemoteImageInfo retrieves information about the image (e.g. digest) without actually downoading it
   249  func (c *Client) GetRemoteImageInfo(imageSource string) (v1.Image, error) {
   250  	ref, err := c.getImageRef(imageSource)
   251  	if err != nil {
   252  		return nil, errors.Wrap(err, "parsing image reference")
   253  	}
   254  
   255  	return remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
   256  }
   257  
   258  func (c *Client) getImageRef(image string) (name.Reference, error) {
   259  	opts := []name.Option{}
   260  	registry := ""
   261  
   262  	if len(c.registryURL) > 0 {
   263  		re := regexp.MustCompile(`(?i)^https?://`)
   264  		registry = re.ReplaceAllString(c.registryURL, "")
   265  		opts = append(opts, name.WithDefaultRegistry(registry))
   266  	}
   267  
   268  	return name.ParseReference(path.Join(registry, image), opts...)
   269  }
   270  
   271  // ImageListWithFilePath compiles container image names based on all Dockerfiles found, considering excludes
   272  // according to following search pattern: **/Dockerfile*
   273  // Return value contains a map with image names and file path
   274  // Examples for image names with imageName testImage
   275  // * Dockerfile: `imageName`
   276  // * sub1/Dockerfile: `imageName-sub1`
   277  // * sub2/Dockerfile_proxy: `imageName-sub2-proxy`
   278  func ImageListWithFilePath(imageName string, excludes []string, trimDir string, utils piperutils.FileUtils) (map[string]string, error) {
   279  
   280  	imageList := map[string]string{}
   281  
   282  	pattern := "**/Dockerfile*"
   283  
   284  	matches, err := utils.Glob(pattern)
   285  	if err != nil || len(matches) == 0 {
   286  		return imageList, fmt.Errorf("failed to retrieve Dockerfiles")
   287  	}
   288  
   289  	for _, dockerfilePath := range matches {
   290  		// make sure that the path we have is relative
   291  		// ToDo: needs rework
   292  		//dockerfilePath = strings.ReplaceAll(dockerfilePath, cwd, ".")
   293  
   294  		if piperutils.ContainsString(excludes, dockerfilePath) {
   295  			log.Entry().Infof("Discard %v since it is in the exclude list %v", dockerfilePath, excludes)
   296  			continue
   297  		}
   298  
   299  		if dockerfilePath == "Dockerfile" {
   300  			imageList[imageName] = dockerfilePath
   301  		} else {
   302  			var finalName string
   303  			if base := filepath.Base(dockerfilePath); base == "Dockerfile" {
   304  				subName := strings.ReplaceAll(filepath.Dir(dockerfilePath), string(filepath.Separator), "-")
   305  				if len(trimDir) > 0 {
   306  					// allow to remove trailing sub directories
   307  					// example .ci/app/Dockerfile
   308  					// with trimDir = .ci/ imagename would only contain app part.
   309  					subName = strings.TrimPrefix(subName, strings.ReplaceAll(trimDir, "/", "-"))
   310  					// make sure that subName does not start with a - (e.g. due not configuring trailing slash for trimDir)
   311  					subName = strings.TrimPrefix(subName, "-")
   312  				}
   313  				finalName = fmt.Sprintf("%v-%v", imageName, subName)
   314  			} else {
   315  				parts := strings.FieldsFunc(base, func(separator rune) bool {
   316  					return separator == []rune("-")[0] || separator == []rune("_")[0]
   317  				})
   318  				if len(parts) == 1 {
   319  					return imageList, fmt.Errorf("wrong format of Dockerfile, must be inside a sub-folder or contain a separator")
   320  				}
   321  				parts[0] = imageName
   322  				finalName = strings.Join(parts, "-")
   323  			}
   324  
   325  			imageList[finalName] = dockerfilePath
   326  		}
   327  	}
   328  
   329  	return imageList, nil
   330  }