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 }