kcl-lang.io/kpm@v0.8.7-0.20240520061008-9fc4c5efc8c7/pkg/oci/oci.go (about)

     1  package oci
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"log"
     9  	"os"
    10  	"path/filepath"
    11  	"reflect"
    12  	"runtime"
    13  	"strings"
    14  
    15  	"github.com/containers/image/v5/docker"
    16  	"github.com/containers/image/v5/types"
    17  	v1 "github.com/opencontainers/image-spec/specs-go/v1"
    18  	"github.com/thoas/go-funk"
    19  	"oras.land/oras-go/pkg/auth"
    20  	dockerauth "oras.land/oras-go/pkg/auth/docker"
    21  	remoteauth "oras.land/oras-go/v2/registry/remote/auth"
    22  
    23  	"kcl-lang.io/kpm/pkg/constants"
    24  	"kcl-lang.io/kpm/pkg/opt"
    25  	pkg "kcl-lang.io/kpm/pkg/package"
    26  	"kcl-lang.io/kpm/pkg/reporter"
    27  	"kcl-lang.io/kpm/pkg/semver"
    28  	"kcl-lang.io/kpm/pkg/settings"
    29  	"kcl-lang.io/kpm/pkg/utils"
    30  
    31  	"oras.land/oras-go/v2"
    32  	"oras.land/oras-go/v2/content"
    33  	"oras.land/oras-go/v2/content/file"
    34  	"oras.land/oras-go/v2/registry/remote"
    35  	"oras.land/oras-go/v2/registry/remote/errcode"
    36  	"oras.land/oras-go/v2/registry/remote/retry"
    37  )
    38  
    39  const OCI_SCHEME = "oci"
    40  const DEFAULT_OCI_ARTIFACT_TYPE = "application/vnd.oci.image.layer.v1.tar"
    41  const (
    42  	OciErrorCodeNameUnknown  = "NAME_UNKNOWN"
    43  	OciErrorCodeRepoNotFound = "NOT_FOUND"
    44  )
    45  
    46  // Login will login 'hostname' by 'username' and 'password'.
    47  func Login(hostname, username, password string, setting *settings.Settings) error {
    48  
    49  	authClient, err := dockerauth.NewClientWithDockerFallback(setting.CredentialsFile)
    50  
    51  	if err != nil {
    52  		return reporter.NewErrorEvent(
    53  			reporter.FailedLogin,
    54  			err,
    55  			fmt.Sprintf("failed to login '%s', please check registry, username and password is valid", hostname),
    56  		)
    57  	}
    58  
    59  	err = authClient.LoginWithOpts(
    60  		[]auth.LoginOption{
    61  			auth.WithLoginHostname(hostname),
    62  			auth.WithLoginUsername(username),
    63  			auth.WithLoginSecret(password),
    64  		}...,
    65  	)
    66  
    67  	if err != nil {
    68  		return reporter.NewErrorEvent(
    69  			reporter.FailedLogin,
    70  			err,
    71  			fmt.Sprintf("failed to login '%s', please check registry, username and password is valid", hostname),
    72  		)
    73  	}
    74  
    75  	return nil
    76  }
    77  
    78  // Logout will logout from registry.
    79  func Logout(hostname string, setting *settings.Settings) error {
    80  
    81  	authClient, err := dockerauth.NewClientWithDockerFallback(setting.CredentialsFile)
    82  
    83  	if err != nil {
    84  		return reporter.NewErrorEvent(reporter.FailedLogout, err, fmt.Sprintf("failed to logout '%s'", hostname))
    85  	}
    86  
    87  	err = authClient.Logout(context.Background(), hostname)
    88  
    89  	if err != nil {
    90  		return reporter.NewErrorEvent(reporter.FailedLogout, err, fmt.Sprintf("failed to logout '%s'", hostname))
    91  	}
    92  
    93  	return nil
    94  }
    95  
    96  // OciClient is mainly responsible for interacting with OCI registry
    97  type OciClient struct {
    98  	repo           *remote.Repository
    99  	ctx            *context.Context
   100  	logWriter      io.Writer
   101  	PullOciOptions *PullOciOptions
   102  }
   103  
   104  type PullOciOptions struct {
   105  	Platform string
   106  	CopyOpts *oras.CopyOptions
   107  }
   108  
   109  func (ociClient *OciClient) SetLogWriter(writer io.Writer) {
   110  	ociClient.logWriter = writer
   111  }
   112  
   113  func (ociClient *OciClient) GetReference() string {
   114  	return ociClient.repo.Reference.String()
   115  }
   116  
   117  // NewOciClient will new an OciClient.
   118  // regName is the registry. e.g. ghcr.io or docker.io.
   119  // repoName is the repo name on registry.
   120  func NewOciClient(regName, repoName string, settings *settings.Settings) (*OciClient, error) {
   121  	repoPath := utils.JoinPath(regName, repoName)
   122  	repo, err := remote.NewRepository(repoPath)
   123  
   124  	if err != nil {
   125  		return nil, reporter.NewErrorEvent(
   126  			reporter.RepoNotFound,
   127  			err,
   128  			fmt.Sprintf("repository '%s' not found", repoPath),
   129  		)
   130  	}
   131  	ctx := context.Background()
   132  	repo.PlainHTTP = settings.DefaultOciPlainHttp()
   133  
   134  	// Login
   135  	credential, err := loadCredential(regName, settings)
   136  	if err != nil {
   137  		return nil, reporter.NewErrorEvent(
   138  			reporter.FailedLoadCredential,
   139  			err,
   140  			fmt.Sprintf("failed to load credential for '%s' from '%s'.", regName, settings.CredentialsFile),
   141  		)
   142  	}
   143  	repo.Client = &remoteauth.Client{
   144  		Client:     retry.DefaultClient,
   145  		Cache:      remoteauth.DefaultCache,
   146  		Credential: remoteauth.StaticCredential(repo.Reference.Host(), *credential),
   147  	}
   148  
   149  	return &OciClient{
   150  		repo: repo,
   151  		ctx:  &ctx,
   152  		PullOciOptions: &PullOciOptions{
   153  			CopyOpts: &oras.CopyOptions{
   154  				CopyGraphOptions: oras.CopyGraphOptions{
   155  					MaxMetadataBytes: DEFAULT_LIMIT_STORE_SIZE, // default is 64 MiB
   156  				},
   157  			},
   158  		},
   159  	}, nil
   160  }
   161  
   162  // The default limit of the store size is 64 MiB.
   163  const DEFAULT_LIMIT_STORE_SIZE = 64 * 1024 * 1024
   164  
   165  // Pull will pull the oci artifacts from oci registry to local path.
   166  func (ociClient *OciClient) Pull(localPath, tag string) error {
   167  	// Create a file store
   168  	fs, err := file.NewWithFallbackLimit(localPath, DEFAULT_LIMIT_STORE_SIZE)
   169  	if err != nil {
   170  		return reporter.NewErrorEvent(reporter.FailedCreateStorePath, err, "Failed to create store path ", localPath)
   171  	}
   172  	defer fs.Close()
   173  	copyOpts := ociClient.PullOciOptions.CopyOpts
   174  	copyOpts.FindSuccessors = ociClient.PullOciOptions.Successors
   175  	_, err = oras.Copy(*ociClient.ctx, ociClient.repo, tag, fs, tag, *copyOpts)
   176  	if err != nil {
   177  		return reporter.NewErrorEvent(
   178  			reporter.FailedGetPkg,
   179  			err,
   180  			fmt.Sprintf("failed to get package with '%s' from '%s'", tag, ociClient.repo.Reference.String()),
   181  		)
   182  	}
   183  
   184  	return nil
   185  }
   186  
   187  // TheLatestTag will return the latest tag of the kcl packages.
   188  func (ociClient *OciClient) TheLatestTag() (string, error) {
   189  	var tagSelected string
   190  
   191  	err := ociClient.repo.Tags(*ociClient.ctx, "", func(tags []string) error {
   192  		var err error
   193  		tagSelected, err = semver.LatestVersion(tags)
   194  		if err != nil {
   195  			return err
   196  		}
   197  
   198  		return nil
   199  	})
   200  
   201  	if err != nil {
   202  		return "", reporter.NewErrorEvent(
   203  			reporter.FailedSelectLatestVersion,
   204  			err,
   205  			fmt.Sprintf("failed to select latest version from '%s'", ociClient.repo.Reference.String()),
   206  		)
   207  	}
   208  
   209  	return tagSelected, nil
   210  }
   211  
   212  // RepoIsNotExist will check if the error is caused by the repo not found.
   213  func RepoIsNotExist(err error) bool {
   214  	errRes, ok := err.(*errcode.ErrorResponse)
   215  	if ok {
   216  		if len(errRes.Errors) == 1 &&
   217  			// docker.io and gchr.io will return NAME_UNKNOWN
   218  			(errRes.Errors[0].Code == OciErrorCodeNameUnknown ||
   219  				// harbor will return NOT_FOUND
   220  				errRes.Errors[0].Code == OciErrorCodeRepoNotFound) {
   221  			return true
   222  		}
   223  	}
   224  	return false
   225  }
   226  
   227  // ContainsTag will check if the tag exists in the repo.
   228  func (ociClient *OciClient) ContainsTag(tag string) (bool, *reporter.KpmEvent) {
   229  	var exists bool
   230  
   231  	err := ociClient.repo.Tags(*ociClient.ctx, "", func(tags []string) error {
   232  		exists = funk.ContainsString(tags, tag)
   233  		return nil
   234  	})
   235  
   236  	if err != nil {
   237  		// If the repo with tag is not found, return false.
   238  		if RepoIsNotExist(err) {
   239  			return false, nil
   240  		}
   241  		// If the user not login, return error.
   242  		return false, reporter.NewErrorEvent(
   243  			reporter.FailedGetPackageVersions,
   244  			err,
   245  			fmt.Sprintf("failed to access '%s'", ociClient.repo.Reference.String()),
   246  		)
   247  	}
   248  
   249  	return exists, nil
   250  }
   251  
   252  // Push will push the oci artifacts to oci registry from local path
   253  func (ociClient *OciClient) Push(localPath, tag string) *reporter.KpmEvent {
   254  	return ociClient.PushWithOciManifest(localPath, tag, &opt.OciManifestOptions{})
   255  }
   256  
   257  // PushWithManifest will push the oci artifacts to oci registry from local path
   258  func (ociClient *OciClient) PushWithOciManifest(localPath, tag string, opts *opt.OciManifestOptions) *reporter.KpmEvent {
   259  	// 0. Create a file store
   260  	fs, err := file.New(filepath.Dir(localPath))
   261  	if err != nil {
   262  		return reporter.NewErrorEvent(reporter.FailedPush, err, "Failed to load store path ", localPath)
   263  	}
   264  	defer fs.Close()
   265  
   266  	// 1. Add files to a file store
   267  
   268  	fileNames := []string{localPath}
   269  	fileDescriptors := make([]v1.Descriptor, 0, len(fileNames))
   270  	for _, name := range fileNames {
   271  		// The file name of the pushed file cannot be a file path,
   272  		// If the file name is a path, the path will be created during pulling.
   273  		// During pulling, a file should be downloaded separately,
   274  		// and a file path is created for each download, which is not good.
   275  		fileDescriptor, err := fs.Add(*ociClient.ctx, filepath.Base(name), DEFAULT_OCI_ARTIFACT_TYPE, "")
   276  		if err != nil {
   277  			return reporter.NewErrorEvent(reporter.FailedPush, err, fmt.Sprintf("Failed to add file '%s'", name))
   278  		}
   279  		fileDescriptors = append(fileDescriptors, fileDescriptor)
   280  	}
   281  
   282  	// 2. Pack the files, tag the packed manifest and add metadata as annotations
   283  	packOpts := oras.PackManifestOptions{
   284  		ManifestAnnotations: opts.Annotations,
   285  		Layers:              fileDescriptors,
   286  	}
   287  	manifestDescriptor, err := oras.PackManifest(*ociClient.ctx, fs, oras.PackManifestVersion1_1_RC4, DEFAULT_OCI_ARTIFACT_TYPE, packOpts)
   288  
   289  	if err != nil {
   290  		return reporter.NewErrorEvent(reporter.FailedPush, err, fmt.Sprintf("failed to pack package in '%s'", localPath))
   291  	}
   292  
   293  	if err = fs.Tag(*ociClient.ctx, manifestDescriptor, tag); err != nil {
   294  		return reporter.NewErrorEvent(reporter.FailedPush, err, fmt.Sprintf("failed to tag package with tag '%s'", tag))
   295  	}
   296  
   297  	// 3. Copy from the file store to the remote repository
   298  	desc, err := oras.Copy(*ociClient.ctx, fs, tag, ociClient.repo, tag, oras.DefaultCopyOptions)
   299  
   300  	if err != nil {
   301  		return reporter.NewErrorEvent(reporter.FailedPush, err, fmt.Sprintf("failed to push '%s'", ociClient.repo.Reference))
   302  	}
   303  
   304  	reporter.ReportMsgTo(fmt.Sprintf("pushed [registry] %s", ociClient.repo.Reference), ociClient.logWriter)
   305  	reporter.ReportMsgTo(fmt.Sprintf("digest: %s", desc.Digest), ociClient.logWriter)
   306  	return nil
   307  }
   308  
   309  // FetchManifestByRef will fetch the manifest and return it into json string.
   310  func (ociClient *OciClient) FetchManifestIntoJsonStr(opts opt.OciFetchOptions) (string, error) {
   311  	fetchOpts := opts.FetchBytesOptions
   312  	_, manifestContent, err := oras.FetchBytes(*ociClient.ctx, ociClient.repo, opts.Tag, fetchOpts)
   313  	if err != nil {
   314  		return "", err
   315  	}
   316  
   317  	return string(manifestContent), nil
   318  }
   319  
   320  func loadCredential(hostName string, settings *settings.Settings) (*remoteauth.Credential, error) {
   321  	authClient, err := dockerauth.NewClientWithDockerFallback(settings.CredentialsFile)
   322  	if err != nil {
   323  		return nil, err
   324  	}
   325  	dockerClient, _ := authClient.(*dockerauth.Client)
   326  	username, password, err := dockerClient.Credential(hostName)
   327  	if err != nil {
   328  		return nil, err
   329  	}
   330  
   331  	return &remoteauth.Credential{
   332  		Username: username,
   333  		Password: password,
   334  	}, nil
   335  }
   336  
   337  // Pull will pull the oci artifacts from oci registry to local path.
   338  func Pull(localPath, hostName, repoName, tag string, settings *settings.Settings) error {
   339  	ociClient, err := NewOciClient(hostName, repoName, settings)
   340  	if err != nil {
   341  		return err
   342  	}
   343  
   344  	var tagSelected string
   345  	if len(tag) == 0 {
   346  		tagSelected, err = ociClient.TheLatestTag()
   347  		if err != nil {
   348  			return err
   349  		}
   350  		reporter.ReportMsgTo(
   351  			fmt.Sprintf("the lastest version '%s' will be pulled", tagSelected),
   352  			os.Stdout,
   353  		)
   354  	} else {
   355  		tagSelected = tag
   356  	}
   357  
   358  	reporter.ReportEventToStdout(
   359  		reporter.NewEvent(
   360  			reporter.Pulling,
   361  			fmt.Sprintf("pulling '%s:%s' from '%s'.", repoName, tagSelected, utils.JoinPath(hostName, repoName)),
   362  		),
   363  	)
   364  	return ociClient.Pull(localPath, tagSelected)
   365  }
   366  
   367  // Push will push the oci artifacts to oci registry from local path
   368  func Push(localPath, hostName, repoName, tag string, settings *settings.Settings) error {
   369  	// Create an oci client.
   370  	ociClient, err := NewOciClient(hostName, repoName, settings)
   371  	if err != nil {
   372  		return err
   373  	}
   374  
   375  	exist, err := ociClient.ContainsTag(tag)
   376  	if err != (*reporter.KpmEvent)(nil) {
   377  		return err
   378  	}
   379  
   380  	if exist {
   381  		return reporter.NewErrorEvent(
   382  			reporter.PkgTagExists,
   383  			fmt.Errorf("package version '%s' already exists", tag),
   384  		)
   385  	}
   386  
   387  	// Push the oci package by the oci client.
   388  	return ociClient.Push(localPath, tag)
   389  }
   390  
   391  // GenOciManifestFromPkg will generate the oci manifest from the kcl package.
   392  func GenOciManifestFromPkg(kclPkg *pkg.KclPkg) (map[string]string, error) {
   393  	res := make(map[string]string)
   394  	res[constants.DEFAULT_KCL_OCI_MANIFEST_NAME] = kclPkg.GetPkgName()
   395  	res[constants.DEFAULT_KCL_OCI_MANIFEST_VERSION] = kclPkg.GetPkgVersion()
   396  	res[constants.DEFAULT_KCL_OCI_MANIFEST_DESCRIPTION] = kclPkg.GetPkgDescription()
   397  	sum, err := kclPkg.GenCheckSum()
   398  	if err != nil {
   399  		return nil, err
   400  	}
   401  	res[constants.DEFAULT_KCL_OCI_MANIFEST_SUM] = sum
   402  	return res, nil
   403  }
   404  
   405  func GetAllImageTags(imageName string) ([]string, error) {
   406  	sysCtx := &types.SystemContext{}
   407  	ref, err := docker.ParseReference("//" + strings.TrimPrefix(imageName, "oci://"))
   408  	if err != nil {
   409  		log.Fatalf("Error parsing reference: %v", err)
   410  	}
   411  
   412  	tags, err := docker.GetRepositoryTags(context.Background(), sysCtx, ref)
   413  	if err != nil {
   414  		log.Fatalf("Error getting tags: %v", err)
   415  	}
   416  	return tags, nil
   417  }
   418  
   419  const (
   420  	MediaTypeConfig           = "application/vnd.docker.container.image.v1+json"
   421  	MediaTypeManifestList     = "application/vnd.docker.distribution.manifest.list.v2+json"
   422  	MediaTypeManifest         = "application/vnd.docker.distribution.manifest.v2+json"
   423  	MediaTypeForeignLayer     = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip"
   424  	MediaTypeArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json"
   425  )
   426  
   427  // Successors returns the nodes directly pointed by the current node.
   428  // In other words, returns the "children" of the current descriptor.
   429  func (popts *PullOciOptions) Successors(ctx context.Context, fetcher content.Fetcher, node v1.Descriptor) ([]v1.Descriptor, error) {
   430  	switch node.MediaType {
   431  	case v1.MediaTypeImageManifest:
   432  		content, err := content.FetchAll(ctx, fetcher, node)
   433  		if err != nil {
   434  			return nil, err
   435  		}
   436  		var manifest v1.Manifest
   437  		if err := json.Unmarshal(content, &manifest); err != nil {
   438  			return nil, err
   439  		}
   440  		var nodes []v1.Descriptor
   441  		if manifest.Subject != nil {
   442  			nodes = append(nodes, *manifest.Subject)
   443  		}
   444  		nodes = append(nodes, manifest.Config)
   445  		return append(nodes, manifest.Layers...), nil
   446  	case v1.MediaTypeImageIndex:
   447  		content, err := content.FetchAll(ctx, fetcher, node)
   448  		if err != nil {
   449  			return nil, err
   450  		}
   451  
   452  		var index v1.Index
   453  		if err := json.Unmarshal(content, &index); err != nil {
   454  			return nil, err
   455  		}
   456  		var nodes []v1.Descriptor
   457  		if index.Subject != nil {
   458  			nodes = append(nodes, *index.Subject)
   459  		}
   460  
   461  		for _, manifest := range index.Manifests {
   462  			if manifest.Platform != nil && len(popts.Platform) != 0 {
   463  				pullPlatform, err := ParsePlatform(popts.Platform)
   464  				if err != nil {
   465  					return nil, err
   466  				}
   467  				if !reflect.DeepEqual(manifest.Platform, pullPlatform) {
   468  					continue
   469  				} else {
   470  					nodes = append(nodes, manifest)
   471  				}
   472  			} else {
   473  				nodes = append(nodes, manifest)
   474  			}
   475  		}
   476  		return nodes, nil
   477  	}
   478  	return nil, nil
   479  }
   480  
   481  func ParsePlatform(platform string) (*v1.Platform, error) {
   482  	// OS[/Arch[/Variant]][:OSVersion]
   483  	// If Arch is not provided, will use GOARCH instead
   484  	var platformStr string
   485  	var p v1.Platform
   486  	platformStr, p.OSVersion, _ = strings.Cut(platform, ":")
   487  	parts := strings.Split(platformStr, "/")
   488  	switch len(parts) {
   489  	case 3:
   490  		p.Variant = parts[2]
   491  		fallthrough
   492  	case 2:
   493  		p.Architecture = parts[1]
   494  	case 1:
   495  		p.Architecture = runtime.GOARCH
   496  	default:
   497  		return nil, fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", platform)
   498  	}
   499  	p.OS = parts[0]
   500  	if p.OS == "" {
   501  		return nil, fmt.Errorf("invalid platform: OS cannot be empty")
   502  	}
   503  	if p.Architecture == "" {
   504  		return nil, fmt.Errorf("invalid platform: Architecture cannot be empty")
   505  	}
   506  
   507  	return &p, nil
   508  }