diff --git a/api/v1alpha1/imagebuild_types.go b/api/v1alpha1/imagebuild_types.go index 284258f1..5780b39c 100644 --- a/api/v1alpha1/imagebuild_types.go +++ b/api/v1alpha1/imagebuild_types.go @@ -47,6 +47,7 @@ func IsTerminalBuildPhase(phase string) bool { // ImageBuildSpec defines the desired state of ImageBuild // +kubebuilder:printcolumn:name="StorageClass",type=string,JSONPath=`.spec.storageClass` +// +kubebuilder:validation:XValidation:rule="!has(self.reproducible) || !self.reproducible || self.secureBuild",message="reproducible builds require secureBuild to be true" type ImageBuildSpec struct { // ─── Common fields ─── @@ -102,6 +103,18 @@ type ImageBuildSpec struct { // +optional TaskBundleRef string `json:"taskBundleRef,omitempty"` + // Reproducible enables full build provenance: saves RPMs, AIB manifest, + // and task bundle ref as OCI referrers for future reproduction. + // Requires SecureBuild to be true for task bundle pinning. + // +optional + Reproducible bool `json:"reproducible,omitempty"` + + // RestoreSourcesRef is the OCI image reference from a prior reproducible build. + // The build pod will pull the sources archive (OCI referrer) attached to this + // image and pre-populate the osbuild store, ensuring identical RPM inputs. + // +optional + RestoreSourcesRef string `json:"restoreSourcesRef,omitempty"` + // TTL is the time-to-live for this build. After this duration past its // completion, the build transitions to the Expired phase and its resources // (PipelineRuns, TaskRuns, PVCs, registry images) are cleaned up. diff --git a/api/v1alpha1/labels.go b/api/v1alpha1/labels.go index b86dd596..90551469 100644 --- a/api/v1alpha1/labels.go +++ b/api/v1alpha1/labels.go @@ -10,6 +10,7 @@ const ( LabelWorkspaceName = "automotive.sdv.cloud.redhat.com/workspace-name" LabelOwner = "automotive.sdv.cloud.redhat.com/owner" - AnnotationTraceID = "automotive.sdv.cloud.redhat.com/trace-id" - AnnotationRequestedBy = "automotive.sdv.cloud.redhat.com/requested-by" + AnnotationTraceID = "automotive.sdv.cloud.redhat.com/trace-id" + AnnotationRequestedBy = "automotive.sdv.cloud.redhat.com/requested-by" + AnnotationTaskBundleRef = "automotive.sdv.cloud.redhat.com/task-bundle-ref" ) diff --git a/cmd/caib/buildcmd/build.go b/cmd/caib/buildcmd/build.go index e4c61120..4d2682af 100644 --- a/cmd/caib/buildcmd/build.go +++ b/cmd/caib/buildcmd/build.go @@ -75,8 +75,11 @@ type Options struct { InternalRegistryImageName *string InternalRegistryTag *string - SecureBuild *bool - TTL *string + SecureBuild *bool + Reproducible *bool + TaskBundleRef *string + RestoreSourcesRef *string + TTL *string InsecureSkipTLS *bool @@ -143,6 +146,10 @@ func (h *Handler) validateBootcBuildFlags() error { } } + if err := h.validateReproducibleFlags(); err != nil { + return err + } + if *h.opts.ContainerPush == "" && !*h.opts.BuildDiskImage && !*h.opts.UseInternalRegistry { return fmt.Errorf( "--push is required when not building a disk image " + @@ -153,6 +160,16 @@ func (h *Handler) validateBootcBuildFlags() error { return nil } +func (h *Handler) validateReproducibleFlags() error { + if err := common.ValidateReproducibleRequiresSecure(*h.opts.Reproducible, *h.opts.SecureBuild); err != nil { + return err + } + if *h.opts.Reproducible && *h.opts.UseInternalRegistry { + return fmt.Errorf("--reproducible cannot be used with --internal-registry (internal registry does not support OCI referrers)") + } + return nil +} + // applyRegistryCredentialsToRequest sets registry credentials on the build request. // When --internal-registry is combined with --push, both are configured so the // container is pushed externally while the disk image uses the internal registry. @@ -474,6 +491,9 @@ func (h *Handler) RunBuild(cmd *cobra.Command, args []string) { BuilderImage: *h.opts.BuilderImage, RebuildBuilder: *h.opts.RebuildBuilder, SecureBuild: *h.opts.SecureBuild, + Reproducible: *h.opts.Reproducible, + TaskBundleRef: *h.opts.TaskBundleRef, + RestoreSourcesRef: *h.opts.RestoreSourcesRef, TTL: *h.opts.TTL, } @@ -587,6 +607,8 @@ func (h *Handler) RunDisk(cmd *cobra.Command, args []string) { Compression: buildapitypes.Compression(*h.opts.CompressionAlgo), ExportOCI: *h.opts.ExportOCI, SecureBuild: *h.opts.SecureBuild, + TaskBundleRef: *h.opts.TaskBundleRef, + RestoreSourcesRef: *h.opts.RestoreSourcesRef, TTL: *h.opts.TTL, } @@ -643,6 +665,11 @@ func (h *Handler) RunBuildDev(cmd *cobra.Command, args []string) { return } + if err := h.validateReproducibleFlags(); err != nil { + h.handleError(err) + return + } + if *h.opts.UseInternalRegistry { if *h.opts.ExportOCI != "" { h.handleError(fmt.Errorf("--internal-registry cannot be used with --push")) @@ -723,6 +750,9 @@ func (h *Handler) RunBuildDev(cmd *cobra.Command, args []string) { Compression: buildapitypes.Compression(*h.opts.CompressionAlgo), ExportOCI: *h.opts.ExportOCI, SecureBuild: *h.opts.SecureBuild, + Reproducible: *h.opts.Reproducible, + TaskBundleRef: *h.opts.TaskBundleRef, + RestoreSourcesRef: *h.opts.RestoreSourcesRef, TTL: *h.opts.TTL, } diff --git a/cmd/caib/buildcmd/build_disk_test.go b/cmd/caib/buildcmd/build_disk_test.go index 1afd4c50..6ff5e47f 100644 --- a/cmd/caib/buildcmd/build_disk_test.go +++ b/cmd/caib/buildcmd/build_disk_test.go @@ -46,6 +46,9 @@ func newTestDiskOpts() Options { internalRegImageName string internalRegTag string secureBuild bool + reproducible bool + taskBundleRef string + restoreSourcesRef string buildTTL string insecureSkipTLS bool ) @@ -86,6 +89,9 @@ func newTestDiskOpts() Options { InternalRegistryImageName: &internalRegImageName, InternalRegistryTag: &internalRegTag, SecureBuild: &secureBuild, + Reproducible: &reproducible, + TaskBundleRef: &taskBundleRef, + RestoreSourcesRef: &restoreSourcesRef, TTL: &buildTTL, InsecureSkipTLS: &insecureSkipTLS, } diff --git a/cmd/caib/common/build_validation.go b/cmd/caib/common/build_validation.go index c1fdf19b..7ca26013 100644 --- a/cmd/caib/common/build_validation.go +++ b/cmd/caib/common/build_validation.go @@ -46,6 +46,15 @@ func ValidateBuildName(name string) error { return nil } +// ValidateReproducibleRequiresSecure returns an error when reproducible builds +// are requested without secure build mode. +func ValidateReproducibleRequiresSecure(reproducible, secureBuild bool) error { + if reproducible && !secureBuild { + return fmt.Errorf("--reproducible requires --secure for task bundle pinning") + } + return nil +} + // ValidateManifestSuffix validates the manifest file extension. func ValidateManifestSuffix(filename string) error { for _, suffix := range validManifestSuffix { diff --git a/cmd/caib/common/oci_artifact.go b/cmd/caib/common/oci_artifact.go index 748e7cae..a2e8db49 100644 --- a/cmd/caib/common/oci_artifact.go +++ b/cmd/caib/common/oci_artifact.go @@ -18,7 +18,7 @@ import ( ) // PullOCIArtifact pulls and extracts an OCI artifact to local destination. -func PullOCIArtifact(ociRef, destPath, username, password string, insecureSkipTLS bool) error { +func PullOCIArtifact(ociRef, destPath, username, password string, insecureSkipTLS bool, authFilePaths ...string) error { fmt.Printf("Pulling OCI artifact %s to %s\n", ociRef, destPath) destDir := filepath.Dir(destPath) @@ -30,6 +30,9 @@ func PullOCIArtifact(ociRef, destPath, username, password string, insecureSkipTL ctx := context.Background() systemCtx := &types.SystemContext{} + if len(authFilePaths) > 0 && authFilePaths[0] != "" { + systemCtx.AuthFilePath = authFilePaths[0] + } if username != "" && password != "" { fmt.Printf("Using provided username/password credentials\n") systemCtx.DockerAuthConfig = &types.DockerAuthConfig{ diff --git a/cmd/caib/image/image.go b/cmd/caib/image/image.go index 9c956e9b..bb978af3 100644 --- a/cmd/caib/image/image.go +++ b/cmd/caib/image/image.go @@ -27,6 +27,7 @@ type Options struct { RunToken func(*cobra.Command, []string) RunDelete func(*cobra.Command, []string) RunCancel func(*cobra.Command, []string) + RunInspect func(*cobra.Command, []string) GetDefaultArch func() string @@ -70,8 +71,11 @@ type Options struct { InternalRegistryImageName *string InternalRegistryTag *string - SecureBuild *bool - TTL *string + SecureBuild *bool + Reproducible *bool + TaskBundleRef *string + RestoreSourcesRef *string + TTL *string SealedBuilderImage *string SealedArchitecture *string @@ -164,6 +168,10 @@ func NewImageCmd(opts Options) *cobra.Command { // Secure build buildCmd.Flags().BoolVar(opts.SecureBuild, "secure", false, "resolve tasks from signed Tekton Bundle (requires OperatorConfig taskBundleRef)") buildCmd.Flags().StringVar(opts.TTL, "ttl", "", "time-to-live for the build (e.g. 24h, 72h, 168h); empty=server default, 0=no expiry") + // Reproducible build + buildCmd.Flags().BoolVar(opts.Reproducible, "reproducible", false, "save RPMs, manifest, and task bundle for future reproduction (requires --secure)") + buildCmd.Flags().StringVar(opts.TaskBundleRef, "task-bundle-ref", "", "digest-pinned Tekton bundle ref for reproducible rebuild (e.g. quay.io/org/tasks@sha256:abc...)") + buildCmd.Flags().StringVar(opts.RestoreSourcesRef, "restore-sources", "", "OCI image ref from prior build — restores archived sources for exact reproducible rebuild") // Internal registry options buildCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") buildCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -222,6 +230,7 @@ func NewImageCmd(opts Options) *cobra.Command { // Secure build diskCmd.Flags().BoolVar(opts.SecureBuild, "secure", false, "resolve tasks from signed Tekton Bundle (requires OperatorConfig taskBundleRef)") diskCmd.Flags().StringVar(opts.TTL, "ttl", "", "time-to-live for the build (e.g. 24h, 72h, 168h); empty=server default, 0=no expiry") + diskCmd.Flags().StringVar(opts.TaskBundleRef, "task-bundle-ref", "", "digest-pinned Tekton bundle ref for reproducible rebuild (e.g. quay.io/org/tasks@sha256:abc...)") // Internal registry options diskCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") diskCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -268,6 +277,10 @@ func NewImageCmd(opts Options) *cobra.Command { // Secure build buildDevCmd.Flags().BoolVar(opts.SecureBuild, "secure", false, "resolve tasks from signed Tekton Bundle (requires OperatorConfig taskBundleRef)") buildDevCmd.Flags().StringVar(opts.TTL, "ttl", "", "time-to-live for the build (e.g. 24h, 72h, 168h); empty=server default, 0=no expiry") + // Reproducible build + buildDevCmd.Flags().BoolVar(opts.Reproducible, "reproducible", false, "save RPMs, manifest, and task bundle for future reproduction (requires --secure)") + buildDevCmd.Flags().StringVar(opts.TaskBundleRef, "task-bundle-ref", "", "digest-pinned Tekton bundle ref for reproducible rebuild (e.g. quay.io/org/tasks@sha256:abc...)") + buildDevCmd.Flags().StringVar(opts.RestoreSourcesRef, "restore-sources", "", "OCI image ref from prior build — restores archived sources for exact reproducible rebuild") // Internal registry options buildDevCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") buildDevCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -313,6 +326,15 @@ func NewImageCmd(opts Options) *cobra.Command { ) flashCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow flash logs (shows full log output instead of progress bar)") flashCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", true, "wait for flash to complete") + inspectCmd := newInspectCmd(opts) + inspectCmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for registry authentication", + ) + inspectCmd.Flags().StringVarP(opts.OutputDir, "output-dir", "o", "", "download referrer artifacts (manifest, RPMs) to this directory") + // Sealed operation shared flags addSealedFlags(prepareResealCmd, opts, defaultServer) addSealedFlags(resealCmd, opts, defaultServer) @@ -332,6 +354,7 @@ func NewImageCmd(opts Options) *cobra.Command { deleteCmd, cancelCmd, flashCmd, + inspectCmd, prepareResealCmd, resealCmd, extractForSigningCmd, @@ -559,6 +582,28 @@ Examples: } } +func newInspectCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "inspect ", + Short: "Show build provenance and reproducibility info for an OCI artifact", + Long: `Inspect reads OCI manifest annotations and referrer artifacts to display +build provenance information: distro, target, architecture, builder versions, +and the exact command to reproduce the build. + +If --output-dir is given, referrer artifacts (AIB manifest, RPM archive, +osbuild manifest) are downloaded to the specified directory. + +Examples: + # Show build provenance + caib image inspect quay.io/org/my-os:v1 + + # Show provenance and download artifacts for reproduction + caib image inspect quay.io/org/my-os:v1 -o ./rebuild/`, + Args: cobra.ExactArgs(1), + Run: opts.RunInspect, + } +} + func newPrepareResealCmd(opts Options) *cobra.Command { return &cobra.Command{ Use: "prepare-reseal [source-container] [output-container]", diff --git a/cmd/caib/inspectcmd/inspect.go b/cmd/caib/inspectcmd/inspect.go new file mode 100644 index 00000000..039d6719 --- /dev/null +++ b/cmd/caib/inspectcmd/inspect.go @@ -0,0 +1,450 @@ +// Package inspectcmd provides the image inspect handler for build provenance. +package inspectcmd + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/types" + "github.com/fatih/color" + godigest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + + caibcommon "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" +) + +const annotationPrefix = "automotive.sdv.cloud.redhat.com/" + +var knownAnnotations = []struct { + key string + label string +}{ + {"distro", "Distro"}, + {"target", "Target"}, + {"arch", "Arch"}, + {"automotive-image-builder", "AIB Image"}, + {"builder-image", "Builder Image"}, + {"aib-version", "AIB Version"}, + {"task-bundle-ref", "Task Bundle"}, + {"custom-defines", "Custom Defines"}, + {"aib-extra-args", "AIB Extra Args"}, + {"export-format", "Export Format"}, + {"aib-command", "AIB Command"}, +} + +var knownReferrerTypes = []struct { + artifactType string + label string +}{ + {"application/vnd.automotive.manifest.v1+yaml", "AIB Manifest"}, + {"application/vnd.automotive.sources.v1+tar+gzip", "Build Sources"}, + {"application/vnd.osbuild.manifest.v1+json", "osbuild Manifest"}, +} + +// Options wires inspect handler dependencies. +type Options struct { + RegistryAuthFile *string + OutputDir *string + OutputFormat *string + InsecureSkipTLS *bool + + HandleError func(error) +} + +type provenanceOutput struct { + Reference string `json:"reference" yaml:"reference"` + Digest string `json:"digest" yaml:"digest"` + Annotations map[string]string `json:"annotations" yaml:"annotations"` + Referrers []referrerInfo `json:"referrers" yaml:"referrers"` + RebuildCmd string `json:"rebuildCommand" yaml:"rebuildCommand"` +} + +// Handler implements the inspect command. +type Handler struct { + opts Options +} + +// NewHandler creates an inspect handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} + +func (h *Handler) supportsColor() bool { + return caibcommon.SupportsColorOutput() +} + +// RunInspect handles `caib image inspect `. +func (h *Handler) RunInspect(_ *cobra.Command, args []string) { + ociRef := args[0] + + sysCtx := &types.SystemContext{} + if h.opts.InsecureSkipTLS != nil && *h.opts.InsecureSkipTLS { + sysCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue + } + + _, username, password := registryauth.ExtractRegistryCredentials(ociRef, "") + if h.opts.RegistryAuthFile != nil && *h.opts.RegistryAuthFile != "" { + sysCtx.AuthFilePath = *h.opts.RegistryAuthFile + } + if username != "" && password != "" { + sysCtx.DockerAuthConfig = &types.DockerAuthConfig{ + Username: username, + Password: password, + } + } + + annotations, digest, err := readManifestAnnotations(ociRef, sysCtx) + if err != nil { + h.handleError(fmt.Errorf("read manifest from %s: %w", ociRef, err)) + return + } + + referrers, err := discoverReferrers(ociRef, digest, sysCtx) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not discover referrers: %v\n", err) + } + + referrerTypes := make(map[string]bool) + for _, r := range referrers { + referrerTypes[r.ArtifactType] = true + } + + format := "" + if h.opts.OutputFormat != nil { + format = strings.ToLower(strings.TrimSpace(*h.opts.OutputFormat)) + } + + switch format { + case "json", "yaml", "yml": + h.printStructured(format, ociRef, digest, annotations, referrers, referrerTypes) + default: + h.printProvenance(ociRef, digest, annotations, referrers, referrerTypes) + } + + if h.opts.OutputDir != nil && *h.opts.OutputDir != "" { + authFile := "" + if h.opts.RegistryAuthFile != nil { + authFile = *h.opts.RegistryAuthFile + } + h.downloadReferrers(ociRef, digest, referrers, *h.opts.OutputDir, username, password, authFile) + } +} + +func (h *Handler) printStructured(format, ociRef, digest string, annotations map[string]string, referrers []referrerInfo, referrerTypes map[string]bool) { + stripped := make(map[string]string) + for k, v := range annotations { + if strings.HasPrefix(k, annotationPrefix) { + stripped[strings.TrimPrefix(k, annotationPrefix)] = v + } + } + + out := provenanceOutput{ + Reference: ociRef, + Digest: digest, + Annotations: stripped, + Referrers: referrers, + RebuildCmd: buildRebuildCommand(ociRef, digest, annotations, referrerTypes), + } + + var data []byte + var err error + if format == "json" { + data, err = json.MarshalIndent(out, "", " ") + } else { + data, err = yaml.Marshal(out) + } + if err != nil { + h.handleError(fmt.Errorf("marshal output: %w", err)) + return + } + fmt.Println(string(data)) +} + +func readManifestAnnotations(ociRef string, sysCtx *types.SystemContext) (map[string]string, string, error) { + ref, err := docker.ParseReference("//" + ociRef) + if err != nil { + return nil, "", fmt.Errorf("parse reference: %w", err) + } + + ctx := context.Background() + src, err := ref.NewImageSource(ctx, sysCtx) + if err != nil { + return nil, "", fmt.Errorf("open image source: %w", err) + } + defer func() { _ = src.Close() }() + + rawManifest, _, err := src.GetManifest(ctx, nil) + if err != nil { + return nil, "", fmt.Errorf("get manifest: %w", err) + } + + digest, err := manifest.Digest(rawManifest) + if err != nil { + return nil, "", fmt.Errorf("compute digest: %w", err) + } + + var parsed struct { + Annotations map[string]string `json:"annotations"` + } + if err := json.Unmarshal(rawManifest, &parsed); err != nil { + return nil, "", fmt.Errorf("parse manifest JSON: %w", err) + } + + return parsed.Annotations, string(digest), nil +} + +type referrerInfo struct { + ArtifactType string `json:"artifactType" yaml:"artifactType"` + Digest string `json:"digest" yaml:"digest"` +} + +func discoverReferrers(ociRef, digest string, sysCtx *types.SystemContext) ([]referrerInfo, error) { + repoName := splitReference(ociRef) + if repoName == "" { + return nil, fmt.Errorf("could not parse repository from %s", ociRef) + } + + repo, err := remote.NewRepository(repoName) + if err != nil { + return nil, fmt.Errorf("parse repository: %w", err) + } + + authClient := &auth.Client{} + if sysCtx.DockerAuthConfig != nil && sysCtx.DockerAuthConfig.Username != "" { + authClient.Credential = auth.StaticCredential(repo.Reference.Host(), auth.Credential{ + Username: sysCtx.DockerAuthConfig.Username, + Password: sysCtx.DockerAuthConfig.Password, + }) + } else if sysCtx.AuthFilePath != "" { + store, err := credentials.NewFileStore(sysCtx.AuthFilePath) + if err != nil { + return nil, fmt.Errorf("load auth file %s: %w", sysCtx.AuthFilePath, err) + } + authClient.Credential = credentials.Credential(store) + } + if sysCtx.DockerInsecureSkipTLSVerify == types.OptionalBoolTrue { + authClient.Client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // user explicitly opted in + }, + }, + } + } + repo.Client = authClient + + dgst, err := godigest.Parse(digest) + if err != nil { + return nil, fmt.Errorf("parse digest: %w", err) + } + + var result []referrerInfo + err = repo.Referrers(context.Background(), ocispec.Descriptor{Digest: dgst}, "", func(referrers []ocispec.Descriptor) error { + for _, r := range referrers { + result = append(result, referrerInfo{ + ArtifactType: r.ArtifactType, + Digest: string(r.Digest), + }) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list referrers: %w", err) + } + + return result, nil +} + +func splitReference(ref string) string { + if idx := strings.LastIndex(ref, "@"); idx >= 0 { + return ref[:idx] + } + if idx := strings.LastIndex(ref, ":"); idx >= 0 { + slashIdx := strings.LastIndex(ref, "/") + if idx > slashIdx { + return ref[:idx] + } + } + return ref +} + +func (h *Handler) printProvenance(ociRef, digest string, annotations map[string]string, _ []referrerInfo, referrerTypes map[string]bool) { + bold := func(a ...any) string { return fmt.Sprint(a...) } + green := func(a ...any) string { return fmt.Sprint(a...) } + yellow := func(a ...any) string { return fmt.Sprint(a...) } + cyan := func(a ...any) string { return fmt.Sprint(a...) } + if h.supportsColor() { + bold = color.New(color.FgHiWhite, color.Bold).SprintFunc() + green = color.New(color.FgHiGreen).SprintFunc() + yellow = color.New(color.FgHiYellow).SprintFunc() + cyan = color.New(color.FgHiCyan).SprintFunc() + } + + fmt.Println() + fmt.Println(bold("Build Provenance")) + fmt.Println(bold(strings.Repeat("═", 50))) + fmt.Printf(" %-16s %s\n", bold("Reference:"), cyan(ociRef)) + fmt.Printf(" %-16s %s\n", bold("Digest:"), digest) + fmt.Println() + + hasAnnotations := false + for _, a := range knownAnnotations { + val := annotations[annotationPrefix+a.key] + if val != "" { + hasAnnotations = true + fmt.Printf(" %-16s %s\n", bold(a.label+":"), green(val)) + } + } + if !hasAnnotations { + fmt.Println(" No automotive build annotations found") + } + + fmt.Println() + fmt.Println(bold("Saved Artifacts")) + fmt.Println(bold(strings.Repeat("═", 50))) + + for _, rt := range knownReferrerTypes { + if referrerTypes[rt.artifactType] { + fmt.Printf(" %s %s (%s)\n", green("✓"), bold(rt.label), rt.artifactType) + } else { + fmt.Printf(" %s %s\n", yellow("✗"), rt.label) + } + } + + fmt.Println() + fmt.Println(bold("Rebuild Command")) + fmt.Println(bold(strings.Repeat("═", 50))) + + cmd := buildRebuildCommand(ociRef, digest, annotations, referrerTypes) + fmt.Println(cyan(cmd)) + fmt.Println() +} + +func buildRebuildCommand(ociRef, digest string, annotations map[string]string, referrerTypes map[string]bool) string { + get := func(key string) string { return annotations[annotationPrefix+key] } + + aibCmd := get("aib-command") + isDevBuild := strings.HasPrefix(aibCmd, "aib-dev") + + var parts []string + if isDevBuild { + parts = append(parts, "caib image build-dev") + } else { + parts = append(parts, "caib image build") + } + + hasManifest := referrerTypes["application/vnd.automotive.manifest.v1+yaml"] + if hasManifest { + parts = append(parts, "manifest.aib.yml") + } else { + parts = append(parts, "") + } + + if v := get("distro"); v != "" { + parts = append(parts, fmt.Sprintf(" --distro %s", v)) + } + if v := get("target"); v != "" { + parts = append(parts, fmt.Sprintf(" --target %s", v)) + } + if v := get("arch"); v != "" { + parts = append(parts, fmt.Sprintf(" --arch %s", v)) + } + if v := get("automotive-image-builder"); v != "" { + parts = append(parts, fmt.Sprintf(" --aib-image %s", v)) + } + if v := get("builder-image"); v != "" { + parts = append(parts, fmt.Sprintf(" --builder-image %s", v)) + } + if v := get("export-format"); v != "" { + parts = append(parts, fmt.Sprintf(" --format %s", v)) + } + if v := get("custom-defines"); v != "" { + for _, def := range strings.Split(v, "\n") { + def = strings.TrimSpace(def) + if def != "" { + parts = append(parts, fmt.Sprintf(" --define %s", def)) + } + } + } + if v := get("aib-extra-args"); v != "" { + for _, arg := range strings.Split(v, "\n") { + arg = strings.TrimSpace(arg) + if arg != "" { + parts = append(parts, fmt.Sprintf(" --extra-args %s", arg)) + } + } + } + hasSources := referrerTypes["application/vnd.automotive.sources.v1+tar+gzip"] + taskBundleRef := get("task-bundle-ref") + if taskBundleRef != "" { + parts = append(parts, fmt.Sprintf(" --task-bundle-ref %s", taskBundleRef)) + } + if taskBundleRef != "" || hasManifest || hasSources { + parts = append(parts, " --secure") + parts = append(parts, " --reproducible") + } + if hasSources { + parts = append(parts, fmt.Sprintf(" --restore-sources %s@%s", splitReference(ociRef), digest)) + } + if isDevBuild { + parts = append(parts, " --push ") + } else { + parts = append(parts, " --push ") + parts = append(parts, " --push-disk ") + } + + return strings.Join(parts, " \\\n") +} + +func (h *Handler) downloadReferrers(ociRef, _ string, referrers []referrerInfo, outputDir, username, password, authFile string) { + if err := os.MkdirAll(outputDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating output dir: %v\n", err) + return + } + + repo := splitReference(ociRef) + insecure := h.opts.InsecureSkipTLS != nil && *h.opts.InsecureSkipTLS + + fileMap := map[string]string{ + "application/vnd.automotive.manifest.v1+yaml": "manifest.aib.yml", + "application/vnd.automotive.sources.v1+tar+gzip": "build-sources.tar.gz", + "application/vnd.osbuild.manifest.v1+json": "image.json", + } + + for _, ref := range referrers { + filename, known := fileMap[ref.ArtifactType] + if !known { + continue + } + + destPath := filepath.Join(outputDir, filename) + pullRef := repo + "@" + ref.Digest + fmt.Printf("Downloading %s → %s\n", ref.ArtifactType, destPath) + if err := caibcommon.PullOCIArtifact(pullRef, destPath, username, password, insecure, authFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to download %s: %v\n", filename, err) + } + } +} diff --git a/cmd/caib/inspectcmd/inspect_test.go b/cmd/caib/inspectcmd/inspect_test.go new file mode 100644 index 00000000..249c99a3 --- /dev/null +++ b/cmd/caib/inspectcmd/inspect_test.go @@ -0,0 +1,342 @@ +package inspectcmd + +import ( + "encoding/json" + "io" + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const ( + testFormatJSON = "json" + testFormatYAML = "yaml" +) + +func TestSplitReference(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {"tag", "quay.io/org/repo:v1", "quay.io/org/repo"}, + {"digest", "quay.io/org/repo@sha256:abc123", "quay.io/org/repo"}, + {"no tag or digest", "quay.io/org/repo", "quay.io/org/repo"}, + {"port with tag", "localhost:5000/repo:latest", "localhost:5000/repo"}, + {"port no tag", "localhost:5000/repo", "localhost:5000/repo"}, + {"digest with tag", "quay.io/org/repo:v1@sha256:abc", "quay.io/org/repo:v1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitReference(tt.ref) + if got != tt.want { + t.Errorf("splitReference(%q) = %q, want %q", tt.ref, got, tt.want) + } + }) + } +} + +func fullAnnotations() map[string]string { + return map[string]string{ + annotationPrefix + "distro": "autosd", + annotationPrefix + "target": "qemu", + annotationPrefix + "arch": "amd64", + annotationPrefix + "automotive-image-builder": "quay.io/aib@sha256:abc", + annotationPrefix + "builder-image": "quay.io/builder@sha256:def", + annotationPrefix + "aib-version": "1.3.0", + annotationPrefix + "task-bundle-ref": "quay.io/tasks@sha256:789", + annotationPrefix + "aib-command": "aib build --distro autosd --target qemu", + } +} + +func TestBuildRebuildCommand_Bootc(t *testing.T) { + annotations := fullAnnotations() + referrerTypes := map[string]bool{ + "application/vnd.automotive.manifest.v1+yaml": true, + } + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + for _, want := range []string{ + "caib image build", + "manifest.aib.yml", + "--distro autosd", + "--target qemu", + "--arch amd64", + "--aib-image quay.io/aib@sha256:abc", + "--builder-image quay.io/builder@sha256:def", + "--task-bundle-ref quay.io/tasks@sha256:789", + "--secure", + "--reproducible", + "--push ", + "--push-disk ", + } { + if !strings.Contains(cmd, want) { + t.Errorf("rebuild command missing %q\ngot: %s", want, cmd) + } + } +} + +func TestBuildRebuildCommand_DevBuild(t *testing.T) { + annotations := fullAnnotations() + annotations[annotationPrefix+"aib-command"] = "aib-dev --verbose build --distro autosd" + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if !strings.Contains(cmd, "caib image build-dev") { + t.Errorf("expected build-dev command, got: %s", cmd) + } + if !strings.Contains(cmd, "") { + t.Errorf("expected placeholder manifest (no referrer), got: %s", cmd) + } + if strings.Contains(cmd, "--push-disk") { + t.Errorf("build-dev should not have --push-disk, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_NoSecure(t *testing.T) { + annotations := fullAnnotations() + delete(annotations, annotationPrefix+"task-bundle-ref") + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if strings.Contains(cmd, "--secure") { + t.Errorf("should not have --secure without task-bundle-ref, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_CustomDefines(t *testing.T) { + annotations := fullAnnotations() + annotations[annotationPrefix+"custom-defines"] = "use_debug=true\nfoo=bar" + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if !strings.Contains(cmd, "--define use_debug=true") { + t.Errorf("missing --define use_debug=true, got: %s", cmd) + } + if !strings.Contains(cmd, "--define foo=bar") { + t.Errorf("missing --define foo=bar, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_ExtraArgs(t *testing.T) { + annotations := fullAnnotations() + annotations[annotationPrefix+"aib-extra-args"] = "--verbose\n--cache-max-size=unlimited" + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if !strings.Contains(cmd, "--extra-args --verbose") { + t.Errorf("missing --extra-args --verbose, got: %s", cmd) + } + if !strings.Contains(cmd, "--extra-args --cache-max-size=unlimited") { + t.Errorf("missing --extra-args --cache-max-size=unlimited, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_ExportFormat(t *testing.T) { + annotations := fullAnnotations() + annotations[annotationPrefix+"export-format"] = "simg" + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if !strings.Contains(cmd, "--format simg") { + t.Errorf("missing --format simg, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_RestoreSources(t *testing.T) { + annotations := fullAnnotations() + referrerTypes := map[string]bool{ + "application/vnd.automotive.manifest.v1+yaml": true, + "application/vnd.automotive.sources.v1+tar+gzip": true, + } + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if !strings.Contains(cmd, "--restore-sources quay.io/org/repo@sha256:abc123") { + t.Errorf("missing --restore-sources with image ref, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_NoRestoreSourcesWithoutReferrer(t *testing.T) { + annotations := fullAnnotations() + referrerTypes := map[string]bool{ + "application/vnd.automotive.manifest.v1+yaml": true, + } + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if strings.Contains(cmd, "--restore-sources") { + t.Errorf("should not have --restore-sources without sources referrer, got: %s", cmd) + } +} + +func TestBuildRebuildCommand_NoBuilderImage(t *testing.T) { + annotations := fullAnnotations() + delete(annotations, annotationPrefix+"builder-image") + referrerTypes := map[string]bool{} + + cmd := buildRebuildCommand("quay.io/org/repo:v1", "sha256:abc123", annotations, referrerTypes) + + if strings.Contains(cmd, "--builder-image") { + t.Errorf("should not have --builder-image when not set, got: %s", cmd) + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = old + + data, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestPrintStructured_JSON(t *testing.T) { + format := testFormatJSON + h := NewHandler(Options{OutputFormat: &format}) + + annotations := fullAnnotations() + referrers := []referrerInfo{ + {ArtifactType: "application/vnd.automotive.manifest.v1+yaml", Digest: "sha256:aaa"}, + } + referrerTypes := map[string]bool{ + "application/vnd.automotive.manifest.v1+yaml": true, + } + + out := captureStdout(t, func() { + h.printStructured(testFormatJSON, "quay.io/org/repo:v1", "sha256:abc123", annotations, referrers, referrerTypes) + }) + + var parsed provenanceOutput + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("invalid JSON output: %v\nraw: %s", err, out) + } + + if parsed.Reference != "quay.io/org/repo:v1" { + t.Errorf("reference = %q, want quay.io/org/repo:v1", parsed.Reference) + } + if parsed.Digest != "sha256:abc123" { + t.Errorf("digest = %q, want sha256:abc123", parsed.Digest) + } + if parsed.Annotations["distro"] != "autosd" { + t.Errorf("annotations[distro] = %q, want autosd", parsed.Annotations["distro"]) + } + if _, ok := parsed.Annotations["automotive.sdv.cloud.redhat.com/distro"]; ok { + t.Error("JSON annotations should have prefix stripped") + } + if len(parsed.Referrers) != 1 { + t.Errorf("expected 1 referrer, got %d", len(parsed.Referrers)) + } + if !strings.Contains(parsed.RebuildCmd, "--secure") { + t.Error("rebuild command should contain --secure") + } +} + +func TestPrintStructured_YAML(t *testing.T) { + format := testFormatYAML + h := NewHandler(Options{OutputFormat: &format}) + + annotations := fullAnnotations() + referrers := []referrerInfo{ + {ArtifactType: "application/vnd.osbuild.manifest.v1+json", Digest: "sha256:bbb"}, + } + referrerTypes := map[string]bool{ + "application/vnd.osbuild.manifest.v1+json": true, + } + + out := captureStdout(t, func() { + h.printStructured(testFormatYAML, "quay.io/org/repo:v1", "sha256:abc123", annotations, referrers, referrerTypes) + }) + + var parsed provenanceOutput + if err := yaml.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("invalid YAML output: %v\nraw: %s", err, out) + } + + if parsed.Reference != "quay.io/org/repo:v1" { + t.Errorf("reference = %q, want quay.io/org/repo:v1", parsed.Reference) + } + if parsed.Annotations["distro"] != "autosd" { + t.Errorf("annotations[distro] = %q, want autosd", parsed.Annotations["distro"]) + } +} + +func TestPrintProvenance_Table(t *testing.T) { + h := NewHandler(Options{}) + + annotations := fullAnnotations() + referrerTypes := map[string]bool{ + "application/vnd.automotive.manifest.v1+yaml": true, + "application/vnd.automotive.sources.v1+tar+gzip": true, + } + + out := captureStdout(t, func() { + h.printProvenance("quay.io/org/repo:v1", "sha256:abc", annotations, nil, referrerTypes) + }) + + if !strings.Contains(out, "Build Provenance") { + t.Error("missing Build Provenance header") + } + if !strings.Contains(out, "autosd") { + t.Error("missing distro value") + } + if !strings.Contains(out, "quay.io/builder@sha256:def") { + t.Error("missing builder-image value") + } +} + +func TestPrintProvenance_FullAIBCommand(t *testing.T) { + h := NewHandler(Options{}) + + longCmd := strings.Repeat("x", 200) + annotations := map[string]string{ + annotationPrefix + "aib-command": longCmd, + } + referrerTypes := map[string]bool{} + + out := captureStdout(t, func() { + h.printProvenance("ref", "dig", annotations, nil, referrerTypes) + }) + + if !strings.Contains(out, longCmd) { + t.Error("aib-command should not be truncated") + } + if strings.Contains(out, "...") { + t.Error("should not have truncation ellipsis") + } +} + +func TestPrintProvenance_NoAnnotations(t *testing.T) { + h := NewHandler(Options{}) + annotations := map[string]string{} + referrerTypes := map[string]bool{} + + out := captureStdout(t, func() { + h.printProvenance("ref", "dig", annotations, nil, referrerTypes) + }) + + if !strings.Contains(out, "No automotive build annotations found") { + t.Error("should show no-annotations message") + } +} diff --git a/cmd/caib/main.go b/cmd/caib/main.go index 211a8e17..87b38e9e 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -63,6 +63,11 @@ var ( // Secure build secureBuild bool + // Reproducible build + reproducibleBuild bool + taskBundleRef string + restoreSourcesRef string + // Build TTL buildTTL string diff --git a/cmd/caib/registryauth/credentials.go b/cmd/caib/registryauth/credentials.go index 9f75aa9a..796b6313 100644 --- a/cmd/caib/registryauth/credentials.go +++ b/cmd/caib/registryauth/credentials.go @@ -22,8 +22,8 @@ func ExtractRegistryCredentials(primaryRef, secondaryRef string) (string, string } if username == "" || password == "" { - fmt.Println("Warning: No registry credentials provided via environment variables.") - fmt.Println("Will attempt to use local auth.json files as fallback.") + fmt.Fprintln(os.Stderr, "Warning: No registry credentials provided via environment variables.") + fmt.Fprintln(os.Stderr, "Will attempt to use local auth.json files as fallback.") } parts := strings.SplitN(ref, "/", 2) diff --git a/cmd/caib/runtime_wiring.go b/cmd/caib/runtime_wiring.go index 87fb62c6..8b99a586 100644 --- a/cmd/caib/runtime_wiring.go +++ b/cmd/caib/runtime_wiring.go @@ -5,6 +5,7 @@ import ( "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/downloadcmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/flashcmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/image" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/inspectcmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/querycmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/sealedcmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/tokencmd" @@ -55,8 +56,11 @@ type runtimeState struct { InternalRegistryImageName *string InternalRegistryTag *string - SecureBuild *bool - TTL *string + SecureBuild *bool + Reproducible *bool + TaskBundleRef *string + RestoreSourcesRef *string + TTL *string InsecureSkipTLS *bool @@ -117,8 +121,11 @@ func newRuntimeState() runtimeState { InternalRegistryImageName: &internalRegistryImageName, InternalRegistryTag: &internalRegistryTag, - SecureBuild: &secureBuild, - TTL: &buildTTL, + SecureBuild: &secureBuild, + Reproducible: &reproducibleBuild, + TaskBundleRef: &taskBundleRef, + RestoreSourcesRef: &restoreSourcesRef, + TTL: &buildTTL, InsecureSkipTLS: &insecureSkipTLS, @@ -141,6 +148,7 @@ type handlerSet struct { flash *flashcmd.Handler sealed *sealedcmd.Handler token *tokencmd.Handler + inspect *inspectcmd.Handler } func (s runtimeState) newHandlers() handlerSet { @@ -185,6 +193,9 @@ func (s runtimeState) newHandlers() handlerSet { InternalRegistryImageName: s.InternalRegistryImageName, InternalRegistryTag: s.InternalRegistryTag, SecureBuild: s.SecureBuild, + Reproducible: s.Reproducible, + TaskBundleRef: s.TaskBundleRef, + RestoreSourcesRef: s.RestoreSourcesRef, TTL: s.TTL, InsecureSkipTLS: s.InsecureSkipTLS, HandleError: handleError, @@ -246,6 +257,13 @@ func (s runtimeState) newHandlers() handlerSet { InsecureSkipTLS: s.InsecureSkipTLS, HandleError: handleError, }), + inspect: inspectcmd.NewHandler(inspectcmd.Options{ + RegistryAuthFile: s.RegistryAuthFile, + OutputDir: s.OutputDir, + OutputFormat: s.OutputFormat, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), } } @@ -266,6 +284,7 @@ func (s runtimeState) imageOptions(h handlerSet) image.Options { RunToken: h.token.RunToken, RunDelete: h.build.RunDelete, RunCancel: h.build.RunCancel, + RunInspect: h.inspect.RunInspect, GetDefaultArch: getDefaultArch, ServerURL: s.ServerURL, @@ -308,8 +327,11 @@ func (s runtimeState) imageOptions(h handlerSet) image.Options { InternalRegistryImageName: s.InternalRegistryImageName, InternalRegistryTag: s.InternalRegistryTag, - SecureBuild: s.SecureBuild, - TTL: s.TTL, + SecureBuild: s.SecureBuild, + Reproducible: s.Reproducible, + TaskBundleRef: s.TaskBundleRef, + RestoreSourcesRef: s.RestoreSourcesRef, + TTL: s.TTL, SealedBuilderImage: s.SealedBuilderImage, SealedArchitecture: s.SealedArchitecture, diff --git a/cmd/export-tasks/main.go b/cmd/export-tasks/main.go index 0c1c3a91..a241ed03 100644 --- a/cmd/export-tasks/main.go +++ b/cmd/export-tasks/main.go @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package main exports Tekton Task definitions as YAML files for Tekton Bundle packaging. -// Tasks are generated from the same Go code used by the operator, ensuring the bundle -// contains the exact same task definitions as cluster-installed ones. +// Package main exports Tekton Task and Pipeline definitions as YAML files for Tekton Bundle packaging. +// Resources are generated from the same Go code used by the operator, ensuring the bundle +// contains the exact same definitions as cluster-installed ones. package main import ( @@ -26,6 +26,7 @@ import ( "path/filepath" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/tasks" @@ -35,7 +36,7 @@ func main() { outputDir := flag.String("output-dir", "", "Directory to write task YAML files (writes to stdout if empty)") flag.Parse() - // Use nil buildConfig for defaults — bundle tasks should not bake in + // Use nil buildConfig for defaults — bundle resources should not bake in // cluster-specific settings like memory volumes or custom timeouts. taskList := []*tektonv1.Task{ tasks.GenerateBuildAutomotiveImageTask("", nil, ""), @@ -45,6 +46,11 @@ func main() { } taskList = append(taskList, tasks.GenerateSealedTasks("")...) + pipeline := tasks.GenerateTektonPipeline("automotive-build-pipeline", "", &tasks.BuildConfig{ + TaskResolver: tasks.TaskResolverBundle, + TaskBundleRef: "$(params.task-bundle-ref)", + }) + if *outputDir != "" { if err := os.MkdirAll(*outputDir, 0o755); err != nil { fmt.Fprintf(os.Stderr, "error creating output directory: %v\n", err) @@ -52,24 +58,39 @@ func main() { } } + type namedResource struct { + name string + obj interface{} + } + + stripMetadata := func(obj metav1.Object) { + obj.SetNamespace("") + obj.SetManagedFields(nil) + obj.SetResourceVersion("") + obj.SetUID("") + obj.SetCreationTimestamp(metav1.Time{}) + } + + resources := make([]namedResource, 0, len(taskList)+1) for _, task := range taskList { - // Strip namespace and runtime metadata — these are cluster concerns, not bundle content. - task.Namespace = "" - task.ManagedFields = nil - task.ResourceVersion = "" - task.UID = "" - task.CreationTimestamp.Reset() - - data, err := yaml.Marshal(task) + stripMetadata(task) + resources = append(resources, namedResource{task.Name, task}) + } + + stripMetadata(pipeline) + resources = append(resources, namedResource{pipeline.Name, pipeline}) + + for _, res := range resources { + data, err := yaml.Marshal(res.obj) if err != nil { - fmt.Fprintf(os.Stderr, "error marshaling task %s: %v\n", task.Name, err) + fmt.Fprintf(os.Stderr, "error marshaling %s: %v\n", res.name, err) os.Exit(1) } if *outputDir == "" { fmt.Printf("---\n%s", data) } else { - path := filepath.Join(*outputDir, task.Name+".yaml") + path := filepath.Join(*outputDir, res.name+".yaml") if err := os.WriteFile(path, data, 0o644); err != nil { fmt.Fprintf(os.Stderr, "error writing %s: %v\n", path, err) os.Exit(1) diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml index e5be3ba7..d1953891 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml @@ -190,6 +190,18 @@ spec: PushSecretRef is the name of the kubernetes.io/dockerconfigjson secret for pushing artifacts This is separate from SecretRef because push operations require docker config format type: string + reproducible: + description: |- + Reproducible enables full build provenance: saves RPMs, AIB manifest, + and task bundle ref as OCI referrers for future reproduction. + Requires SecureBuild to be true for task bundle pinning. + type: boolean + restoreSourcesRef: + description: |- + RestoreSourcesRef is the OCI image reference from a prior reproducible build. + The build pod will pull the sources archive (OCI referrer) attached to this + image and pre-populate the osbuild store, ensuring identical RPM inputs. + type: string runtimeClassName: description: RuntimeClassName specifies the runtime class to use for the build pod @@ -231,6 +243,9 @@ spec: on completion so subsequent builds can reuse it. type: string type: object + x-kubernetes-validations: + - message: reproducible builds require secureBuild to be true + rule: '!has(self.reproducible) || !self.reproducible || self.secureBuild' status: description: ImageBuildStatus defines the observed state of ImageBuild properties: diff --git a/go.mod b/go.mod index ffbbd7d3..f81a0361 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,15 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.0 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 github.com/shipwright-io/build v0.18.3 golang.org/x/term v0.40.0 k8s.io/apimachinery v0.33.11 k8s.io/apiserver v0.33.11 k8s.io/client-go v0.33.11 knative.dev/pkg v0.0.0-20250716115900-19d3cc2da0b9 + oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/controller-runtime v0.21.0 ) @@ -74,8 +77,6 @@ require ( github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect diff --git a/go.sum b/go.sum index f7201e5c..842c1942 100644 --- a/go.sum +++ b/go.sum @@ -970,6 +970,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= knative.dev/pkg v0.0.0-20250716115900-19d3cc2da0b9 h1:P9GFsqTmX4WokSVVUK1IbHOnbJLi8EW1pd4PvbUEodo= knative.dev/pkg v0.0.0-20250716115900-19d3cc2da0b9/go.mod h1:Hq2y1gu4P/MWEk9zB8L/zrjJ19ywEnYgwXq8ZMjRl30= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index d5ab77da..085cffb6 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -718,6 +718,10 @@ func validateBuildRequest(req *BuildRequest) error { } } + if req.Reproducible && !req.SecureBuild { + return fmt.Errorf("reproducible builds require secureBuild to be true") + } + return nil } @@ -858,6 +862,10 @@ func (a *APIServer) setupInternalRegistryBuild( c.JSON(http.StatusBadRequest, gin.H{"error": "useInternalRegistry cannot be used with exportOci"}) return "", "", fmt.Errorf("validation error") } + if req.Reproducible { + c.JSON(http.StatusBadRequest, gin.H{"error": "reproducible builds cannot use internal registry (OCI referrers not supported)"}) + return "", "", fmt.Errorf("validation error") + } // Resolve external route (validates registry is reachable) if _, err := getExternalRegistryRoute(ctx, k8sClient, namespace); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -1172,6 +1180,32 @@ var digestPinnedRef = regexp.MustCompile(`^.+@sha256:[a-fA-F0-9]{64}$`) // validateSecureBuild checks that the OperatorConfig has a valid digest-pinned taskBundleRef. // Returns the validated ref, an HTTP status code, and error. +func resolveTaskBundleRef(ctx context.Context, k8sClient client.Client, namespace string, req *BuildRequest) (string, int, error) { + if !req.SecureBuild { + return "", 0, nil + } + if req.TaskBundleRef != "" { + ref := strings.TrimSpace(req.TaskBundleRef) + if !digestPinnedRef.MatchString(ref) { + return "", http.StatusBadRequest, fmt.Errorf("taskBundleRef must be digest-pinned (image@sha256:<64 hex>), got %q", ref) + } + return ref, 0, nil + } + return validateSecureBuild(ctx, k8sClient, namespace) +} + +func validateRestoreSourcesRef(req *BuildRequest) error { + if req.RestoreSourcesRef == "" { + return nil + } + ref := strings.TrimSpace(req.RestoreSourcesRef) + if !digestPinnedRef.MatchString(ref) { + return fmt.Errorf("restoreSourcesRef must be digest-pinned (image@sha256:<64 hex>), got %q", ref) + } + req.RestoreSourcesRef = ref + return nil +} + func validateSecureBuild(ctx context.Context, k8sClient client.Client, namespace string) (string, int, error) { operatorConfig := &automotivev1alpha1.OperatorConfig{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: "config", Namespace: namespace}, operatorConfig); err != nil { @@ -1249,18 +1283,16 @@ func (a *APIServer) createBuild(c *gin.Context) { requestedBy := a.resolveRequester(c) - // Validate secureBuild requirements early, before creating any resources. - // Snapshot the validated ref to prevent TOCTOU races with OperatorConfig changes. - var taskBundleRef string - if req.SecureBuild { - var statusCode int - var err error - taskBundleRef, statusCode, err = validateSecureBuild(ctx, k8sClient, namespace) - if err != nil { - spanError(span, err) - c.JSON(statusCode, gin.H{"error": err.Error()}) - return - } + taskBundleRef, bundleStatus, bundleErr := resolveTaskBundleRef(ctx, k8sClient, namespace, &req) + if bundleErr != nil { + spanError(span, bundleErr) + c.JSON(bundleStatus, gin.H{"error": bundleErr.Error()}) + return + } + + if err := validateRestoreSourcesRef(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return } // Resolve --workspace: create/find build-cache PVC, forward lease, start file server @@ -1337,6 +1369,9 @@ func (a *APIServer) createBuild(c *gin.Context) { automotivev1alpha1.AnnotationRequestedBy: requestedBy, automotivev1alpha1.AnnotationTraceID: traceID, } + if req.Reproducible && taskBundleRef != "" { + annotations[automotivev1alpha1.AnnotationTaskBundleRef] = taskBundleRef + } imageBuild := &automotivev1alpha1.ImageBuild{ ObjectMeta: metav1.ObjectMeta{ @@ -1346,18 +1381,20 @@ func (a *APIServer) createBuild(c *gin.Context) { Annotations: annotations, }, Spec: automotivev1alpha1.ImageBuildSpec{ - Architecture: string(req.Architecture), - StorageClass: req.StorageClass, - SecretRef: envSecretRef, - PushSecretRef: pushSecretName, - AIB: buildAIBSpec(&req, req.Manifest, req.ManifestFileName, needsUpload), - Export: buildExportSpec(&req), - Flash: flashSpec, - BuildCachePVC: buildCachePVCName, - Workspace: req.Workspace, - SecureBuild: req.SecureBuild, - TaskBundleRef: taskBundleRef, - TTL: effectiveTTL, + Architecture: string(req.Architecture), + StorageClass: req.StorageClass, + SecretRef: envSecretRef, + PushSecretRef: pushSecretName, + AIB: buildAIBSpec(&req, req.Manifest, req.ManifestFileName, needsUpload), + Export: buildExportSpec(&req), + Flash: flashSpec, + BuildCachePVC: buildCachePVCName, + Workspace: req.Workspace, + SecureBuild: req.SecureBuild, + Reproducible: req.Reproducible, + TaskBundleRef: taskBundleRef, + RestoreSourcesRef: req.RestoreSourcesRef, + TTL: effectiveTTL, }, } if err := k8sClient.Create(ctx, imageBuild); err != nil { @@ -1646,6 +1683,9 @@ func getBuildTemplate(c *gin.Context, name string) { AIBExtraArgs: build.Spec.GetAIBExtraArgs(), Compression: Compression(build.Spec.GetCompression()), SecureBuild: build.Spec.SecureBuild, + Reproducible: build.Spec.Reproducible, + TaskBundleRef: build.Spec.TaskBundleRef, + RestoreSourcesRef: build.Spec.RestoreSourcesRef, TTL: build.Spec.GetTTL(), }, SourceFiles: sourceFiles, diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index ca983e3f..595e00ef 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -163,6 +163,16 @@ type BuildRequest struct { // Secure build: resolve tasks from signed Tekton Bundle SecureBuild bool `json:"secureBuild,omitempty"` + // TaskBundleRef overrides OperatorConfig's taskBundleRef (for reproducible rebuilds) + TaskBundleRef string `json:"taskBundleRef,omitempty"` + + // Reproducible saves RPMs, AIB manifest, and task bundle ref as OCI referrers + Reproducible bool `json:"reproducible,omitempty"` + + // RestoreSourcesRef is an OCI image reference whose archived sources will be + // restored into the build's osbuild store before building. + RestoreSourcesRef string `json:"restoreSourcesRef,omitempty"` + // TTL is the time-to-live for the build. Empty uses server default, "0" disables expiry. TTL string `json:"ttl,omitempty"` diff --git a/internal/common/tasks/pipeline_test.go b/internal/common/tasks/pipeline_test.go index 97ff240d..0659b78e 100644 --- a/internal/common/tasks/pipeline_test.go +++ b/internal/common/tasks/pipeline_test.go @@ -3,6 +3,8 @@ package tasks import ( "strings" "testing" + + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" ) func TestBuildTaskRef_ClusterResolver(t *testing.T) { @@ -49,7 +51,7 @@ func TestBuildTaskRef_BundleResolver(t *testing.T) { TaskBundleRef: bundleRef, }) - if ref.Resolver != tektonResolverBundles { + if ref.Resolver != TektonResolverBundles { t.Fatalf("expected bundles resolver, got %q", ref.Resolver) } @@ -174,7 +176,7 @@ func TestGenerateTektonPipeline_BundleResolver(t *testing.T) { if task.TaskRef == nil { continue // skip tasks with inline TaskSpec } - if task.TaskRef.Resolver != tektonResolverBundles { + if task.TaskRef.Resolver != TektonResolverBundles { t.Errorf("task %q should use bundles resolver, got %q", task.Name, task.TaskRef.Resolver) } } @@ -250,6 +252,192 @@ func TestCollectImagesScript_Format(t *testing.T) { t.Fatal("pipeline should have collect-images-result task") } +func hasParam(params []tektonv1.ParamSpec, name string) bool { + for _, p := range params { + if p.Name == name { + return true + } + } + return false +} + +func findPipelineTask(tasks []tektonv1.PipelineTask, name string) *tektonv1.PipelineTask { + for i := range tasks { + if tasks[i].Name == name { + return &tasks[i] + } + } + return nil +} + +func taskParamBinding(task *tektonv1.PipelineTask, name string) (string, bool) { + for _, p := range task.Params { + if p.Name == name { + return p.Value.StringVal, true + } + } + return "", false +} + +func TestReproducibleParams_PushTask(t *testing.T) { + task := GeneratePushArtifactRegistryTask("test-ns", nil) + + required := []string{"reproducible", "task-bundle-ref", "custom-defines", "aib-extra-args"} + for _, name := range required { + if !hasParam(task.Spec.Params, name) { + t.Errorf("push-artifact-registry task missing param %q", name) + } + } +} + +func TestReproducibleParams_Pipeline(t *testing.T) { + pipeline := GenerateTektonPipeline("test-pipeline", "test-ns", &BuildConfig{}) + + required := []string{"reproducible", "task-bundle-ref", "custom-defines", "aib-extra-args"} + for _, name := range required { + if !hasParam(pipeline.Spec.Params, name) { + t.Errorf("pipeline missing param %q", name) + } + } +} + +func TestReproducibleParams_BuildImageBinding(t *testing.T) { + pipeline := GenerateTektonPipeline("test-pipeline", "test-ns", &BuildConfig{}) + + buildTask := findPipelineTask(pipeline.Spec.Tasks, "build-image") + if buildTask == nil { + t.Fatal("pipeline missing build-image task") + } + + val, ok := taskParamBinding(buildTask, "reproducible") + if !ok { + t.Fatal("build-image task missing reproducible param binding") + } + if val != "$(params.reproducible)" { + t.Errorf("build-image reproducible binding = %q, want $(params.reproducible)", val) + } +} + +func TestReproducibleParams_PushDiskArtifactBindings(t *testing.T) { + pipeline := GenerateTektonPipeline("test-pipeline", "test-ns", &BuildConfig{}) + + pushTask := findPipelineTask(pipeline.Spec.Tasks, "push-disk-artifact") + if pushTask == nil { + t.Fatal("pipeline missing push-disk-artifact task") + } + + expectedBindings := map[string]string{ + "reproducible": "$(params.reproducible)", + "task-bundle-ref": "$(params.task-bundle-ref)", + "custom-defines": "$(params.custom-defines)", + "aib-extra-args": "$(params.aib-extra-args)", + } + for param, wantVal := range expectedBindings { + got, ok := taskParamBinding(pushTask, param) + if !ok { + t.Errorf("push-disk-artifact missing param binding %q", param) + continue + } + if got != wantVal { + t.Errorf("push-disk-artifact %s = %q, want %q", param, got, wantVal) + } + } +} + +func TestReproducibleParams_BuildTask(t *testing.T) { + task := GenerateBuildAutomotiveImageTask("test-ns", nil, "") + + if !hasParam(task.Spec.Params, "reproducible") { + t.Error("build-automotive-image task missing reproducible param") + } +} + +func TestReproducibleParams_PushScript_References(t *testing.T) { + task := GeneratePushArtifactRegistryTask("test-ns", nil) + + if len(task.Spec.Steps) == 0 { + t.Fatal("push task has no steps") + } + + script := task.Spec.Steps[0].Script + refs := []string{ + "$(params.reproducible)", + "$(params.task-bundle-ref)", + "$(params.custom-defines)", + "$(params.aib-extra-args)", + } + for _, ref := range refs { + if !strings.Contains(script, ref) { + t.Errorf("push script missing param reference %q", ref) + } + } +} + +func TestReproducibleParams_BuildScript_References(t *testing.T) { + task := GenerateBuildAutomotiveImageTask("test-ns", nil, "") + + var buildStep string + for _, s := range task.Spec.Steps { + if s.Name == "build-image" { + buildStep = s.Script + break + } + } + if buildStep == "" { + t.Fatal("build task has no 'build-image' step") + } + + if !strings.Contains(buildStep, "$(params.reproducible)") { + t.Error("build script missing $(params.reproducible) reference") + } +} + +func TestPipelineParamSpec_Defaults(t *testing.T) { + pipeline := GenerateTektonPipeline("test-pipeline", "test-ns", &BuildConfig{}) + + wantDefaults := map[string]string{ + "reproducible": "false", + "task-bundle-ref": "", + "custom-defines": "", + "aib-extra-args": "", + "secure-build": "false", + } + for _, p := range pipeline.Spec.Params { + if expected, ok := wantDefaults[p.Name]; ok { + if p.Default == nil { + t.Errorf("pipeline param %q has nil default, want %q", p.Name, expected) + continue + } + if p.Default.StringVal != expected { + t.Errorf("pipeline param %q default = %q, want %q", p.Name, p.Default.StringVal, expected) + } + } + } +} + +func TestPushTask_ParamSpec_Defaults(t *testing.T) { + task := GeneratePushArtifactRegistryTask("test-ns", nil) + + wantDefaults := map[string]string{ + "reproducible": "false", + "task-bundle-ref": "", + "custom-defines": "", + "aib-extra-args": "", + "secure-build": "false", + } + for _, p := range task.Spec.Params { + if expected, ok := wantDefaults[p.Name]; ok { + if p.Default == nil { + t.Errorf("push task param %q has nil default, want %q", p.Name, expected) + continue + } + if p.Default.StringVal != expected { + t.Errorf("push task param %q default = %q, want %q", p.Name, p.Default.StringVal, expected) + } + } + } +} + // TestImagesResultFormat verifies the image@digest format Chains expects func TestImagesResultFormat(t *testing.T) { // Simulate what the collect-images script produces diff --git a/internal/common/tasks/scripts/build_image.sh b/internal/common/tasks/scripts/build_image.sh index 09801280..a6897aa9 100644 --- a/internal/common/tasks/scripts/build_image.sh +++ b/internal/common/tasks/scripts/build_image.sh @@ -87,6 +87,70 @@ else chmod g-s "/_build" 2>/dev/null || true fi +RESTORE_SOURCES_REF="$(params.restore-sources-ref)" +if [ -n "$RESTORE_SOURCES_REF" ]; then + echo "=== Restoring sources from $RESTORE_SOURCES_REF ===" + + ORAS_VERSION="1.2.0" + case "$(uname -m)" in + x86_64) ORAS_ARCH="amd64" ;; + aarch64|arm64) ORAS_ARCH="arm64" ;; + *) echo "ERROR: Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + ORAS_TARBALL="oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + ORAS_BASE_URL="https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}" + ORAS_CHECKSUMS="oras_${ORAS_VERSION}_checksums.txt" + curl -sLO "${ORAS_BASE_URL}/${ORAS_TARBALL}" + curl -sLO "${ORAS_BASE_URL}/${ORAS_CHECKSUMS}" + expected_checksum=$(grep "${ORAS_TARBALL}" "${ORAS_CHECKSUMS}" | cut -d' ' -f1) + if command -v sha256sum >/dev/null; then + actual_checksum=$(sha256sum "${ORAS_TARBALL}" | cut -d' ' -f1) + else + actual_checksum=$(shasum -a 256 "${ORAS_TARBALL}" | cut -d' ' -f1) + fi + if [ "$expected_checksum" != "$actual_checksum" ]; then + echo "ERROR: ORAS checksum verification failed" >&2; exit 1 + fi + tar -zxf "$ORAS_TARBALL" oras + mkdir -p "$HOME/bin" + mv oras "$HOME/bin/" + rm -f "$ORAS_TARBALL" "$ORAS_CHECKSUMS" + export PATH="$HOME/bin:$PATH" + + ORAS_AUTH_FLAGS=() + if [ -n "$REGISTRY_AUTH_FILE" ] && [ -f "$REGISTRY_AUTH_FILE" ]; then + ORAS_AUTH_FLAGS=(--registry-config "$REGISTRY_AUTH_FILE") + fi + + SOURCES_TYPE="application/vnd.automotive.sources.v1+tar+gzip" + SOURCES_DIGEST=$(oras discover "${ORAS_AUTH_FLAGS[@]}" "$RESTORE_SOURCES_REF" \ + --artifact-type "$SOURCES_TYPE" --format json \ + | grep -o 'sha256:[a-f0-9]\{64\}' | head -1) + + if [ -z "$SOURCES_DIGEST" ]; then + echo "ERROR: No sources referrer found for $RESTORE_SOURCES_REF" >&2 + exit 1 + fi + + # Strip digest to get repo (ref is always digest-pinned: registry/repo@sha256:...) + SOURCES_REPO="${RESTORE_SOURCES_REF%%@*}" + + RESTORE_TMPDIR=$(mktemp -d) + oras pull "${ORAS_AUTH_FLAGS[@]}" "${SOURCES_REPO}@${SOURCES_DIGEST}" -o "$RESTORE_TMPDIR" + SOURCES_ARCHIVE=$(find "$RESTORE_TMPDIR" -name '*.tar.gz' -print -quit) + + if [ -z "$SOURCES_ARCHIVE" ]; then + echo "ERROR: No archive found after pulling sources referrer" >&2 + exit 1 + fi + + mkdir -p "$BUILD_DIR/osbuild_store" + tar -xzf "$SOURCES_ARCHIVE" -C "$BUILD_DIR/osbuild_store" + rm -rf "$RESTORE_TMPDIR" + echo "Sources restored: $(find "$BUILD_DIR/osbuild_store/sources" -type f | wc -l) blobs" + echo "=== Sources restoration complete ===" +fi + install_custom_ca_certs setup_osbuild @@ -334,6 +398,8 @@ declare -a COMMON_BUILD_ARGS=( if [ "$(params.use-persistent-cache)" = "true" ]; then COMMON_BUILD_ARGS+=(--define "reproducible_image=true") COMMON_BUILD_ARGS+=(--cache-max-size=unlimited) +elif [ "$(params.reproducible)" = "true" ]; then + COMMON_BUILD_ARGS+=(--define "reproducible_image=true") fi AIB_INVOKE_TIME=$(date +%s) @@ -831,6 +897,24 @@ if [ -n "${CONTAINER_PUSH:-}" ]; then echo -n "$PUSHED_DIGEST" > "$WORKSPACE_PATH/.chains/container/digest" fi +# Package osbuild sources and manifest for reproducible builds. +# osbuild stores downloaded files (RPMs, etc.) as content-addressed blobs in +# osbuild_store/sources/org.osbuild.files/ — we archive the entire sources dir +# so a future rebuild can restore the exact same binaries. +if [ "$(params.reproducible)" = "true" ]; then + echo "=== Reproducible build: packaging artifacts ===" + SOURCES_DIR="$BUILD_DIR/osbuild_store/sources" + SOURCES_ARCHIVE="$WORKSPACE_PATH/build-sources.tar.gz" + if [ -d "$SOURCES_DIR" ]; then + tar -czf "$SOURCES_ARCHIVE" -C "$BUILD_DIR/osbuild_store" sources + echo "Sources archive: $(du -sh "$SOURCES_ARCHIVE" | cut -f1)" + else + echo "WARNING: No osbuild sources found at $SOURCES_DIR" + fi + cp "$MANIFEST_FILE" "$WORKSPACE_PATH/aib-manifest.yml" + echo "AIB manifest saved to workspace" +fi + BUILD_END_TIME=$(date +%s) echo "⏱ Post-build phase: $((BUILD_END_TIME - AIB_END_TIME))s" echo "⏱ Total build-image step: $((BUILD_END_TIME - BUILD_START_TIME))s" @@ -838,4 +922,4 @@ echo "⏱ Total build-image step: $((BUILD_END_TIME - BUILD_START_TIME))s" # Write structured timing data as a Tekton result for Prometheus metrics cat > /tekton/results/build-timing </dev/null)" ]; then manifest_annotations_json=$(python3 - \ "$distro" "$target" "$arch" "$file_list" \ - "$default_partitions" "$builder_image_used" "$aib_version" "$aib_image" "$aib_command" <<'PYEOF' + "$default_partitions" "$builder_image_used" "$aib_version" "$aib_image" "$aib_command" "$TASK_BUNDLE_REF" \ + "$CUSTOM_DEFINES" "$AIB_EXTRA_ARGS" "$EXPORT_FORMAT" <<'PYEOF' import json, sys -distro, target, arch, parts, default_parts, builder, aib_ver, aib_img, aib_cmd = sys.argv[1:10] +distro, target, arch, parts, default_parts, builder, aib_ver, aib_img, aib_cmd, task_bundle, custom_defs, extra_args, export_fmt = sys.argv[1:14] a = { "automotive.sdv.cloud.redhat.com/multi-layer": "true", "automotive.sdv.cloud.redhat.com/parts": parts, @@ -327,6 +333,10 @@ if builder: a["automotive.sdv.cloud.redhat.com/builder-image"] if aib_ver: a["automotive.sdv.cloud.redhat.com/aib-version"] = aib_ver if aib_img: a["automotive.sdv.cloud.redhat.com/automotive-image-builder"] = aib_img if aib_cmd: a["automotive.sdv.cloud.redhat.com/aib-command"] = aib_cmd +if task_bundle: a["automotive.sdv.cloud.redhat.com/task-bundle-ref"] = task_bundle +if custom_defs: a["automotive.sdv.cloud.redhat.com/custom-defines"] = custom_defs +if extra_args: a["automotive.sdv.cloud.redhat.com/aib-extra-args"] = extra_args +if export_fmt: a["automotive.sdv.cloud.redhat.com/export-format"] = export_fmt print(json.dumps(a)) PYEOF ) @@ -388,21 +398,26 @@ else trap 'rm -f "$single_annotations_file"' EXIT python3 - "$single_annotations_file" \ "$distro" "$target" "$arch" \ - "$parts_list" "$builder_image_used" "$aib_version" "$aib_image" "$aib_command" <<'PYEOF' + "$parts_list" "$builder_image_used" "$aib_version" "$aib_image" "$aib_command" "$TASK_BUNDLE_REF" \ + "$CUSTOM_DEFINES" "$AIB_EXTRA_ARGS" "$EXPORT_FORMAT" <<'PYEOF' import json, sys from pathlib import Path -out_file, distro, target, arch, parts, builder, aib_ver, aib_img, aib_cmd = sys.argv[1:10] +out_file, distro, target, arch, parts, builder, aib_ver, aib_img, aib_cmd, task_bundle, custom_defs, extra_args, export_fmt = sys.argv[1:14] annotations = { "automotive.sdv.cloud.redhat.com/distro": distro, "automotive.sdv.cloud.redhat.com/target": target, "automotive.sdv.cloud.redhat.com/arch": arch, } -if parts: annotations["automotive.sdv.cloud.redhat.com/parts"] = parts -if builder: annotations["automotive.sdv.cloud.redhat.com/builder-image"] = builder -if aib_ver: annotations["automotive.sdv.cloud.redhat.com/aib-version"] = aib_ver -if aib_img: annotations["automotive.sdv.cloud.redhat.com/automotive-image-builder"] = aib_img -if aib_cmd: annotations["automotive.sdv.cloud.redhat.com/aib-command"] = aib_cmd +if parts: annotations["automotive.sdv.cloud.redhat.com/parts"] = parts +if builder: annotations["automotive.sdv.cloud.redhat.com/builder-image"] = builder +if aib_ver: annotations["automotive.sdv.cloud.redhat.com/aib-version"] = aib_ver +if aib_img: annotations["automotive.sdv.cloud.redhat.com/automotive-image-builder"] = aib_img +if aib_cmd: annotations["automotive.sdv.cloud.redhat.com/aib-command"] = aib_cmd +if task_bundle: annotations["automotive.sdv.cloud.redhat.com/task-bundle-ref"] = task_bundle +if custom_defs: annotations["automotive.sdv.cloud.redhat.com/custom-defines"] = custom_defs +if extra_args: annotations["automotive.sdv.cloud.redhat.com/aib-extra-args"] = extra_args +if export_fmt: annotations["automotive.sdv.cloud.redhat.com/export-format"] = export_fmt Path(out_file).write_text(json.dumps({"$manifest": annotations})) PYEOF @@ -504,3 +519,31 @@ PYEOF else echo "No osbuild manifest found or no digest available, skipping manifest attach" fi + +# attach_referrer FILE ARTIFACT_TYPE LABEL +# Attaches a file as an OCI referrer. Fatal on failure in reproducible mode. +attach_referrer() { + local file="$1" artifact_type="$2" label="$3" + if [ ! -f "$file" ]; then + echo "ERROR: $label not found at $file (required for reproducible build)" + exit 1 + fi + echo "Attaching $label ($(du -sh "$file" | cut -f1)) to ${repo_url}@${DISK_DIGEST}" + if ! "$HOME/bin/oras" attach \ + --artifact-type "$artifact_type" \ + "${repo_url}@${DISK_DIGEST}" \ + "${file}:${artifact_type}"; then + echo "ERROR: Failed to attach $label (fatal in reproducible mode)" + exit 1 + fi +} + +if [ "$REPRODUCIBLE" = "true" ] && [ -n "$DISK_DIGEST" ]; then + cd /workspace/shared || { echo "ERROR: cannot cd to /workspace/shared"; exit 1; } + echo "=== Attaching reproducibility artifacts ===" + attach_referrer "./aib-manifest.yml" \ + "application/vnd.automotive.manifest.v1+yaml" "AIB input manifest" + attach_referrer "./build-sources.tar.gz" \ + "application/vnd.automotive.sources.v1+tar+gzip" "osbuild sources archive" + echo "=== Reproducibility artifacts attached ===" +fi diff --git a/internal/common/tasks/tasks.go b/internal/common/tasks/tasks.go index 9b631c96..94f8be40 100644 --- a/internal/common/tasks/tasks.go +++ b/internal/common/tasks/tasks.go @@ -38,8 +38,8 @@ const ( TaskResolverCluster = "cluster" // TaskResolverBundle resolves tasks from a signed Tekton Bundle OCI image. TaskResolverBundle = "bundle" - // tektonResolverBundles is the Tekton-internal resolver name for bundles (plural). - tektonResolverBundles = "bundles" + // TektonResolverBundles is the Tekton-internal resolver name for OCI bundles. + TektonResolverBundles = "bundles" ) func traceIDParamSpec() tektonv1.ParamSpec { @@ -71,13 +71,27 @@ func traceIDPipelineParam() tektonv1.Param { } } +func pipelinePassthroughParams(names ...string) []tektonv1.Param { + params := make([]tektonv1.Param, len(names)) + for i, name := range names { + params[i] = tektonv1.Param{ + Name: name, + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params." + name + ")", + }, + } + } + return params +} + // buildTaskRef constructs a TaskRef that uses either the cluster resolver or the // bundles resolver, depending on BuildConfig.TaskResolver. func buildTaskRef(taskName, namespace string, buildConfig *BuildConfig) *tektonv1.TaskRef { if buildConfig != nil && buildConfig.TaskResolver == TaskResolverBundle && buildConfig.TaskBundleRef != "" { return &tektonv1.TaskRef{ ResolverRef: tektonv1.ResolverRef{ - Resolver: tektonResolverBundles, + Resolver: TektonResolverBundles, Params: []tektonv1.Param{ { Name: "bundle", @@ -315,6 +329,42 @@ func GeneratePushArtifactRegistryTask(namespace string, buildConfig *BuildConfig StringVal: "false", }, }, + { + Name: "reproducible", + Type: tektonv1.ParamTypeString, + Description: "Attach RPMs and AIB manifest as OCI referrers for reproducibility (true/false)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "false", + }, + }, + { + Name: "task-bundle-ref", + Type: tektonv1.ParamTypeString, + Description: "Digest-pinned Tekton Bundle reference used for this build", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "custom-defines", + Type: tektonv1.ParamTypeString, + Description: "Newline-separated custom build definitions (key=value pairs)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "aib-extra-args", + Type: tektonv1.ParamTypeString, + Description: "Newline-separated extra arguments passed to AIB", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, { Name: "yq-helper-image", Type: tektonv1.ParamTypeString, @@ -536,6 +586,24 @@ func GenerateBuildAutomotiveImageTask(namespace string, buildConfig *BuildConfig StringVal: "false", }, }, + { + Name: "reproducible", + Type: tektonv1.ParamTypeString, + Description: "Save RPMs and manifest as OCI referrers for reproducibility (true/false)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "false", + }, + }, + { + Name: "restore-sources-ref", + Type: tektonv1.ParamTypeString, + Description: "OCI image ref whose sources archive referrer will be restored before build", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, { Name: "yq-helper-image", Type: tektonv1.ParamTypeString, @@ -1053,6 +1121,51 @@ func GenerateTektonPipeline(name, namespace string, buildConfig *BuildConfig) *t StringVal: "false", }, }, + { + Name: "reproducible", + Type: tektonv1.ParamTypeString, + Description: "Save build sources and manifest as OCI referrers for reproduction (true/false)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "false", + }, + }, + { + Name: "task-bundle-ref", + Type: tektonv1.ParamTypeString, + Description: "Digest-pinned OCI reference to the Tekton task bundle used for this build", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "restore-sources-ref", + Type: tektonv1.ParamTypeString, + Description: "OCI image ref whose sources archive referrer will be restored before build", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "custom-defines", + Type: tektonv1.ParamTypeString, + Description: "Newline-separated custom build definitions (key=value pairs)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "aib-extra-args", + Type: tektonv1.ParamTypeString, + Description: "Newline-separated extra arguments passed to AIB", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, traceIDParamSpec(), }, Workspaces: []tektonv1.PipelineWorkspaceDeclaration{ @@ -1113,124 +1226,24 @@ func GenerateTektonPipeline(name, namespace string, buildConfig *BuildConfig) *t { Name: "build-image", TaskRef: buildTaskRef("build-automotive-image", namespace, buildConfig), - Params: []tektonv1.Param{ - { - Name: "target-architecture", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.arch)", - }, - }, - { - Name: "distro", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.distro)", - }, - }, - { - Name: "target", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.target)", + Params: append( + []tektonv1.Param{ + { + Name: "target-architecture", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "$(params.arch)"}, }, }, - { - Name: "mode", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.mode)", - }, - }, - { - Name: "export-format", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.export-format)", - }, - }, - { - Name: "compression", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.compression)", - }, - }, - { - Name: "automotive-image-builder", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.automotive-image-builder)", - }, - }, - { - Name: "container-push", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.container-push)", - }, - }, - { - Name: "build-disk-image", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.build-disk-image)", - }, - }, - { - Name: "export-oci", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.export-oci)", - }, - }, - { - Name: "builder-image", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - // Use pipeline param directly - controller sets this based on mode - // For bootc: points to cluster registry where build-image cached the builder - // For traditional: empty (not needed) - StringVal: "$(params.builder-image)", - }, - }, - { - Name: "cluster-registry-route", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.cluster-registry-route)", - }, - }, - { - Name: "container-ref", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.container-ref)", - }, - }, - { - Name: "rebuild-builder", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.rebuild-builder)", - }, - }, - { - Name: "use-persistent-cache", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.use-persistent-cache)", - }, - }, - { - Name: "yq-helper-image", - Value: tektonv1.ParamValue{ - Type: tektonv1.ParamTypeString, - StringVal: "$(params.yq-helper-image)", - }, - }, - traceIDPipelineParam(), - }, + append( + pipelinePassthroughParams( + "distro", "target", "mode", "export-format", "compression", + "automotive-image-builder", "container-push", "build-disk-image", + "export-oci", "builder-image", "cluster-registry-route", + "container-ref", "rebuild-builder", "use-persistent-cache", + "yq-helper-image", "reproducible", "restore-sources-ref", + ), + traceIDPipelineParam(), + )..., + ), Workspaces: []tektonv1.WorkspacePipelineTaskBinding{ {Name: workspaceNameShared, Workspace: workspaceNameShared}, {Name: "manifest-config-workspace", Workspace: "manifest-config-workspace"}, @@ -1333,6 +1346,34 @@ func GenerateTektonPipeline(name, namespace string, buildConfig *BuildConfig) *t StringVal: "$(params.secure-build)", }, }, + { + Name: "reproducible", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.reproducible)", + }, + }, + { + Name: "task-bundle-ref", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.task-bundle-ref)", + }, + }, + { + Name: "custom-defines", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.custom-defines)", + }, + }, + { + Name: "aib-extra-args", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.aib-extra-args)", + }, + }, { Name: "yq-helper-image", Value: tektonv1.ParamValue{ diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 31e5c851..5da3d7a5 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -1074,6 +1074,41 @@ func (r *ImageBuildReconciler) createBuildTaskRun( StringVal: fmt.Sprintf("%t", imageBuild.Spec.SecureBuild), }, }, + { + Name: "reproducible", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: fmt.Sprintf("%t", imageBuild.Spec.Reproducible), + }, + }, + { + Name: "task-bundle-ref", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: imageBuild.Spec.TaskBundleRef, + }, + }, + { + Name: "restore-sources-ref", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: imageBuild.Spec.RestoreSourcesRef, + }, + }, + { + Name: "custom-defines", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: strings.Join(imageBuild.Spec.GetCustomDefs(), "\n"), + }, + }, + { + Name: "aib-extra-args", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: strings.Join(imageBuild.Spec.GetAIBExtraArgs(), "\n"), + }, + }, { Name: "trace-id", Value: tektonv1.ParamValue{ @@ -1438,11 +1473,17 @@ func (r *ImageBuildReconciler) createBuildTaskRun( }, } - // When using bundle resolver, embed the pipeline spec inline so task refs - // point to the signed bundle. Otherwise reference the cluster-installed pipeline. if buildConfig != nil && buildConfig.TaskResolver == tasks.TaskResolverBundle { - pipeline := tasks.GenerateTektonPipeline("", imageBuild.Namespace, buildConfig) - pipelineRunSpec.PipelineSpec = &pipeline.Spec + pipelineRunSpec.PipelineRef = &tektonv1.PipelineRef{ + ResolverRef: tektonv1.ResolverRef{ + Resolver: tektonv1.ResolverName(tasks.TektonResolverBundles), + Params: tektonv1.Params{ + {Name: "bundle", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: buildConfig.TaskBundleRef}}, + {Name: "name", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "automotive-build-pipeline"}}, + {Name: "kind", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "pipeline"}}, + }, + }, + } } else { pipelineRunSpec.PipelineRef = &tektonv1.PipelineRef{ Name: "automotive-build-pipeline", @@ -1640,6 +1681,41 @@ func (r *ImageBuildReconciler) createPushTaskRun(ctx context.Context, imageBuild StringVal: artifactFilename, }, }, + { + Name: "secure-build", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: fmt.Sprintf("%t", imageBuild.Spec.SecureBuild), + }, + }, + { + Name: "reproducible", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: fmt.Sprintf("%t", imageBuild.Spec.Reproducible), + }, + }, + { + Name: "task-bundle-ref", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: imageBuild.Spec.TaskBundleRef, + }, + }, + { + Name: "custom-defines", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: strings.Join(imageBuild.Spec.GetCustomDefs(), "\n"), + }, + }, + { + Name: "aib-extra-args", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: strings.Join(imageBuild.Spec.GetAIBExtraArgs(), "\n"), + }, + }, { Name: "trace-id", Value: tektonv1.ParamValue{ diff --git a/vendor/modules.txt b/vendor/modules.txt index a3bb61d9..4d176f50 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1580,6 +1580,28 @@ knative.dev/pkg/metrics/metricskey knative.dev/pkg/ptr knative.dev/pkg/tracker knative.dev/pkg/webhook/resourcesemantics +# oras.land/oras-go/v2 v2.6.0 +## explicit; go 1.23.0 +oras.land/oras-go/v2/content +oras.land/oras-go/v2/errdef +oras.land/oras-go/v2/internal/cas +oras.land/oras-go/v2/internal/descriptor +oras.land/oras-go/v2/internal/docker +oras.land/oras-go/v2/internal/httputil +oras.land/oras-go/v2/internal/ioutil +oras.land/oras-go/v2/internal/spec +oras.land/oras-go/v2/internal/syncutil +oras.land/oras-go/v2/registry +oras.land/oras-go/v2/registry/remote +oras.land/oras-go/v2/registry/remote/auth +oras.land/oras-go/v2/registry/remote/credentials +oras.land/oras-go/v2/registry/remote/credentials/internal/config +oras.land/oras-go/v2/registry/remote/credentials/internal/executer +oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil +oras.land/oras-go/v2/registry/remote/credentials/trace +oras.land/oras-go/v2/registry/remote/errcode +oras.land/oras-go/v2/registry/remote/internal/errutil +oras.land/oras-go/v2/registry/remote/retry # sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.0 ## explicit; go 1.21 sigs.k8s.io/apiserver-network-proxy/konnectivity-client/pkg/client diff --git a/vendor/oras.land/oras-go/v2/LICENSE b/vendor/oras.land/oras-go/v2/LICENSE new file mode 100644 index 00000000..a67d1693 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 ORAS Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/oras.land/oras-go/v2/content/descriptor.go b/vendor/oras.land/oras-go/v2/content/descriptor.go new file mode 100644 index 00000000..8e6c25de --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/descriptor.go @@ -0,0 +1,40 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package content + +import ( + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/internal/descriptor" +) + +// NewDescriptorFromBytes returns a descriptor, given the content and media type. +// If no media type is specified, "application/octet-stream" will be used. +func NewDescriptorFromBytes(mediaType string, content []byte) ocispec.Descriptor { + if mediaType == "" { + mediaType = descriptor.DefaultMediaType + } + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: digest.FromBytes(content), + Size: int64(len(content)), + } +} + +// Equal returns true if two descriptors point to the same content. +func Equal(a, b ocispec.Descriptor) bool { + return a.Size == b.Size && a.Digest == b.Digest && a.MediaType == b.MediaType +} diff --git a/vendor/oras.land/oras-go/v2/content/graph.go b/vendor/oras.land/oras-go/v2/content/graph.go new file mode 100644 index 00000000..9ae83728 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/graph.go @@ -0,0 +1,122 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package content + +import ( + "context" + "encoding/json" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/internal/docker" + "oras.land/oras-go/v2/internal/spec" +) + +// PredecessorFinder finds out the nodes directly pointing to a given node of a +// directed acyclic graph. +// In other words, returns the "parents" of the current descriptor. +// PredecessorFinder is an extension of Storage. +type PredecessorFinder interface { + // Predecessors returns the nodes directly pointing to the current node. + Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) +} + +// GraphStorage represents a CAS that supports direct predecessor node finding. +type GraphStorage interface { + Storage + PredecessorFinder +} + +// ReadOnlyGraphStorage represents a read-only GraphStorage. +type ReadOnlyGraphStorage interface { + ReadOnlyStorage + PredecessorFinder +} + +// Successors returns the nodes directly pointed by the current node. +// In other words, returns the "children" of the current descriptor. +func Successors(ctx context.Context, fetcher Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { + switch node.MediaType { + case docker.MediaTypeManifest: + content, err := FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + // OCI manifest schema can be used to marshal docker manifest + var manifest ocispec.Manifest + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, err + } + return append([]ocispec.Descriptor{manifest.Config}, manifest.Layers...), nil + case ocispec.MediaTypeImageManifest: + content, err := FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + var manifest ocispec.Manifest + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, err + } + var nodes []ocispec.Descriptor + if manifest.Subject != nil { + nodes = append(nodes, *manifest.Subject) + } + nodes = append(nodes, manifest.Config) + return append(nodes, manifest.Layers...), nil + case docker.MediaTypeManifestList: + content, err := FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + + // OCI manifest index schema can be used to marshal docker manifest list + var index ocispec.Index + if err := json.Unmarshal(content, &index); err != nil { + return nil, err + } + return index.Manifests, nil + case ocispec.MediaTypeImageIndex: + content, err := FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + + var index ocispec.Index + if err := json.Unmarshal(content, &index); err != nil { + return nil, err + } + var nodes []ocispec.Descriptor + if index.Subject != nil { + nodes = append(nodes, *index.Subject) + } + return append(nodes, index.Manifests...), nil + case spec.MediaTypeArtifactManifest: + content, err := FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + + var manifest spec.Artifact + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, err + } + var nodes []ocispec.Descriptor + if manifest.Subject != nil { + nodes = append(nodes, *manifest.Subject) + } + return append(nodes, manifest.Blobs...), nil + } + return nil, nil +} diff --git a/vendor/oras.land/oras-go/v2/content/limitedstorage.go b/vendor/oras.land/oras-go/v2/content/limitedstorage.go new file mode 100644 index 00000000..9a6df2f8 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/limitedstorage.go @@ -0,0 +1,50 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package content + +import ( + "context" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/errdef" +) + +// LimitedStorage represents a CAS with a push size limit. +type LimitedStorage struct { + Storage // underlying storage + PushLimit int64 // max size for push +} + +// Push pushes the content, matching the expected descriptor. +// The size of the content cannot exceed the push size limit. +func (ls *LimitedStorage) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { + if expected.Size > ls.PushLimit { + return fmt.Errorf( + "content size %v exceeds push size limit %v: %w", + expected.Size, + ls.PushLimit, + errdef.ErrSizeExceedsLimit) + } + + return ls.Storage.Push(ctx, expected, io.LimitReader(content, expected.Size)) +} + +// LimitStorage returns a storage with a push size limit. +func LimitStorage(s Storage, n int64) *LimitedStorage { + return &LimitedStorage{s, n} +} diff --git a/vendor/oras.land/oras-go/v2/content/reader.go b/vendor/oras.land/oras-go/v2/content/reader.go new file mode 100644 index 00000000..37bab5e1 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/reader.go @@ -0,0 +1,149 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package content + +import ( + "errors" + "fmt" + "io" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +var ( + // ErrInvalidDescriptorSize is returned by ReadAll() when + // the descriptor has an invalid size. + ErrInvalidDescriptorSize = errors.New("invalid descriptor size") + + // ErrMismatchedDigest is returned by ReadAll() when + // the descriptor has an invalid digest. + ErrMismatchedDigest = errors.New("mismatched digest") + + // ErrTrailingData is returned by ReadAll() when + // there exists trailing data unread when the read terminates. + ErrTrailingData = errors.New("trailing data") +) + +var ( + // errEarlyVerify is returned by VerifyReader.Verify() when + // Verify() is called before completing reading the entire content blob. + errEarlyVerify = errors.New("early verify") +) + +// VerifyReader reads the content described by its descriptor and verifies +// against its size and digest. +type VerifyReader struct { + base *io.LimitedReader + verifier digest.Verifier + verified bool + err error +} + +// Read reads up to len(p) bytes into p. It returns the number of bytes +// read (0 <= n <= len(p)) and any error encountered. +func (vr *VerifyReader) Read(p []byte) (n int, err error) { + if vr.err != nil { + return 0, vr.err + } + + n, err = vr.base.Read(p) + if err != nil { + if err == io.EOF && vr.base.N > 0 { + err = io.ErrUnexpectedEOF + } + vr.err = err + } + return +} + +// Verify checks for remaining unread content and verifies the read content against the digest +func (vr *VerifyReader) Verify() error { + if vr.verified { + return nil + } + if vr.err == nil { + if vr.base.N > 0 { + return errEarlyVerify + } + } else if vr.err != io.EOF { + return vr.err + } + + if err := ensureEOF(vr.base.R); err != nil { + vr.err = err + return vr.err + } + if !vr.verifier.Verified() { + vr.err = ErrMismatchedDigest + return vr.err + } + + vr.verified = true + vr.err = io.EOF + return nil +} + +// NewVerifyReader wraps r for reading content with verification against desc. +func NewVerifyReader(r io.Reader, desc ocispec.Descriptor) *VerifyReader { + if err := desc.Digest.Validate(); err != nil { + return &VerifyReader{ + err: fmt.Errorf("failed to validate %s: %w", desc.Digest, err), + } + } + verifier := desc.Digest.Verifier() + lr := &io.LimitedReader{ + R: io.TeeReader(r, verifier), + N: desc.Size, + } + return &VerifyReader{ + base: lr, + verifier: verifier, + } +} + +// ReadAll safely reads the content described by the descriptor. +// The read content is verified against the size and the digest +// using a VerifyReader. +func ReadAll(r io.Reader, desc ocispec.Descriptor) ([]byte, error) { + if desc.Size < 0 { + return nil, ErrInvalidDescriptorSize + } + buf := make([]byte, desc.Size) + + vr := NewVerifyReader(r, desc) + if n, err := io.ReadFull(vr, buf); err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + return nil, fmt.Errorf("read failed: expected content size of %d, got %d, for digest %s: %w", desc.Size, n, desc.Digest.String(), err) + } + return nil, fmt.Errorf("read failed: %w", err) + } + if err := vr.Verify(); err != nil { + return nil, err + } + return buf, nil +} + +// ensureEOF ensures the read operation ends with an EOF and no +// trailing data is present. +func ensureEOF(r io.Reader) error { + var peek [1]byte + _, err := io.ReadFull(r, peek[:]) + if err != io.EOF { + return ErrTrailingData + } + return nil +} diff --git a/vendor/oras.land/oras-go/v2/content/resolver.go b/vendor/oras.land/oras-go/v2/content/resolver.go new file mode 100644 index 00000000..bc0fd8df --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/resolver.go @@ -0,0 +1,47 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package content provides implementations to access content stores. +package content + +import ( + "context" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Resolver resolves reference tags. +type Resolver interface { + // Resolve resolves a reference to a descriptor. + Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) +} + +// Tagger tags reference tags. +type Tagger interface { + // Tag tags a descriptor with a reference string. + Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error +} + +// TagResolver provides reference tag indexing services. +type TagResolver interface { + Tagger + Resolver +} + +// Untagger untags reference tags. +type Untagger interface { + // Untag untags the given reference string. + Untag(ctx context.Context, reference string) error +} diff --git a/vendor/oras.land/oras-go/v2/content/storage.go b/vendor/oras.land/oras-go/v2/content/storage.go new file mode 100644 index 00000000..47c95d87 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/content/storage.go @@ -0,0 +1,80 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package content + +import ( + "context" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Fetcher fetches content. +type Fetcher interface { + // Fetch fetches the content identified by the descriptor. + Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) +} + +// Pusher pushes content. +type Pusher interface { + // Push pushes the content, matching the expected descriptor. + // Reader is preferred to Writer so that the suitable buffer size can be + // chosen by the underlying implementation. Furthermore, the implementation + // can also do reflection on the Reader for more advanced I/O optimization. + Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error +} + +// Storage represents a content-addressable storage (CAS) where contents are +// accessed via Descriptors. +// The storage is designed to handle blobs of large sizes. +type Storage interface { + ReadOnlyStorage + Pusher +} + +// ReadOnlyStorage represents a read-only Storage. +type ReadOnlyStorage interface { + Fetcher + + // Exists returns true if the described content exists. + Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) +} + +// Deleter removes content. +// Deleter is an extension of Storage. +type Deleter interface { + // Delete removes the content identified by the descriptor. + Delete(ctx context.Context, target ocispec.Descriptor) error +} + +// FetchAll safely fetches the content described by the descriptor. +// The fetched content is verified against the size and the digest. +func FetchAll(ctx context.Context, fetcher Fetcher, desc ocispec.Descriptor) ([]byte, error) { + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + return nil, err + } + defer rc.Close() + return ReadAll(rc, desc) +} + +// FetcherFunc is the basic Fetch method defined in Fetcher. +type FetcherFunc func(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) + +// Fetch performs Fetch operation by the FetcherFunc. +func (fn FetcherFunc) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + return fn(ctx, target) +} diff --git a/vendor/oras.land/oras-go/v2/errdef/errors.go b/vendor/oras.land/oras-go/v2/errdef/errors.go new file mode 100644 index 00000000..7adb44b1 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/errdef/errors.go @@ -0,0 +1,31 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errdef + +import "errors" + +// Common errors used in ORAS +var ( + ErrAlreadyExists = errors.New("already exists") + ErrInvalidDigest = errors.New("invalid digest") + ErrInvalidReference = errors.New("invalid reference") + ErrInvalidMediaType = errors.New("invalid media type") + ErrMissingReference = errors.New("missing reference") + ErrNotFound = errors.New("not found") + ErrSizeExceedsLimit = errors.New("size exceeds limit") + ErrUnsupported = errors.New("unsupported") + ErrUnsupportedVersion = errors.New("unsupported version") +) diff --git a/vendor/oras.land/oras-go/v2/internal/cas/memory.go b/vendor/oras.land/oras-go/v2/internal/cas/memory.go new file mode 100644 index 00000000..7e358e13 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/cas/memory.go @@ -0,0 +1,88 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cas + +import ( + "bytes" + "context" + "fmt" + "io" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + contentpkg "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/internal/descriptor" +) + +// Memory is a memory based CAS. +type Memory struct { + content sync.Map // map[descriptor.Descriptor][]byte +} + +// NewMemory creates a new Memory CAS. +func NewMemory() *Memory { + return &Memory{} +} + +// Fetch fetches the content identified by the descriptor. +func (m *Memory) Fetch(_ context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + key := descriptor.FromOCI(target) + content, exists := m.content.Load(key) + if !exists { + return nil, fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrNotFound) + } + return io.NopCloser(bytes.NewReader(content.([]byte))), nil +} + +// Push pushes the content, matching the expected descriptor. +func (m *Memory) Push(_ context.Context, expected ocispec.Descriptor, content io.Reader) error { + key := descriptor.FromOCI(expected) + + // check if the content exists in advance to avoid reading from the content. + if _, exists := m.content.Load(key); exists { + return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists) + } + + // read and try to store the content. + value, err := contentpkg.ReadAll(content, expected) + if err != nil { + return err + } + if _, exists := m.content.LoadOrStore(key, value); exists { + return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists) + } + return nil +} + +// Exists returns true if the described content exists. +func (m *Memory) Exists(_ context.Context, target ocispec.Descriptor) (bool, error) { + key := descriptor.FromOCI(target) + _, exists := m.content.Load(key) + return exists, nil +} + +// Map dumps the memory into a built-in map structure. +// Like other operations, calling Map() is go-routine safe. However, it does not +// necessarily correspond to any consistent snapshot of the storage contents. +func (m *Memory) Map() map[descriptor.Descriptor][]byte { + res := make(map[descriptor.Descriptor][]byte) + m.content.Range(func(key, value interface{}) bool { + res[key.(descriptor.Descriptor)] = value.([]byte) + return true + }) + return res +} diff --git a/vendor/oras.land/oras-go/v2/internal/cas/proxy.go b/vendor/oras.land/oras-go/v2/internal/cas/proxy.go new file mode 100644 index 00000000..ada5f94e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/cas/proxy.go @@ -0,0 +1,125 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cas + +import ( + "context" + "io" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/internal/ioutil" +) + +// Proxy is a caching proxy for the storage. +// The first fetch call of a described content will read from the remote and +// cache the fetched content. +// The subsequent fetch call will read from the local cache. +type Proxy struct { + content.ReadOnlyStorage + Cache content.Storage + StopCaching bool +} + +// NewProxy creates a proxy for the `base` storage, using the `cache` storage as +// the cache. +func NewProxy(base content.ReadOnlyStorage, cache content.Storage) *Proxy { + return &Proxy{ + ReadOnlyStorage: base, + Cache: cache, + } +} + +// NewProxyWithLimit creates a proxy for the `base` storage, using the `cache` +// storage with a push size limit as the cache. +func NewProxyWithLimit(base content.ReadOnlyStorage, cache content.Storage, pushLimit int64) *Proxy { + limitedCache := content.LimitStorage(cache, pushLimit) + return &Proxy{ + ReadOnlyStorage: base, + Cache: limitedCache, + } +} + +// Fetch fetches the content identified by the descriptor. +func (p *Proxy) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + if p.StopCaching { + return p.FetchCached(ctx, target) + } + + rc, err := p.Cache.Fetch(ctx, target) + if err == nil { + return rc, nil + } + + rc, err = p.ReadOnlyStorage.Fetch(ctx, target) + if err != nil { + return nil, err + } + pr, pw := io.Pipe() + var wg sync.WaitGroup + wg.Add(1) + var pushErr error + go func() { + defer wg.Done() + pushErr = p.Cache.Push(ctx, target, pr) + if pushErr != nil { + pr.CloseWithError(pushErr) + } + }() + closer := ioutil.CloserFunc(func() error { + rcErr := rc.Close() + if err := pw.Close(); err != nil { + return err + } + wg.Wait() + if pushErr != nil { + return pushErr + } + return rcErr + }) + + return struct { + io.Reader + io.Closer + }{ + Reader: io.TeeReader(rc, pw), + Closer: closer, + }, nil +} + +// FetchCached fetches the content identified by the descriptor. +// If the content is not cached, it will be fetched from the remote without +// caching. +func (p *Proxy) FetchCached(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + exists, err := p.Cache.Exists(ctx, target) + if err != nil { + return nil, err + } + if exists { + return p.Cache.Fetch(ctx, target) + } + return p.ReadOnlyStorage.Fetch(ctx, target) +} + +// Exists returns true if the described content exists. +func (p *Proxy) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { + exists, err := p.Cache.Exists(ctx, target) + if err == nil && exists { + return true, nil + } + return p.ReadOnlyStorage.Exists(ctx, target) +} diff --git a/vendor/oras.land/oras-go/v2/internal/descriptor/descriptor.go b/vendor/oras.land/oras-go/v2/internal/descriptor/descriptor.go new file mode 100644 index 00000000..b9b339c0 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/descriptor/descriptor.go @@ -0,0 +1,89 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package descriptor + +import ( + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/internal/docker" + "oras.land/oras-go/v2/internal/spec" +) + +// DefaultMediaType is the media type used when no media type is specified. +const DefaultMediaType string = "application/octet-stream" + +// Descriptor contains the minimun information to describe the disposition of +// targeted content. +// Since it only has strings and integers, Descriptor is a comparable struct. +type Descriptor struct { + // MediaType is the media type of the object this schema refers to. + MediaType string `json:"mediaType,omitempty"` + + // Digest is the digest of the targeted content. + Digest digest.Digest `json:"digest"` + + // Size specifies the size in bytes of the blob. + Size int64 `json:"size"` +} + +// Empty is an empty descriptor +var Empty Descriptor + +// FromOCI shrinks the OCI descriptor to the minimum. +func FromOCI(desc ocispec.Descriptor) Descriptor { + return Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + } +} + +// IsForeignLayer checks if a descriptor describes a foreign layer. +func IsForeignLayer(desc ocispec.Descriptor) bool { + switch desc.MediaType { + case ocispec.MediaTypeImageLayerNonDistributable, + ocispec.MediaTypeImageLayerNonDistributableGzip, + ocispec.MediaTypeImageLayerNonDistributableZstd, + docker.MediaTypeForeignLayer: + return true + default: + return false + } +} + +// IsManifest checks if a descriptor describes a manifest. +func IsManifest(desc ocispec.Descriptor) bool { + switch desc.MediaType { + case docker.MediaTypeManifest, + docker.MediaTypeManifestList, + ocispec.MediaTypeImageManifest, + ocispec.MediaTypeImageIndex, + spec.MediaTypeArtifactManifest: + return true + default: + return false + } +} + +// Plain returns a plain descriptor that contains only MediaType, Digest and +// Size. +func Plain(desc ocispec.Descriptor) ocispec.Descriptor { + return ocispec.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + } +} diff --git a/vendor/oras.land/oras-go/v2/internal/docker/mediatype.go b/vendor/oras.land/oras-go/v2/internal/docker/mediatype.go new file mode 100644 index 00000000..76a4ba9e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/docker/mediatype.go @@ -0,0 +1,24 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +// docker media types +const ( + MediaTypeConfig = "application/vnd.docker.container.image.v1+json" + MediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" + MediaTypeManifest = "application/vnd.docker.distribution.manifest.v2+json" + MediaTypeForeignLayer = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip" +) diff --git a/vendor/oras.land/oras-go/v2/internal/httputil/seek.go b/vendor/oras.land/oras-go/v2/internal/httputil/seek.go new file mode 100644 index 00000000..3fa14e2d --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/httputil/seek.go @@ -0,0 +1,116 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httputil + +import ( + "errors" + "fmt" + "io" + "net/http" +) + +// Client is an interface for a HTTP client. +// This interface is defined inside this package to prevent potential import +// loop. +type Client interface { + // Do sends an HTTP request and returns an HTTP response. + Do(*http.Request) (*http.Response, error) +} + +// readSeekCloser seeks http body by starting new connections. +type readSeekCloser struct { + client Client + req *http.Request + rc io.ReadCloser + size int64 + offset int64 + closed bool +} + +// NewReadSeekCloser returns a seeker to make the HTTP response seekable. +// Callers should ensure that the server supports Range request. +func NewReadSeekCloser(client Client, req *http.Request, respBody io.ReadCloser, size int64) io.ReadSeekCloser { + return &readSeekCloser{ + client: client, + req: req, + rc: respBody, + size: size, + } +} + +// Read reads the content body and counts offset. +func (rsc *readSeekCloser) Read(p []byte) (n int, err error) { + if rsc.closed { + return 0, errors.New("read: already closed") + } + n, err = rsc.rc.Read(p) + rsc.offset += int64(n) + return +} + +// Seek starts a new connection to the remote for reading if position changes. +func (rsc *readSeekCloser) Seek(offset int64, whence int) (int64, error) { + if rsc.closed { + return 0, errors.New("seek: already closed") + } + switch whence { + case io.SeekCurrent: + offset += rsc.offset + case io.SeekStart: + // no-op + case io.SeekEnd: + offset += rsc.size + default: + return 0, errors.New("seek: invalid whence") + } + if offset < 0 { + return 0, errors.New("seek: an attempt was made to move the pointer before the beginning of the content") + } + if offset == rsc.offset { + return offset, nil + } + if offset >= rsc.size { + rsc.rc.Close() + rsc.rc = http.NoBody + rsc.offset = offset + return offset, nil + } + + req := rsc.req.Clone(rsc.req.Context()) + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, rsc.size-1)) + resp, err := rsc.client.Do(req) + if err != nil { + return 0, fmt.Errorf("seek: %s %q: %w", req.Method, req.URL, err) + } + if resp.StatusCode != http.StatusPartialContent { + resp.Body.Close() + return 0, fmt.Errorf("seek: %s %q: unexpected status code %d", resp.Request.Method, resp.Request.URL, resp.StatusCode) + } + + rsc.rc.Close() + rsc.rc = resp.Body + rsc.offset = offset + return offset, nil +} + +// Close closes the content body. +func (rsc *readSeekCloser) Close() error { + if rsc.closed { + return nil + } + rsc.closed = true + return rsc.rc.Close() +} diff --git a/vendor/oras.land/oras-go/v2/internal/ioutil/io.go b/vendor/oras.land/oras-go/v2/internal/ioutil/io.go new file mode 100644 index 00000000..de41bda9 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/ioutil/io.go @@ -0,0 +1,66 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ioutil + +import ( + "fmt" + "io" + "reflect" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" +) + +// CloserFunc is the basic Close method defined in io.Closer. +type CloserFunc func() error + +// Close performs close operation by the CloserFunc. +func (fn CloserFunc) Close() error { + return fn() +} + +// CopyBuffer copies from src to dst through the provided buffer +// until either EOF is reached on src, or an error occurs. +// The copied content is verified against the size and the digest. +func CopyBuffer(dst io.Writer, src io.Reader, buf []byte, desc ocispec.Descriptor) error { + // verify while copying + vr := content.NewVerifyReader(src, desc) + if _, err := io.CopyBuffer(dst, vr, buf); err != nil { + return fmt.Errorf("copy failed: %w", err) + } + return vr.Verify() +} + +// Types returned by `io.NopCloser()`. +var ( + nopCloserType = reflect.TypeOf(io.NopCloser(nil)) + nopCloserWriterToType = reflect.TypeOf(io.NopCloser(struct { + io.Reader + io.WriterTo + }{})) +) + +// UnwrapNopCloser unwraps the reader wrapped by `io.NopCloser()`. +// Similar implementation can be found in the built-in package `net/http`. +// Reference: https://github.com/golang/go/blob/go1.22.1/src/net/http/transfer.go#L1090-L1105 +func UnwrapNopCloser(r io.Reader) io.Reader { + switch reflect.TypeOf(r) { + case nopCloserType, nopCloserWriterToType: + return reflect.ValueOf(r).Field(0).Interface().(io.Reader) + default: + return r + } +} diff --git a/vendor/oras.land/oras-go/v2/internal/spec/artifact.go b/vendor/oras.land/oras-go/v2/internal/spec/artifact.go new file mode 100644 index 00000000..7f801fd9 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/spec/artifact.go @@ -0,0 +1,57 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package spec + +import ocispec "github.com/opencontainers/image-spec/specs-go/v1" + +const ( + // AnnotationArtifactCreated is the annotation key for the date and time on which the artifact was built, conforming to RFC 3339. + AnnotationArtifactCreated = "org.opencontainers.artifact.created" + + // AnnotationArtifactDescription is the annotation key for the human readable description for the artifact. + AnnotationArtifactDescription = "org.opencontainers.artifact.description" + + // AnnotationReferrersFiltersApplied is the annotation key for the comma separated list of filters applied by the registry in the referrers listing. + AnnotationReferrersFiltersApplied = "org.opencontainers.referrers.filtersApplied" +) + +// MediaTypeArtifactManifest specifies the media type for a content descriptor. +const MediaTypeArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json" + +// Artifact describes an artifact manifest. +// This structure provides `application/vnd.oci.artifact.manifest.v1+json` mediatype when marshalled to JSON. +// +// This manifest type was introduced in image-spec v1.1.0-rc1 and was removed in +// image-spec v1.1.0-rc3. It is not part of the current image-spec and is kept +// here for Go compatibility. +// +// Reference: https://github.com/opencontainers/image-spec/pull/999 +type Artifact struct { + // MediaType is the media type of the object this schema refers to. + MediaType string `json:"mediaType"` + + // ArtifactType is the IANA media type of the artifact this schema refers to. + ArtifactType string `json:"artifactType"` + + // Blobs is a collection of blobs referenced by this manifest. + Blobs []ocispec.Descriptor `json:"blobs,omitempty"` + + // Subject (reference) is an optional link from the artifact to another manifest forming an association between the artifact and the other manifest. + Subject *ocispec.Descriptor `json:"subject,omitempty"` + + // Annotations contains arbitrary metadata for the artifact manifest. + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/vendor/oras.land/oras-go/v2/internal/syncutil/limit.go b/vendor/oras.land/oras-go/v2/internal/syncutil/limit.go new file mode 100644 index 00000000..e429f24f --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/syncutil/limit.go @@ -0,0 +1,107 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncutil + +import ( + "context" + + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" +) + +// LimitedRegion provides a way to bound concurrent access to a code block. +type LimitedRegion struct { + ctx context.Context + limiter *semaphore.Weighted + ended bool +} + +// LimitRegion creates a new LimitedRegion. +func LimitRegion(ctx context.Context, limiter *semaphore.Weighted) *LimitedRegion { + if limiter == nil { + return nil + } + return &LimitedRegion{ + ctx: ctx, + limiter: limiter, + ended: true, + } +} + +// Start starts the region with concurrency limit. +func (lr *LimitedRegion) Start() error { + if lr == nil || !lr.ended { + return nil + } + if err := lr.limiter.Acquire(lr.ctx, 1); err != nil { + return err + } + lr.ended = false + return nil +} + +// End ends the region with concurrency limit. +func (lr *LimitedRegion) End() { + if lr == nil || lr.ended { + return + } + lr.limiter.Release(1) + lr.ended = true +} + +// GoFunc represents a function that can be invoked by Go. +type GoFunc[T any] func(ctx context.Context, region *LimitedRegion, t T) error + +// Go concurrently invokes fn on items. +func Go[T any](ctx context.Context, limiter *semaphore.Weighted, fn GoFunc[T], items ...T) error { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + eg, egCtx := errgroup.WithContext(ctx) + for _, item := range items { + region := LimitRegion(egCtx, limiter) + if err := region.Start(); err != nil { + cancel(err) + // break loop instead of returning to allow previously scheduled + // goroutines to finish their deferred region.End() calls + break + } + + eg.Go(func(t T, lr *LimitedRegion) func() error { + return func() error { + defer lr.End() + + select { + case <-egCtx.Done(): + // skip the task if the context is already cancelled + return nil + default: + } + + if err := fn(egCtx, lr, t); err != nil { + cancel(err) + return err + } + return nil + } + }(item, region)) + } + + if err := eg.Wait(); err != nil { + cancel(err) + } + return context.Cause(ctx) +} diff --git a/vendor/oras.land/oras-go/v2/internal/syncutil/limitgroup.go b/vendor/oras.land/oras-go/v2/internal/syncutil/limitgroup.go new file mode 100644 index 00000000..4ef087dc --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/syncutil/limitgroup.go @@ -0,0 +1,67 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncutil + +import ( + "context" + + "golang.org/x/sync/errgroup" +) + +// LimitedGroup is a collection of goroutines working on subtasks that are part of +// the same overall task. +type LimitedGroup struct { + grp *errgroup.Group + ctx context.Context +} + +// LimitGroup returns a new LimitedGroup and an associated Context derived from ctx. +// +// The number of active goroutines in this group is limited to the given limit. +// A negative value indicates no limit. +// +// The derived Context is canceled the first time a function passed to Go +// returns a non-nil error or the first time Wait returns, whichever occurs +// first. +func LimitGroup(ctx context.Context, limit int) (*LimitedGroup, context.Context) { + grp, ctx := errgroup.WithContext(ctx) + grp.SetLimit(limit) + return &LimitedGroup{grp: grp, ctx: ctx}, ctx +} + +// Go calls the given function in a new goroutine. +// It blocks until the new goroutine can be added without the number of +// active goroutines in the group exceeding the configured limit. +// +// The first call to return a non-nil error cancels the group's context. +// After which, any subsequent calls to Go will not execute their given function. +// The error will be returned by Wait. +func (g *LimitedGroup) Go(f func() error) { + g.grp.Go(func() error { + select { + case <-g.ctx.Done(): + return g.ctx.Err() + default: + return f() + } + }) +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the first non-nil error (if any) from them. +func (g *LimitedGroup) Wait() error { + return g.grp.Wait() +} diff --git a/vendor/oras.land/oras-go/v2/internal/syncutil/merge.go b/vendor/oras.land/oras-go/v2/internal/syncutil/merge.go new file mode 100644 index 00000000..44788990 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/syncutil/merge.go @@ -0,0 +1,140 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncutil + +import "sync" + +// mergeStatus represents the merge status of an item. +type mergeStatus struct { + // main indicates if items are being merged by the current go-routine. + main bool + // err represents the error of the merge operation. + err error +} + +// Merge represents merge operations on items. +// The state transfer is shown as below: +// +// +----------+ +// | Start +--------+-------------+ +// +----+-----+ | | +// | | | +// v v v +// +----+-----+ +----+----+ +----+----+ +// +-------+ Prepare +<--+ Pending +-->+ Waiting | +// | +----+-----+ +---------+ +----+----+ +// | | | +// | v | +// | + ---+---- + | +// On Error | Resolve | | +// | + ---+---- + | +// | | | +// | v | +// | +----+-----+ | +// +------>+ Complete +<---------------------+ +// +----+-----+ +// | +// v +// +----+-----+ +// | End | +// +----------+ +type Merge[T any] struct { + lock sync.Mutex + committed bool + items []T + status chan mergeStatus + pending []T + pendingStatus chan mergeStatus +} + +// Do merges concurrent operations of items into a single call of prepare and +// resolve. +// If Do is called multiple times concurrently, only one of the calls will be +// selected to invoke prepare and resolve. +func (m *Merge[T]) Do(item T, prepare func() error, resolve func(items []T) error) error { + status := <-m.assign(item) + if status.main { + err := prepare() + items := m.commit() + if err == nil { + err = resolve(items) + } + m.complete(err) + return err + } + return status.err +} + +// assign adds a new item into the item list. +func (m *Merge[T]) assign(item T) <-chan mergeStatus { + m.lock.Lock() + defer m.lock.Unlock() + + if m.committed { + if m.pendingStatus == nil { + m.pendingStatus = make(chan mergeStatus, 1) + } + m.pending = append(m.pending, item) + return m.pendingStatus + } + + if m.status == nil { + m.status = make(chan mergeStatus, 1) + m.status <- mergeStatus{main: true} + } + m.items = append(m.items, item) + return m.status +} + +// commit closes the assignment window, and the assigned items will be ready +// for resolve. +func (m *Merge[T]) commit() []T { + m.lock.Lock() + defer m.lock.Unlock() + + m.committed = true + return m.items +} + +// complete completes the previous merge, and moves the pending items to the +// stage for the next merge. +func (m *Merge[T]) complete(err error) { + // notify results + if err == nil { + close(m.status) + } else { + remaining := len(m.items) - 1 + status := m.status + for remaining > 0 { + status <- mergeStatus{err: err} + remaining-- + } + } + + // move pending items to the stage + m.lock.Lock() + defer m.lock.Unlock() + + m.committed = false + m.items = m.pending + m.status = m.pendingStatus + m.pending = nil + m.pendingStatus = nil + + if m.status != nil { + m.status <- mergeStatus{main: true} + } +} diff --git a/vendor/oras.land/oras-go/v2/internal/syncutil/once.go b/vendor/oras.land/oras-go/v2/internal/syncutil/once.go new file mode 100644 index 00000000..e4497053 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/syncutil/once.go @@ -0,0 +1,102 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncutil + +import ( + "context" + "sync" + "sync/atomic" +) + +// Once is an object that will perform exactly one action. +// Unlike sync.Once, this Once allows the action to have return values. +type Once struct { + result interface{} + err error + status chan bool +} + +// NewOnce creates a new Once instance. +func NewOnce() *Once { + status := make(chan bool, 1) + status <- true + return &Once{ + status: status, + } +} + +// Do calls the function f if and only if Do is being called first time or all +// previous function calls are cancelled, deadline exceeded, or panicking. +// When `once.Do(ctx, f)` is called multiple times, the return value of the +// first call of the function f is stored, and is directly returned for other +// calls. +// Besides the return value of the function f, including the error, Do returns +// true if the function f passed is called first and is not cancelled, deadline +// exceeded, or panicking. Otherwise, returns false. +func (o *Once) Do(ctx context.Context, f func() (interface{}, error)) (bool, interface{}, error) { + defer func() { + if r := recover(); r != nil { + o.status <- true + panic(r) + } + }() + for { + select { + case inProgress := <-o.status: + if !inProgress { + return false, o.result, o.err + } + result, err := f() + if err == context.Canceled || err == context.DeadlineExceeded { + o.status <- true + return false, nil, err + } + o.result, o.err = result, err + close(o.status) + return true, result, err + case <-ctx.Done(): + return false, nil, ctx.Err() + } + } +} + +// OnceOrRetry is an object that will perform exactly one success action. +type OnceOrRetry struct { + done atomic.Bool + lock sync.Mutex +} + +// OnceOrRetry calls the function f if and only if Do is being called for the +// first time for this instance of Once or all previous calls to Do are failed. +func (o *OnceOrRetry) Do(f func() error) error { + // fast path + if o.done.Load() { + return nil + } + + // slow path + o.lock.Lock() + defer o.lock.Unlock() + + if o.done.Load() { + return nil + } + if err := f(); err != nil { + return err + } + o.done.Store(true) + return nil +} diff --git a/vendor/oras.land/oras-go/v2/internal/syncutil/pool.go b/vendor/oras.land/oras-go/v2/internal/syncutil/pool.go new file mode 100644 index 00000000..6fb4a69c --- /dev/null +++ b/vendor/oras.land/oras-go/v2/internal/syncutil/pool.go @@ -0,0 +1,64 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncutil + +import "sync" + +// poolItem represents an item in Pool. +type poolItem[T any] struct { + value T + refCount int +} + +// Pool is a scalable pool with items identified by keys. +type Pool[T any] struct { + // New optionally specifies a function to generate a value when Get would + // otherwise return nil. + // It may not be changed concurrently with calls to Get. + New func() T + + lock sync.Mutex + items map[any]*poolItem[T] +} + +// Get gets the value identified by key. +// The caller should invoke the returned function after using the returned item. +func (p *Pool[T]) Get(key any) (*T, func()) { + p.lock.Lock() + defer p.lock.Unlock() + + item, ok := p.items[key] + if !ok { + if p.items == nil { + p.items = make(map[any]*poolItem[T]) + } + item = &poolItem[T]{} + if p.New != nil { + item.value = p.New() + } + p.items[key] = item + } + item.refCount++ + + return &item.value, func() { + p.lock.Lock() + defer p.lock.Unlock() + item.refCount-- + if item.refCount <= 0 { + delete(p.items, key) + } + } +} diff --git a/vendor/oras.land/oras-go/v2/registry/reference.go b/vendor/oras.land/oras-go/v2/registry/reference.go new file mode 100644 index 00000000..54c72fd2 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/reference.go @@ -0,0 +1,276 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/opencontainers/go-digest" + "oras.land/oras-go/v2/errdef" +) + +// regular expressions for components. +var ( + // repositoryRegexp is adapted from the distribution implementation. The + // repository name set under OCI distribution spec is a subset of the docker + // spec. For maximum compatability, the docker spec is verified client-side. + // Further checks are left to the server-side. + // + // References: + // - https://github.com/distribution/distribution/blob/v2.7.1/reference/regexp.go#L53 + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests + repositoryRegexp = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`) + + // tagRegexp checks the tag name. + // The docker and OCI spec have the same regular expression. + // + // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests + tagRegexp = regexp.MustCompile(`^[\w][\w.-]{0,127}$`) +) + +// Reference references either a resource descriptor (where Reference.Reference +// is a tag or a digest), or a resource repository (where Reference.Reference +// is the empty string). +type Reference struct { + // Registry is the name of the registry. It is usually the domain name of + // the registry optionally with a port. + Registry string + + // Repository is the name of the repository. + Repository string + + // Reference is the reference of the object in the repository. This field + // can take any one of the four valid forms (see ParseReference). In the + // case where it's the empty string, it necessarily implies valid form D, + // and where it is non-empty, then it is either a tag, or a digest + // (implying one of valid forms A, B, or C). + Reference string +} + +// ParseReference parses a string (artifact) into an `artifact reference`. +// Corresponding cryptographic hash implementations are required to be imported +// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage +// if the string contains a digest. +// +// Note: An "image" is an "artifact", however, an "artifact" is not necessarily +// an "image". +// +// The token `artifact` is composed of other tokens, and those in turn are +// composed of others. This definition recursivity requires a notation capable +// of recursion, thus the following two forms have been adopted: +// +// 1. Backus–Naur Form (BNF) has been adopted to address the recursive nature +// of the definition. +// 2. Token opacity is revealed via its label letter-casing. That is, "opaque" +// tokens (i.e., tokens that are not final, and must therefore be further +// broken down into their constituents) are denoted in *lowercase*, while +// final tokens (i.e., leaf-node tokens that are final) are denoted in +// *uppercase*. +// +// Finally, note that a number of the opaque tokens are polymorphic in nature; +// that is, they can take on one of numerous forms, not restricted to a single +// defining form. +// +// The top-level token, `artifact`, is composed of two (opaque) tokens; namely +// `socketaddr` and `path`: +// +// ::= "/" +// +// The former is described as follows: +// +// ::= | ":" +// ::= | +// ::= | +// +// The latter, which is of greater interest here, is described as follows: +// +// ::= | +// ::= "@" | ":" "@" | ":" +// ::= ":" +// +// This second token--`path`--can take on exactly four forms, each of which will +// now be illustrated: +// +// <--- path --------------------------------------------> | - Decode `path` +// <=== REPOSITORY ===> <--- reference ------------------> | - Decode `reference` +// <=== REPOSITORY ===> @ <=================== digest ===> | - Valid Form A +// <=== REPOSITORY ===> : @ <=== digest ===> | - Valid Form B (tag is dropped) +// <=== REPOSITORY ===> : <=== TAG ======================> | - Valid Form C +// <=== REPOSITORY ======================================> | - Valid Form D +// +// Note: In the case of Valid Form B, TAG is dropped without any validation or +// further consideration. +func ParseReference(artifact string) (Reference, error) { + parts := strings.SplitN(artifact, "/", 2) + if len(parts) == 1 { + // Invalid Form + return Reference{}, fmt.Errorf("%w: missing registry or repository", errdef.ErrInvalidReference) + } + registry, path := parts[0], parts[1] + + var isTag bool + var repository string + var reference string + if index := strings.Index(path, "@"); index != -1 { + // `digest` found; Valid Form A (if not B) + isTag = false + repository = path[:index] + reference = path[index+1:] + + if index = strings.Index(repository, ":"); index != -1 { + // `tag` found (and now dropped without validation) since `the + // `digest` already present; Valid Form B + repository = repository[:index] + } + } else if index = strings.Index(path, ":"); index != -1 { + // `tag` found; Valid Form C + isTag = true + repository = path[:index] + reference = path[index+1:] + } else { + // empty `reference`; Valid Form D + repository = path + } + ref := Reference{ + Registry: registry, + Repository: repository, + Reference: reference, + } + + if err := ref.ValidateRegistry(); err != nil { + return Reference{}, err + } + + if err := ref.ValidateRepository(); err != nil { + return Reference{}, err + } + + if len(ref.Reference) == 0 { + return ref, nil + } + + validator := ref.ValidateReferenceAsDigest + if isTag { + validator = ref.ValidateReferenceAsTag + } + if err := validator(); err != nil { + return Reference{}, err + } + + return ref, nil +} + +// Validate the entire reference object; the registry, the repository, and the +// reference. +func (r Reference) Validate() error { + if err := r.ValidateRegistry(); err != nil { + return err + } + + if err := r.ValidateRepository(); err != nil { + return err + } + + return r.ValidateReference() +} + +// ValidateRegistry validates the registry. +func (r Reference) ValidateRegistry() error { + if uri, err := url.ParseRequestURI("dummy://" + r.Registry); err != nil || uri.Host == "" || uri.Host != r.Registry { + return fmt.Errorf("%w: invalid registry %q", errdef.ErrInvalidReference, r.Registry) + } + return nil +} + +// ValidateRepository validates the repository. +func (r Reference) ValidateRepository() error { + if !repositoryRegexp.MatchString(r.Repository) { + return fmt.Errorf("%w: invalid repository %q", errdef.ErrInvalidReference, r.Repository) + } + return nil +} + +// ValidateReferenceAsTag validates the reference as a tag. +func (r Reference) ValidateReferenceAsTag() error { + if !tagRegexp.MatchString(r.Reference) { + return fmt.Errorf("%w: invalid tag %q", errdef.ErrInvalidReference, r.Reference) + } + return nil +} + +// ValidateReferenceAsDigest validates the reference as a digest. +func (r Reference) ValidateReferenceAsDigest() error { + if _, err := r.Digest(); err != nil { + return fmt.Errorf("%w: invalid digest %q: %v", errdef.ErrInvalidReference, r.Reference, err) + } + return nil +} + +// ValidateReference where the reference is first tried as an ampty string, then +// as a digest, and if that fails, as a tag. +func (r Reference) ValidateReference() error { + if len(r.Reference) == 0 { + return nil + } + + if index := strings.IndexByte(r.Reference, ':'); index != -1 { + return r.ValidateReferenceAsDigest() + } + + return r.ValidateReferenceAsTag() +} + +// Host returns the host name of the registry. +func (r Reference) Host() string { + if r.Registry == "docker.io" { + return "registry-1.docker.io" + } + return r.Registry +} + +// ReferenceOrDefault returns the reference or the default reference if empty. +func (r Reference) ReferenceOrDefault() string { + if r.Reference == "" { + return "latest" + } + return r.Reference +} + +// Digest returns the reference as a digest. +// Corresponding cryptographic hash implementations are required to be imported +// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage +func (r Reference) Digest() (digest.Digest, error) { + return digest.Parse(r.Reference) +} + +// String implements `fmt.Stringer` and returns the reference string. +// The resulted string is meaningful only if the reference is valid. +func (r Reference) String() string { + if r.Repository == "" { + return r.Registry + } + ref := r.Registry + "/" + r.Repository + if r.Reference == "" { + return ref + } + if d, err := r.Digest(); err == nil { + return ref + "@" + d.String() + } + return ref + ":" + r.Reference +} diff --git a/vendor/oras.land/oras-go/v2/registry/registry.go b/vendor/oras.land/oras-go/v2/registry/registry.go new file mode 100644 index 00000000..4736efa8 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/registry.go @@ -0,0 +1,52 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package registry provides high-level operations to manage registries. +package registry + +import "context" + +// Registry represents a collection of repositories. +type Registry interface { + // Repositories lists the name of repositories available in the registry. + // Since the returned repositories may be paginated by the underlying + // implementation, a function should be passed in to process the paginated + // repository list. + // `last` argument is the `last` parameter when invoking the catalog API. + // If `last` is NOT empty, the entries in the response start after the + // repo specified by `last`. Otherwise, the response starts from the top + // of the Repositories list. + // Note: When implemented by a remote registry, the catalog API is called. + // However, not all registries supports pagination or conforms the + // specification. + // Reference: https://distribution.github.io/distribution/spec/api/#catalog + // See also `Repositories()` in this package. + Repositories(ctx context.Context, last string, fn func(repos []string) error) error + + // Repository returns a repository reference by the given name. + Repository(ctx context.Context, name string) (Repository, error) +} + +// Repositories lists the name of repositories available in the registry. +func Repositories(ctx context.Context, reg Registry) ([]string, error) { + var res []string + if err := reg.Repositories(ctx, "", func(repos []string) error { + res = append(res, repos...) + return nil + }); err != nil { + return nil, err + } + return res, nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/auth/cache.go b/vendor/oras.land/oras-go/v2/registry/remote/auth/cache.go new file mode 100644 index 00000000..d11c092b --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/auth/cache.go @@ -0,0 +1,232 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "context" + "strings" + "sync" + + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/internal/syncutil" +) + +// DefaultCache is the sharable cache used by DefaultClient. +var DefaultCache Cache = NewCache() + +// Cache caches the auth-scheme and auth-token for the "Authorization" header in +// accessing the remote registry. +// Precisely, the header is `Authorization: auth-scheme auth-token`. +// The `auth-token` is a generic term as `token68` in RFC 7235 section 2.1. +type Cache interface { + // GetScheme returns the auth-scheme part cached for the given registry. + // A single registry is assumed to have a consistent scheme. + // If a registry has different schemes per path, the auth client is still + // workable. However, the cache may not be effective as the cache cannot + // correctly guess the scheme. + GetScheme(ctx context.Context, registry string) (Scheme, error) + + // GetToken returns the auth-token part cached for the given registry of a + // given scheme. + // The underlying implementation MAY cache the token for all schemes for the + // given registry. + GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) + + // Set fetches the token using the given fetch function and caches the token + // for the given scheme with the given key for the given registry. + // The return values of the fetch function is returned by this function. + // The underlying implementation MAY combine the fetch operation if the Set + // function is invoked multiple times at the same time. + Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) +} + +// cacheEntry is a cache entry for a single registry. +type cacheEntry struct { + scheme Scheme + tokens sync.Map // map[string]string +} + +// concurrentCache is a cache suitable for concurrent invocation. +type concurrentCache struct { + status sync.Map // map[string]*syncutil.Once + cache sync.Map // map[string]*cacheEntry +} + +// NewCache creates a new go-routine safe cache instance. +func NewCache() Cache { + return &concurrentCache{} +} + +// GetScheme returns the auth-scheme part cached for the given registry. +func (cc *concurrentCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { + entry, ok := cc.cache.Load(registry) + if !ok { + return SchemeUnknown, errdef.ErrNotFound + } + return entry.(*cacheEntry).scheme, nil +} + +// GetToken returns the auth-token part cached for the given registry of a given +// scheme. +func (cc *concurrentCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { + entryValue, ok := cc.cache.Load(registry) + if !ok { + return "", errdef.ErrNotFound + } + entry := entryValue.(*cacheEntry) + if entry.scheme != scheme { + return "", errdef.ErrNotFound + } + if token, ok := entry.tokens.Load(key); ok { + return token.(string), nil + } + return "", errdef.ErrNotFound +} + +// Set fetches the token using the given fetch function and caches the token +// for the given scheme with the given key for the given registry. +// Set combines the fetch operation if the Set is invoked multiple times at the +// same time. +func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { + // fetch token + statusKey := strings.Join([]string{ + registry, + scheme.String(), + key, + }, " ") + statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce()) + fetchOnce := statusValue.(*syncutil.Once) + fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) { + return fetch(ctx) + }) + if fetchedFirst { + cc.status.Delete(statusKey) + } + if err != nil { + return "", err + } + token := result.(string) + if !fetchedFirst { + return token, nil + } + + // cache token + newEntry := &cacheEntry{ + scheme: scheme, + } + entryValue, exists := cc.cache.LoadOrStore(registry, newEntry) + entry := entryValue.(*cacheEntry) + if exists && entry.scheme != scheme { + // there is a scheme change, which is not expected in most scenarios. + // force invalidating all previous cache. + entry = newEntry + cc.cache.Store(registry, entry) + } + entry.tokens.Store(key, token) + + return token, nil +} + +// noCache is a cache implementation that does not do cache at all. +type noCache struct{} + +// GetScheme always returns not found error as it has no cache. +func (noCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { + return SchemeUnknown, errdef.ErrNotFound +} + +// GetToken always returns not found error as it has no cache. +func (noCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { + return "", errdef.ErrNotFound +} + +// Set calls fetch directly without caching. +func (noCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { + return fetch(ctx) +} + +// hostCache is an auth cache that ignores scopes. Uses only the registry's hostname to find a token. +type hostCache struct { + Cache +} + +// GetToken implements Cache. +func (c *hostCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { + return c.Cache.GetToken(ctx, registry, scheme, "") +} + +// Set implements Cache. +func (c *hostCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { + return c.Cache.Set(ctx, registry, scheme, "", fetch) +} + +// fallbackCache tries the primary cache then falls back to the secondary cache. +type fallbackCache struct { + primary Cache + secondary Cache +} + +// GetScheme implements Cache. +func (fc *fallbackCache) GetScheme(ctx context.Context, registry string) (Scheme, error) { + scheme, err := fc.primary.GetScheme(ctx, registry) + if err == nil { + return scheme, nil + } + + // fallback + return fc.secondary.GetScheme(ctx, registry) +} + +// GetToken implements Cache. +func (fc *fallbackCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) { + token, err := fc.primary.GetToken(ctx, registry, scheme, key) + if err == nil { + return token, nil + } + + // fallback + return fc.secondary.GetToken(ctx, registry, scheme, key) +} + +// Set implements Cache. +func (fc *fallbackCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) { + token, err := fc.primary.Set(ctx, registry, scheme, key, fetch) + if err != nil { + return "", err + } + + return fc.secondary.Set(ctx, registry, scheme, key, func(ctx context.Context) (string, error) { + return token, nil + }) +} + +// NewSingleContextCache creates a host-based cache for optimizing the auth flow for non-compliant registries. +// It is intended to be used in a single context, such as pulling from a single repository. +// This cache should not be shared. +// +// Note: [NewCache] should be used for compliant registries as it can be shared +// across context and will generally make less re-authentication requests. +func NewSingleContextCache() Cache { + cache := NewCache() + return &fallbackCache{ + primary: cache, + // We can re-use the came concurrentCache here because the key space is different + // (keys are always empty for the hostCache) so there is no collision. + // Even if there is a collision it is not an issue. + // Re-using saves a little memory. + secondary: &hostCache{cache}, + } +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/auth/challenge.go b/vendor/oras.land/oras-go/v2/registry/remote/auth/challenge.go new file mode 100644 index 00000000..ffc52f8e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/auth/challenge.go @@ -0,0 +1,167 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "strconv" + "strings" +) + +// Scheme define the authentication method. +type Scheme byte + +const ( + // SchemeUnknown represents unknown or unsupported schemes + SchemeUnknown Scheme = iota + + // SchemeBasic represents the "Basic" HTTP authentication scheme. + // Reference: https://tools.ietf.org/html/rfc7617 + SchemeBasic + + // SchemeBearer represents the Bearer token in OAuth 2.0. + // Reference: https://tools.ietf.org/html/rfc6750 + SchemeBearer +) + +// parseScheme parse the authentication scheme from the given string +// case-insensitively. +func parseScheme(scheme string) Scheme { + switch { + case strings.EqualFold(scheme, "basic"): + return SchemeBasic + case strings.EqualFold(scheme, "bearer"): + return SchemeBearer + } + return SchemeUnknown +} + +// String return the string for the scheme. +func (s Scheme) String() string { + switch s { + case SchemeBasic: + return "Basic" + case SchemeBearer: + return "Bearer" + } + return "Unknown" +} + +// parseChallenge parses the "WWW-Authenticate" header returned by the remote +// registry, and extracts parameters if scheme is Bearer. +// References: +// - https://distribution.github.io/distribution/spec/auth/token/#how-to-authenticate +// - https://tools.ietf.org/html/rfc7235#section-2.1 +func parseChallenge(header string) (scheme Scheme, params map[string]string) { + // as defined in RFC 7235 section 2.1, we have + // challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] + // auth-scheme = token + // auth-param = token BWS "=" BWS ( token / quoted-string ) + // + // since we focus parameters only on Bearer, we have + // challenge = auth-scheme [ 1*SP #auth-param ] + schemeString, rest := parseToken(header) + scheme = parseScheme(schemeString) + + // fast path for non bearer challenge + if scheme != SchemeBearer { + return + } + + // parse params for bearer auth. + // combining RFC 7235 section 2.1 with RFC 7230 section 7, we have + // #auth-param => auth-param *( OWS "," OWS auth-param ) + var key, value string + for { + key, rest = parseToken(skipSpace(rest)) + if key == "" { + return + } + + rest = skipSpace(rest) + if rest == "" || rest[0] != '=' { + return + } + rest = skipSpace(rest[1:]) + if rest == "" { + return + } + + if rest[0] == '"' { + prefix, err := strconv.QuotedPrefix(rest) + if err != nil { + return + } + value, err = strconv.Unquote(prefix) + if err != nil { + return + } + rest = rest[len(prefix):] + } else { + value, rest = parseToken(rest) + if value == "" { + return + } + } + if params == nil { + params = map[string]string{ + key: value, + } + } else { + params[key] = value + } + + rest = skipSpace(rest) + if rest == "" || rest[0] != ',' { + return + } + rest = rest[1:] + } +} + +// isNotTokenChar reports whether rune is not a `tchar` defined in RFC 7230 +// section 3.2.6. +func isNotTokenChar(r rune) bool { + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + return (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && + (r < '0' || r > '9') && !strings.ContainsRune("!#$%&'*+-.^_`|~", r) +} + +// parseToken finds the next token from the given string. If no token found, +// an empty token is returned and the whole of the input is returned in rest. +// Note: Since token = 1*tchar, empty string is not a valid token. +func parseToken(s string) (token, rest string) { + if i := strings.IndexFunc(s, isNotTokenChar); i != -1 { + return s[:i], s[i:] + } + return s, "" +} + +// skipSpace skips "bad" whitespace (BWS) defined in RFC 7230 section 3.2.3. +func skipSpace(s string) string { + // OWS = *( SP / HTAB ) + // ; optional whitespace + // BWS = OWS + // ; "bad" whitespace + if i := strings.IndexFunc(s, func(r rune) bool { + return r != ' ' && r != '\t' + }); i != -1 { + return s[i:] + } + return s +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/auth/client.go b/vendor/oras.land/oras-go/v2/registry/remote/auth/client.go new file mode 100644 index 00000000..5c5330e7 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/auth/client.go @@ -0,0 +1,430 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package auth provides authentication for a client to a remote registry. +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "oras.land/oras-go/v2/registry/remote/internal/errutil" + "oras.land/oras-go/v2/registry/remote/retry" +) + +// ErrBasicCredentialNotFound is returned when the credential is not found for +// basic auth. +var ErrBasicCredentialNotFound = errors.New("basic credential not found") + +// DefaultClient is the default auth-decorated client. +var DefaultClient = &Client{ + Client: retry.DefaultClient, + Header: http.Header{ + "User-Agent": {"oras-go"}, + }, + Cache: DefaultCache, +} + +// maxResponseBytes specifies the default limit on how many response bytes are +// allowed in the server's response from authorization service servers. +// A typical response message from authorization service servers is around 1 to +// 4 KiB. Since the size of a token must be smaller than the HTTP header size +// limit, which is usually 16 KiB. As specified by the distribution, the +// response may contain 2 identical tokens, that is, 16 x 2 = 32 KiB. +// Hence, 128 KiB should be sufficient. +// References: https://distribution.github.io/distribution/spec/auth/token/ +var maxResponseBytes int64 = 128 * 1024 // 128 KiB + +// defaultClientID specifies the default client ID used in OAuth2. +// See also ClientID. +var defaultClientID = "oras-go" + +// CredentialFunc represents a function that resolves the credential for the +// given registry (i.e. host:port). +// +// [EmptyCredential] is a valid return value and should not be considered as +// an error. +type CredentialFunc func(ctx context.Context, hostport string) (Credential, error) + +// StaticCredential specifies static credentials for the given host. +func StaticCredential(registry string, cred Credential) CredentialFunc { + if registry == "docker.io" { + // it is expected that traffic targeting "docker.io" will be redirected + // to "registry-1.docker.io" + // reference: https://github.com/moby/moby/blob/v24.0.0-beta.2/registry/config.go#L25-L48 + registry = "registry-1.docker.io" + } + return func(_ context.Context, hostport string) (Credential, error) { + if hostport == registry { + return cred, nil + } + return EmptyCredential, nil + } +} + +// Client is an auth-decorated HTTP client. +// Its zero value is a usable client that uses http.DefaultClient with no cache. +type Client struct { + // Client is the underlying HTTP client used to access the remote + // server. + // If nil, http.DefaultClient is used. + // It is possible to use the default retry client from the package + // `oras.land/oras-go/v2/registry/remote/retry`. That client is already available + // in the DefaultClient. + // It is also possible to use a custom client. For example, github.com/hashicorp/go-retryablehttp + // is a popular HTTP client that supports retries. + Client *http.Client + + // Header contains the custom headers to be added to each request. + Header http.Header + + // Credential specifies the function for resolving the credential for the + // given registry (i.e. host:port). + // EmptyCredential is a valid return value and should not be considered as + // an error. + // If nil, the credential is always resolved to EmptyCredential. + Credential CredentialFunc + + // Cache caches credentials for direct accessing the remote registry. + // If nil, no cache is used. + Cache Cache + + // ClientID used in fetching OAuth2 token as a required field. + // If empty, a default client ID is used. + // Reference: https://distribution.github.io/distribution/spec/auth/oauth/#getting-a-token + ClientID string + + // ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant + // instead the distribution spec when authenticating using username and + // password. + // References: + // - https://distribution.github.io/distribution/spec/auth/jwt/ + // - https://distribution.github.io/distribution/spec/auth/oauth/ + ForceAttemptOAuth2 bool +} + +// client returns an HTTP client used to access the remote registry. +// http.DefaultClient is return if the client is not configured. +func (c *Client) client() *http.Client { + if c.Client == nil { + return http.DefaultClient + } + return c.Client +} + +// send adds headers to the request and sends the request to the remote server. +func (c *Client) send(req *http.Request) (*http.Response, error) { + for key, values := range c.Header { + req.Header[key] = append(req.Header[key], values...) + } + return c.client().Do(req) +} + +// credential resolves the credential for the given registry. +func (c *Client) credential(ctx context.Context, reg string) (Credential, error) { + if c.Credential == nil { + return EmptyCredential, nil + } + return c.Credential(ctx, reg) +} + +// cache resolves the cache. +// noCache is return if the cache is not configured. +func (c *Client) cache() Cache { + if c.Cache == nil { + return noCache{} + } + return c.Cache +} + +// SetUserAgent sets the user agent for all out-going requests. +func (c *Client) SetUserAgent(userAgent string) { + if c.Header == nil { + c.Header = http.Header{} + } + c.Header.Set("User-Agent", userAgent) +} + +// Do sends the request to the remote server, attempting to resolve +// authentication if 'Authorization' header is not set. +// +// On authentication failure due to bad credential, +// - Do returns error if it fails to fetch token for bearer auth. +// - Do returns the registry response without error for basic auth. +func (c *Client) Do(originalReq *http.Request) (*http.Response, error) { + if auth := originalReq.Header.Get("Authorization"); auth != "" { + return c.send(originalReq) + } + + ctx := originalReq.Context() + req := originalReq.Clone(ctx) + + // attempt cached auth token + var attemptedKey string + cache := c.cache() + host := originalReq.Host + scheme, err := cache.GetScheme(ctx, host) + if err == nil { + switch scheme { + case SchemeBasic: + token, err := cache.GetToken(ctx, host, SchemeBasic, "") + if err == nil { + req.Header.Set("Authorization", "Basic "+token) + } + case SchemeBearer: + scopes := GetAllScopesForHost(ctx, host) + attemptedKey = strings.Join(scopes, " ") + token, err := cache.GetToken(ctx, host, SchemeBearer, attemptedKey) + if err == nil { + req.Header.Set("Authorization", "Bearer "+token) + } + } + } + + resp, err := c.send(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + + // attempt again with credentials for recognized schemes + challenge := resp.Header.Get("Www-Authenticate") + scheme, params := parseChallenge(challenge) + switch scheme { + case SchemeBasic: + resp.Body.Close() + + token, err := cache.Set(ctx, host, SchemeBasic, "", func(ctx context.Context) (string, error) { + return c.fetchBasicAuth(ctx, host) + }) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err) + } + + req = originalReq.Clone(ctx) + req.Header.Set("Authorization", "Basic "+token) + case SchemeBearer: + resp.Body.Close() + + scopes := GetAllScopesForHost(ctx, host) + if paramScope := params["scope"]; paramScope != "" { + // merge hinted scopes with challenged scopes + scopes = append(scopes, strings.Split(paramScope, " ")...) + scopes = CleanScopes(scopes) + } + key := strings.Join(scopes, " ") + + // attempt the cache again if there is a scope change + if key != attemptedKey { + if token, err := cache.GetToken(ctx, host, SchemeBearer, key); err == nil { + req = originalReq.Clone(ctx) + req.Header.Set("Authorization", "Bearer "+token) + if err := rewindRequestBody(req); err != nil { + return nil, err + } + + resp, err := c.send(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + resp.Body.Close() + } + } + + // attempt with credentials + realm := params["realm"] + service := params["service"] + token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) { + return c.fetchBearerToken(ctx, host, realm, service, scopes) + }) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err) + } + + req = originalReq.Clone(ctx) + req.Header.Set("Authorization", "Bearer "+token) + default: + return resp, nil + } + if err := rewindRequestBody(req); err != nil { + return nil, err + } + + return c.send(req) +} + +// fetchBasicAuth fetches a basic auth token for the basic challenge. +func (c *Client) fetchBasicAuth(ctx context.Context, registry string) (string, error) { + cred, err := c.credential(ctx, registry) + if err != nil { + return "", fmt.Errorf("failed to resolve credential: %w", err) + } + if cred == EmptyCredential { + return "", ErrBasicCredentialNotFound + } + if cred.Username == "" || cred.Password == "" { + return "", errors.New("missing username or password for basic auth") + } + auth := cred.Username + ":" + cred.Password + return base64.StdEncoding.EncodeToString([]byte(auth)), nil +} + +// fetchBearerToken fetches an access token for the bearer challenge. +func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service string, scopes []string) (string, error) { + cred, err := c.credential(ctx, registry) + if err != nil { + return "", err + } + if cred.AccessToken != "" { + return cred.AccessToken, nil + } + if cred == EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) { + return c.fetchDistributionToken(ctx, realm, service, scopes, cred.Username, cred.Password) + } + return c.fetchOAuth2Token(ctx, realm, service, scopes, cred) +} + +// fetchDistributionToken fetches an access token as defined by the distribution +// specification. +// It fetches anonymous tokens if no credential is provided. +// References: +// - https://distribution.github.io/distribution/spec/auth/jwt/ +// - https://distribution.github.io/distribution/spec/auth/token/ +func (c *Client) fetchDistributionToken(ctx context.Context, realm, service string, scopes []string, username, password string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil) + if err != nil { + return "", err + } + if username != "" || password != "" { + req.SetBasicAuth(username, password) + } + q := req.URL.Query() + if service != "" { + q.Set("service", service) + } + for _, scope := range scopes { + q.Add("scope", scope) + } + req.URL.RawQuery = q.Encode() + + resp, err := c.send(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", errutil.ParseErrorResponse(resp) + } + + // As specified in https://distribution.github.io/distribution/spec/auth/token/ section + // "Token Response Fields", the token is either in `token` or + // `access_token`. If both present, they are identical. + var result struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + lr := io.LimitReader(resp.Body, maxResponseBytes) + if err := json.NewDecoder(lr).Decode(&result); err != nil { + return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) + } + if result.AccessToken != "" { + return result.AccessToken, nil + } + if result.Token != "" { + return result.Token, nil + } + return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL) +} + +// fetchOAuth2Token fetches an OAuth2 access token. +// Reference: https://distribution.github.io/distribution/spec/auth/oauth/ +func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred Credential) (string, error) { + form := url.Values{} + if cred.RefreshToken != "" { + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", cred.RefreshToken) + } else if cred.Username != "" && cred.Password != "" { + form.Set("grant_type", "password") + form.Set("username", cred.Username) + form.Set("password", cred.Password) + } else { + return "", errors.New("missing username or password for bearer auth") + } + form.Set("service", service) + clientID := c.ClientID + if clientID == "" { + clientID = defaultClientID + } + form.Set("client_id", clientID) + if len(scopes) != 0 { + form.Set("scope", strings.Join(scopes, " ")) + } + body := strings.NewReader(form.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, realm, body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.send(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", errutil.ParseErrorResponse(resp) + } + + var result struct { + AccessToken string `json:"access_token"` + } + lr := io.LimitReader(resp.Body, maxResponseBytes) + if err := json.NewDecoder(lr).Decode(&result); err != nil { + return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) + } + if result.AccessToken != "" { + return result.AccessToken, nil + } + return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL) +} + +// rewindRequestBody tries to rewind the request body if exists. +func rewindRequestBody(req *http.Request) error { + if req.Body == nil || req.Body == http.NoBody { + return nil + } + if req.GetBody == nil { + return fmt.Errorf("%s %q: request body is not rewindable", req.Method, req.URL) + } + body, err := req.GetBody() + if err != nil { + return fmt.Errorf("%s %q: failed to get request body: %w", req.Method, req.URL, err) + } + req.Body = body + return nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/auth/credential.go b/vendor/oras.land/oras-go/v2/registry/remote/auth/credential.go new file mode 100644 index 00000000..044bcaec --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/auth/credential.go @@ -0,0 +1,40 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +// EmptyCredential represents an empty credential. +var EmptyCredential Credential + +// Credential contains authentication credentials used to access remote +// registries. +type Credential struct { + // Username is the name of the user for the remote registry. + Username string + + // Password is the secret associated with the username. + Password string + + // RefreshToken is a bearer token to be sent to the authorization service + // for fetching access tokens. + // A refresh token is often referred as an identity token. + // Reference: https://distribution.github.io/distribution/spec/auth/oauth/ + RefreshToken string + + // AccessToken is a bearer token to be sent to the registry. + // An access token is often referred as a registry token. + // Reference: https://distribution.github.io/distribution/spec/auth/token/ + AccessToken string +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/auth/scope.go b/vendor/oras.land/oras-go/v2/registry/remote/auth/scope.go new file mode 100644 index 00000000..bdd6e5c4 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/auth/scope.go @@ -0,0 +1,325 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "context" + "slices" + "strings" + + "oras.land/oras-go/v2/registry" +) + +// Actions used in scopes. +// Reference: https://distribution.github.io/distribution/spec/auth/scope/ +const ( + // ActionPull represents generic read access for resources of the repository + // type. + ActionPull = "pull" + + // ActionPush represents generic write access for resources of the + // repository type. + ActionPush = "push" + + // ActionDelete represents the delete permission for resources of the + // repository type. + ActionDelete = "delete" +) + +// ScopeRegistryCatalog is the scope for registry catalog access. +const ScopeRegistryCatalog = "registry:catalog:*" + +// ScopeRepository returns a repository scope with given actions. +// Reference: https://distribution.github.io/distribution/spec/auth/scope/ +func ScopeRepository(repository string, actions ...string) string { + actions = cleanActions(actions) + if repository == "" || len(actions) == 0 { + return "" + } + return strings.Join([]string{ + "repository", + repository, + strings.Join(actions, ","), + }, ":") +} + +// AppendRepositoryScope returns a new context containing scope hints for the +// auth client to fetch bearer tokens with the given actions on the repository. +// If called multiple times, the new scopes will be appended to the existing +// scopes. The resulted scopes are de-duplicated. +// +// For example, uploading blob to the repository "hello-world" does HEAD request +// first then POST and PUT. The HEAD request will return a challenge for scope +// `repository:hello-world:pull`, and the auth client will fetch a token for +// that challenge. Later, the POST request will return a challenge for scope +// `repository:hello-world:push`, and the auth client will fetch a token for +// that challenge again. By invoking AppendRepositoryScope with the actions +// [ActionPull] and [ActionPush] for the repository `hello-world`, +// the auth client with cache is hinted to fetch a token via a single token +// fetch request for all the HEAD, POST, PUT requests. +func AppendRepositoryScope(ctx context.Context, ref registry.Reference, actions ...string) context.Context { + if len(actions) == 0 { + return ctx + } + scope := ScopeRepository(ref.Repository, actions...) + return AppendScopesForHost(ctx, ref.Host(), scope) +} + +// scopesContextKey is the context key for scopes. +type scopesContextKey struct{} + +// WithScopes returns a context with scopes added. Scopes are de-duplicated. +// Scopes are used as hints for the auth client to fetch bearer tokens with +// larger scopes. +// +// For example, uploading blob to the repository "hello-world" does HEAD request +// first then POST and PUT. The HEAD request will return a challenge for scope +// `repository:hello-world:pull`, and the auth client will fetch a token for +// that challenge. Later, the POST request will return a challenge for scope +// `repository:hello-world:push`, and the auth client will fetch a token for +// that challenge again. By invoking WithScopes with the scope +// `repository:hello-world:pull,push`, the auth client with cache is hinted to +// fetch a token via a single token fetch request for all the HEAD, POST, PUT +// requests. +// +// Passing an empty list of scopes will virtually remove the scope hints in the +// context. +// +// Reference: https://distribution.github.io/distribution/spec/auth/scope/ +func WithScopes(ctx context.Context, scopes ...string) context.Context { + scopes = CleanScopes(scopes) + return context.WithValue(ctx, scopesContextKey{}, scopes) +} + +// AppendScopes appends additional scopes to the existing scopes in the context +// and returns a new context. The resulted scopes are de-duplicated. +// The append operation does modify the existing scope in the context passed in. +func AppendScopes(ctx context.Context, scopes ...string) context.Context { + if len(scopes) == 0 { + return ctx + } + return WithScopes(ctx, append(GetScopes(ctx), scopes...)...) +} + +// GetScopes returns the scopes in the context. +func GetScopes(ctx context.Context) []string { + if scopes, ok := ctx.Value(scopesContextKey{}).([]string); ok { + return slices.Clone(scopes) + } + return nil +} + +// scopesForHostContextKey is the context key for per-host scopes. +type scopesForHostContextKey string + +// WithScopesForHost returns a context with per-host scopes added. +// Scopes are de-duplicated. +// Scopes are used as hints for the auth client to fetch bearer tokens with +// larger scopes. +// +// For example, uploading blob to the repository "hello-world" does HEAD request +// first then POST and PUT. The HEAD request will return a challenge for scope +// `repository:hello-world:pull`, and the auth client will fetch a token for +// that challenge. Later, the POST request will return a challenge for scope +// `repository:hello-world:push`, and the auth client will fetch a token for +// that challenge again. By invoking WithScopesForHost with the scope +// `repository:hello-world:pull,push`, the auth client with cache is hinted to +// fetch a token via a single token fetch request for all the HEAD, POST, PUT +// requests. +// +// Passing an empty list of scopes will virtually remove the scope hints in the +// context for the given host. +// +// Reference: https://distribution.github.io/distribution/spec/auth/scope/ +func WithScopesForHost(ctx context.Context, host string, scopes ...string) context.Context { + scopes = CleanScopes(scopes) + return context.WithValue(ctx, scopesForHostContextKey(host), scopes) +} + +// AppendScopesForHost appends additional scopes to the existing scopes +// in the context for the given host and returns a new context. +// The resulted scopes are de-duplicated. +// The append operation does modify the existing scope in the context passed in. +func AppendScopesForHost(ctx context.Context, host string, scopes ...string) context.Context { + if len(scopes) == 0 { + return ctx + } + oldScopes := GetScopesForHost(ctx, host) + return WithScopesForHost(ctx, host, append(oldScopes, scopes...)...) +} + +// GetScopesForHost returns the scopes in the context for the given host, +// excluding global scopes added by [WithScopes] and [AppendScopes]. +func GetScopesForHost(ctx context.Context, host string) []string { + if scopes, ok := ctx.Value(scopesForHostContextKey(host)).([]string); ok { + return slices.Clone(scopes) + } + return nil +} + +// GetAllScopesForHost returns the scopes in the context for the given host, +// including global scopes added by [WithScopes] and [AppendScopes]. +func GetAllScopesForHost(ctx context.Context, host string) []string { + scopes := GetScopesForHost(ctx, host) + globalScopes := GetScopes(ctx) + + if len(scopes) == 0 { + return globalScopes + } + if len(globalScopes) == 0 { + return scopes + } + // re-clean the scopes + allScopes := append(scopes, globalScopes...) + return CleanScopes(allScopes) +} + +// CleanScopes merges and sort the actions in ascending order if the scopes have +// the same resource type and name. The final scopes are sorted in ascending +// order. In other words, the scopes passed in are de-duplicated and sorted. +// Therefore, the output of this function is deterministic. +// +// If there is a wildcard `*` in the action, other actions in the same resource +// type and name are ignored. +func CleanScopes(scopes []string) []string { + // fast paths + switch len(scopes) { + case 0: + return nil + case 1: + scope := scopes[0] + i := strings.LastIndex(scope, ":") + if i == -1 { + return []string{scope} + } + actionList := strings.Split(scope[i+1:], ",") + actionList = cleanActions(actionList) + if len(actionList) == 0 { + return nil + } + actions := strings.Join(actionList, ",") + scope = scope[:i+1] + actions + return []string{scope} + } + + // slow path + var result []string + + // merge recognizable scopes + resourceTypes := make(map[string]map[string]map[string]struct{}) + for _, scope := range scopes { + // extract resource type + i := strings.Index(scope, ":") + if i == -1 { + result = append(result, scope) + continue + } + resourceType := scope[:i] + + // extract resource name and actions + rest := scope[i+1:] + i = strings.LastIndex(rest, ":") + if i == -1 { + result = append(result, scope) + continue + } + resourceName := rest[:i] + actions := rest[i+1:] + if actions == "" { + // drop scope since no action found + continue + } + + // add to the intermediate map for de-duplication + namedActions := resourceTypes[resourceType] + if namedActions == nil { + namedActions = make(map[string]map[string]struct{}) + resourceTypes[resourceType] = namedActions + } + actionSet := namedActions[resourceName] + if actionSet == nil { + actionSet = make(map[string]struct{}) + namedActions[resourceName] = actionSet + } + for _, action := range strings.Split(actions, ",") { + if action != "" { + actionSet[action] = struct{}{} + } + } + } + + // reconstruct scopes + for resourceType, namedActions := range resourceTypes { + for resourceName, actionSet := range namedActions { + if len(actionSet) == 0 { + continue + } + var actions []string + for action := range actionSet { + if action == "*" { + actions = []string{"*"} + break + } + actions = append(actions, action) + } + slices.Sort(actions) + scope := resourceType + ":" + resourceName + ":" + strings.Join(actions, ",") + result = append(result, scope) + } + } + + // sort and return + slices.Sort(result) + return result +} + +// cleanActions removes the duplicated actions and sort in ascending order. +// If there is a wildcard `*` in the action, other actions are ignored. +func cleanActions(actions []string) []string { + // fast paths + switch len(actions) { + case 0: + return nil + case 1: + if actions[0] == "" { + return nil + } + return actions + } + + // slow path + slices.Sort(actions) + n := 0 + for i := range len(actions) { + if actions[i] == "*" { + return []string{"*"} + } + if actions[i] != actions[n] { + n++ + if n != i { + actions[n] = actions[i] + } + } + } + n++ + if actions[0] == "" { + if n == 1 { + return nil + } + return actions[1:n] + } + return actions[:n] +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/file_store.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/file_store.go new file mode 100644 index 00000000..7664cc2a --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/file_store.go @@ -0,0 +1,97 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import ( + "context" + "errors" + "fmt" + "strings" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +// FileStore implements a credentials store using the docker configuration file +// to keep the credentials in plain-text. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +type FileStore struct { + // DisablePut disables putting credentials in plaintext. + // If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled. + DisablePut bool + + config *config.Config +} + +var ( + // ErrPlaintextPutDisabled is returned by Put() when DisablePut is set + // to true. + ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled") + // ErrBadCredentialFormat is returned by Put() when the credential format + // is bad. + ErrBadCredentialFormat = errors.New("bad credential format") +) + +// NewFileStore creates a new file credentials store. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewFileStore(configPath string) (*FileStore, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + return newFileStore(cfg), nil +} + +// newFileStore creates a file credentials store based on the given config instance. +func newFileStore(cfg *config.Config) *FileStore { + return &FileStore{config: cfg} +} + +// Get retrieves credentials from the store for the given server address. +func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { + return fs.config.GetCredential(serverAddress) +} + +// Put saves credentials into the store for the given server address. +// Returns ErrPlaintextPutDisabled if fs.DisablePut is set to true. +func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { + if fs.DisablePut { + return ErrPlaintextPutDisabled + } + if err := validateCredentialFormat(cred); err != nil { + return err + } + + return fs.config.PutCredential(serverAddress, cred) +} + +// Delete removes credentials from the store for the given server address. +func (fs *FileStore) Delete(_ context.Context, serverAddress string) error { + return fs.config.DeleteCredential(serverAddress) +} + +// validateCredentialFormat validates the format of cred. +func validateCredentialFormat(cred auth.Credential) error { + if strings.ContainsRune(cred.Username, ':') { + // Username and password will be encoded in the base64(username:password) + // format in the file. The decoded result will be wrong if username + // contains colon(s). + return fmt.Errorf("%w: colons(:) are not allowed in username", ErrBadCredentialFormat) + } + return nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/config/config.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/config/config.go new file mode 100644 index 00000000..3a898f22 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/config/config.go @@ -0,0 +1,332 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil" +) + +const ( + // configFieldAuths is the "auths" field in the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + configFieldAuths = "auths" + // configFieldCredentialsStore is the "credsStore" field in the config file. + configFieldCredentialsStore = "credsStore" + // configFieldCredentialHelpers is the "credHelpers" field in the config file. + configFieldCredentialHelpers = "credHelpers" +) + +// ErrInvalidConfigFormat is returned when the config format is invalid. +var ErrInvalidConfigFormat = errors.New("invalid config format") + +// AuthConfig contains authorization information for connecting to a Registry. +// References: +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45 +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22 +type AuthConfig struct { + // Auth is a base64-encoded string of "{username}:{password}". + Auth string `json:"auth,omitempty"` + // IdentityToken is used to authenticate the user and get an access token + // for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + // RegistryToken is a bearer token to be sent to a registry. + RegistryToken string `json:"registrytoken,omitempty"` + + Username string `json:"username,omitempty"` // legacy field for compatibility + Password string `json:"password,omitempty"` // legacy field for compatibility +} + +// NewAuthConfig creates an authConfig based on cred. +func NewAuthConfig(cred auth.Credential) AuthConfig { + return AuthConfig{ + Auth: encodeAuth(cred.Username, cred.Password), + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + } +} + +// Credential returns an auth.Credential based on ac. +func (ac AuthConfig) Credential() (auth.Credential, error) { + cred := auth.Credential{ + Username: ac.Username, + Password: ac.Password, + RefreshToken: ac.IdentityToken, + AccessToken: ac.RegistryToken, + } + if ac.Auth != "" { + var err error + // override username and password + cred.Username, cred.Password, err = decodeAuth(ac.Auth) + if err != nil { + return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) + } + } + return cred, nil +} + +// Config represents a docker configuration file. +// References: +// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 +type Config struct { + // path is the path to the config file. + path string + // rwLock is a read-write-lock for the file store. + rwLock sync.RWMutex + // content is the content of the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 + content map[string]json.RawMessage + // authsCache is a cache of the auths field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + authsCache map[string]json.RawMessage + // credentialsStore is the credsStore field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28 + credentialsStore string + // credentialHelpers is the credHelpers field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29 + credentialHelpers map[string]string +} + +// Load loads Config from the given config path. +func Load(configPath string) (*Config, error) { + cfg := &Config{path: configPath} + configFile, err := os.Open(configPath) + if err != nil { + if os.IsNotExist(err) { + // init content and caches if the content file does not exist + cfg.content = make(map[string]json.RawMessage) + cfg.authsCache = make(map[string]json.RawMessage) + return cfg, nil + } + return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) + } + defer configFile.Close() + + // decode config content if the config file exists + if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil { + return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) + } + + if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok { + if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil { + return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok { + if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil { + return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if authsBytes, ok := cfg.content[configFieldAuths]; ok { + if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + } + if cfg.authsCache == nil { + cfg.authsCache = make(map[string]json.RawMessage) + } + + return cfg, nil +} + +// GetAuthConfig returns an auth.Credential for serverAddress. +func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + authCfgBytes, ok := cfg.authsCache[serverAddress] + if !ok { + // NOTE: the auth key for the server address may have been stored with + // a http/https prefix in legacy config files, e.g. "registry.example.com" + // can be stored as "https://registry.example.com/". + var matched bool + for addr, auth := range cfg.authsCache { + if ToHostname(addr) == serverAddress { + matched = true + authCfgBytes = auth + break + } + } + if !matched { + return auth.EmptyCredential, nil + } + } + var authCfg AuthConfig + if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { + return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) + } + return authCfg.Credential() +} + +// PutAuthConfig puts cred for serverAddress. +func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + authCfg := NewAuthConfig(cred) + authCfgBytes, err := json.Marshal(authCfg) + if err != nil { + return fmt.Errorf("failed to marshal auth field: %w", err) + } + cfg.authsCache[serverAddress] = authCfgBytes + return cfg.saveFile() +} + +// DeleteAuthConfig deletes the corresponding credential for serverAddress. +func (cfg *Config) DeleteCredential(serverAddress string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + if _, ok := cfg.authsCache[serverAddress]; !ok { + // no ops + return nil + } + delete(cfg.authsCache, serverAddress) + return cfg.saveFile() +} + +// GetCredentialHelper returns the credential helpers for serverAddress. +func (cfg *Config) GetCredentialHelper(serverAddress string) string { + return cfg.credentialHelpers[serverAddress] +} + +// CredentialsStore returns the configured credentials store. +func (cfg *Config) CredentialsStore() string { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + return cfg.credentialsStore +} + +// Path returns the path to the config file. +func (cfg *Config) Path() string { + return cfg.path +} + +// SetCredentialsStore puts the configured credentials store. +func (cfg *Config) SetCredentialsStore(credsStore string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + cfg.credentialsStore = credsStore + return cfg.saveFile() +} + +// IsAuthConfigured returns whether there is authentication configured in this +// config file or not. +func (cfg *Config) IsAuthConfigured() bool { + return cfg.credentialsStore != "" || + len(cfg.credentialHelpers) > 0 || + len(cfg.authsCache) > 0 +} + +// saveFile saves Config into the file. +func (cfg *Config) saveFile() (returnErr error) { + // marshal content + // credentialHelpers is skipped as it's never set + if cfg.credentialsStore != "" { + credsStoreBytes, err := json.Marshal(cfg.credentialsStore) + if err != nil { + return fmt.Errorf("failed to marshal creds store: %w", err) + } + cfg.content[configFieldCredentialsStore] = credsStoreBytes + } else { + // omit empty + delete(cfg.content, configFieldCredentialsStore) + } + authsBytes, err := json.Marshal(cfg.authsCache) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + cfg.content[configFieldAuths] = authsBytes + jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // write the content to a ingest file for atomicity + configDir := filepath.Dir(cfg.path) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("failed to make directory %s: %w", configDir, err) + } + ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + defer func() { + if returnErr != nil { + // clean up the ingest file in case of error + os.Remove(ingest) + } + }() + + // overwrite the config file + if err := os.Rename(ingest, cfg.path); err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + return nil +} + +// encodeAuth base64-encodes username and password into base64(username:password). +func encodeAuth(username, password string) string { + if username == "" && password == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// decodeAuth decodes a base64 encoded string and returns username and password. +func decodeAuth(authStr string) (username string, password string, err error) { + if authStr == "" { + return "", "", nil + } + + decoded, err := base64.StdEncoding.DecodeString(authStr) + if err != nil { + return "", "", err + } + decodedStr := string(decoded) + username, password, ok := strings.Cut(decodedStr, ":") + if !ok { + return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) + } + return username, password, nil +} + +// ToHostname normalizes a server address to just its hostname, removing +// the scheme and the path parts. +// It is used to match keys in the auths map, which may be either stored as +// hostname or as hostname including scheme (in legacy docker config files). +// Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71 +func ToHostname(addr string) string { + addr = strings.TrimPrefix(addr, "http://") + addr = strings.TrimPrefix(addr, "https://") + addr, _, _ = strings.Cut(addr, "/") + return addr +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/executer/executer.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/executer/executer.go new file mode 100644 index 00000000..a074c684 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/executer/executer.go @@ -0,0 +1,80 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package executer is an abstraction for the docker credential helper protocol +// binaries. It is used by nativeStore to interact with installed binaries. +package executer + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + + "oras.land/oras-go/v2/registry/remote/credentials/trace" +) + +// dockerDesktopHelperName is the name of the docker credentials helper +// execuatable. +const dockerDesktopHelperName = "docker-credential-desktop.exe" + +// Executer is an interface that simulates an executable binary. +type Executer interface { + Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) +} + +// executable implements the Executer interface. +type executable struct { + name string +} + +// New returns a new Executer instance. +func New(name string) Executer { + return &executable{ + name: name, + } +} + +// Execute operates on an executable binary and supports context. +func (c *executable) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) { + cmd := exec.CommandContext(ctx, c.name, action) + cmd.Stdin = input + cmd.Stderr = os.Stderr + trace := trace.ContextExecutableTrace(ctx) + if trace != nil && trace.ExecuteStart != nil { + trace.ExecuteStart(c.name, action) + } + output, err := cmd.Output() + if trace != nil && trace.ExecuteDone != nil { + trace.ExecuteDone(c.name, action, err) + } + if err != nil { + switch execErr := err.(type) { + case *exec.ExitError: + if errMessage := string(bytes.TrimSpace(output)); errMessage != "" { + return nil, errors.New(errMessage) + } + case *exec.Error: + // check if the error is caused by Docker Desktop not running + if execErr.Err == exec.ErrNotFound && c.name == dockerDesktopHelperName { + return nil, errors.New("credentials store is configured to `desktop.exe` but Docker Desktop seems not running") + } + } + return nil, err + } + return output, nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil/ioutil.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil/ioutil.go new file mode 100644 index 00000000..b2e3179d --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil/ioutil.go @@ -0,0 +1,49 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ioutil + +import ( + "fmt" + "io" + "os" +) + +// Ingest writes content into a temporary ingest file with the file name format +// "oras_credstore_temp_{randomString}". +func Ingest(dir string, content io.Reader) (path string, ingestErr error) { + tempFile, err := os.CreateTemp(dir, "oras_credstore_temp_*") + if err != nil { + return "", fmt.Errorf("failed to create ingest file: %w", err) + } + path = tempFile.Name() + defer func() { + if err := tempFile.Close(); err != nil && ingestErr == nil { + ingestErr = fmt.Errorf("failed to close ingest file: %w", err) + } + // remove the temp file in case of error. + if ingestErr != nil { + os.Remove(path) + } + }() + + if err := tempFile.Chmod(0600); err != nil { + return "", fmt.Errorf("failed to ensure permission: %w", err) + } + if _, err := io.Copy(tempFile, content); err != nil { + return "", fmt.Errorf("failed to ingest: %w", err) + } + return +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/memory_store.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/memory_store.go new file mode 100644 index 00000000..7fdabb1e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/memory_store.go @@ -0,0 +1,81 @@ +/* + Copyright The ORAS Authors. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +// memoryStore is a store that keeps credentials in memory. +type memoryStore struct { + store sync.Map +} + +// NewMemoryStore creates a new in-memory credentials store. +func NewMemoryStore() Store { + return &memoryStore{} +} + +// NewMemoryStoreFromDockerConfig creates a new in-memory credentials store from the given configuration. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewMemoryStoreFromDockerConfig(c []byte) (Store, error) { + cfg := struct { + Auths map[string]config.AuthConfig `json:"auths"` + }{} + if err := json.Unmarshal(c, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal auth field: %w: %v", config.ErrInvalidConfigFormat, err) + } + + s := &memoryStore{} + for addr, auth := range cfg.Auths { + // Normalize the auth key to hostname. + hostname := config.ToHostname(addr) + cred, err := auth.Credential() + if err != nil { + return nil, err + } + _, _ = s.store.LoadOrStore(hostname, cred) + } + return s, nil +} + +// Get retrieves credentials from the store for the given server address. +func (ms *memoryStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { + cred, found := ms.store.Load(serverAddress) + if !found { + return auth.EmptyCredential, nil + } + return cred.(auth.Credential), nil +} + +// Put saves credentials into the store for the given server address. +func (ms *memoryStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { + ms.store.Store(serverAddress, cred) + return nil +} + +// Delete removes credentials from the store for the given server address. +func (ms *memoryStore) Delete(_ context.Context, serverAddress string) error { + ms.store.Delete(serverAddress) + return nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store.go new file mode 100644 index 00000000..9f4c7f74 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store.go @@ -0,0 +1,139 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/executer" +) + +const ( + remoteCredentialsPrefix = "docker-credential-" + emptyUsername = "" + errCredentialsNotFoundMessage = "credentials not found in native keychain" +) + +// dockerCredentials mimics how docker credential helper binaries store +// credential information. +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol +type dockerCredentials struct { + ServerURL string `json:"ServerURL"` + Username string `json:"Username"` + Secret string `json:"Secret"` +} + +// nativeStore implements a credentials store using native keychain to keep +// credentials secure. +type nativeStore struct { + exec executer.Executer +} + +// NewNativeStore creates a new native store that uses a remote helper program to +// manage credentials. +// +// The argument of NewNativeStore can be the native keychains +// ("wincred" for Windows, "pass" for linux and "osxkeychain" for macOS), +// or any program that follows the docker-credentials-helper protocol. +// +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login#credentials-store +func NewNativeStore(helperSuffix string) Store { + return &nativeStore{ + exec: executer.New(remoteCredentialsPrefix + helperSuffix), + } +} + +// NewDefaultNativeStore returns a native store based on the platform-default +// docker credentials helper and a bool indicating if the native store is +// available. +// - Windows: "wincred" +// - Linux: "pass" or "secretservice" +// - macOS: "osxkeychain" +// +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store +func NewDefaultNativeStore() (Store, bool) { + if helper := getDefaultHelperSuffix(); helper != "" { + return NewNativeStore(helper), true + } + return nil, false +} + +// Get retrieves credentials from the store for the given server. +func (ns *nativeStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + var cred auth.Credential + out, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "get") + if err != nil { + if err.Error() == errCredentialsNotFoundMessage { + // do not return an error if the credentials are not in the keychain. + return auth.EmptyCredential, nil + } + return auth.EmptyCredential, err + } + var dockerCred dockerCredentials + if err := json.Unmarshal(out, &dockerCred); err != nil { + return auth.EmptyCredential, err + } + // bearer auth is used if the username is "" + if dockerCred.Username == emptyUsername { + cred.RefreshToken = dockerCred.Secret + } else { + cred.Username = dockerCred.Username + cred.Password = dockerCred.Secret + } + return cred, nil +} + +// Put saves credentials into the store. +func (ns *nativeStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + dockerCred := &dockerCredentials{ + ServerURL: serverAddress, + Username: cred.Username, + Secret: cred.Password, + } + if cred.RefreshToken != "" { + dockerCred.Username = emptyUsername + dockerCred.Secret = cred.RefreshToken + } + credJSON, err := json.Marshal(dockerCred) + if err != nil { + return err + } + _, err = ns.exec.Execute(ctx, bytes.NewReader(credJSON), "store") + return err +} + +// Delete removes credentials from the store for the given server. +func (ns *nativeStore) Delete(ctx context.Context, serverAddress string) error { + _, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "erase") + return err +} + +// getDefaultHelperSuffix returns the default credential helper suffix. +func getDefaultHelperSuffix() string { + platformDefault := getPlatformDefaultHelperSuffix() + if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil { + return platformDefault + } + return "" +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_darwin.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_darwin.go new file mode 100644 index 00000000..1a9aca6f --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_darwin.go @@ -0,0 +1,23 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "osxkeychain" +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_generic.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_generic.go new file mode 100644 index 00000000..5c7d4a3b --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_generic.go @@ -0,0 +1,25 @@ +//go:build !windows && !darwin && !linux + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "" +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_linux.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_linux.go new file mode 100644 index 00000000..f182923b --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_linux.go @@ -0,0 +1,29 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import "os/exec" + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + if _, err := exec.LookPath("pass"); err == nil { + return "pass" + } + + return "secretservice" +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_windows.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_windows.go new file mode 100644 index 00000000..e334cc79 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/native_store_windows.go @@ -0,0 +1,23 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "wincred" +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/registry.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/registry.go new file mode 100644 index 00000000..39735b77 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/registry.go @@ -0,0 +1,102 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import ( + "context" + "errors" + "fmt" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// ErrClientTypeUnsupported is thrown by Login() when the registry's client type +// is not supported. +var ErrClientTypeUnsupported = errors.New("client type not supported") + +// Login provides the login functionality with the given credentials. The target +// registry's client should be nil or of type *auth.Client. Login uses +// a client local to the function and will not modify the original client of +// the registry. +func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error { + // create a clone of the original registry for login purpose + regClone := *reg + // we use the original client if applicable, otherwise use a default client + var authClient auth.Client + if reg.Client == nil { + authClient = *auth.DefaultClient + authClient.Cache = nil // no cache + } else if client, ok := reg.Client.(*auth.Client); ok { + authClient = *client + } else { + return ErrClientTypeUnsupported + } + regClone.Client = &authClient + // update credentials with the client + authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred) + // validate and store the credential + if err := regClone.Ping(ctx); err != nil { + return fmt.Errorf("failed to validate the credentials for %s: %w", regClone.Reference.Registry, err) + } + hostname := ServerAddressFromRegistry(regClone.Reference.Registry) + if err := store.Put(ctx, hostname, cred); err != nil { + return fmt.Errorf("failed to store the credentials for %s: %w", hostname, err) + } + return nil +} + +// Logout provides the logout functionality given the registry name. +func Logout(ctx context.Context, store Store, registryName string) error { + registryName = ServerAddressFromRegistry(registryName) + if err := store.Delete(ctx, registryName); err != nil { + return fmt.Errorf("failed to delete the credential for %s: %w", registryName, err) + } + return nil +} + +// Credential returns a Credential() function that can be used by auth.Client. +func Credential(store Store) auth.CredentialFunc { + return func(ctx context.Context, hostport string) (auth.Credential, error) { + hostport = ServerAddressFromHostname(hostport) + if hostport == "" { + return auth.EmptyCredential, nil + } + return store.Get(ctx, hostport) + } +} + +// ServerAddressFromRegistry maps a registry to a server address, which is used as +// a key for credentials store. The Docker CLI expects that the credentials of +// the registry 'docker.io' will be added under the key "https://index.docker.io/v1/". +// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 +func ServerAddressFromRegistry(registry string) string { + if registry == "docker.io" { + return "https://index.docker.io/v1/" + } + return registry +} + +// ServerAddressFromHostname maps a hostname to a server address, which is used as +// a key for credentials store. It is expected that the traffic targetting the +// host "registry-1.docker.io" will be redirected to "https://index.docker.io/v1/". +// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 +func ServerAddressFromHostname(hostname string) string { + if hostname == "registry-1.docker.io" { + return "https://index.docker.io/v1/" + } + return hostname +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/store.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/store.go new file mode 100644 index 00000000..e26a98ae --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/store.go @@ -0,0 +1,262 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package credentials supports reading, saving, and removing credentials from +// Docker configuration files and external credential stores that follow +// the Docker credential helper protocol. +// +// Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-stores +package credentials + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "oras.land/oras-go/v2/internal/syncutil" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +const ( + dockerConfigDirEnv = "DOCKER_CONFIG" + dockerConfigFileDir = ".docker" + dockerConfigFileName = "config.json" +) + +// Store is the interface that any credentials store must implement. +type Store interface { + // Get retrieves credentials from the store for the given server address. + Get(ctx context.Context, serverAddress string) (auth.Credential, error) + // Put saves credentials into the store for the given server address. + Put(ctx context.Context, serverAddress string, cred auth.Credential) error + // Delete removes credentials from the store for the given server address. + Delete(ctx context.Context, serverAddress string) error +} + +// DynamicStore dynamically determines which store to use based on the settings +// in the config file. +type DynamicStore struct { + config *config.Config + options StoreOptions + detectedCredsStore string + setCredsStoreOnce syncutil.OnceOrRetry +} + +// StoreOptions provides options for NewStore. +type StoreOptions struct { + // AllowPlaintextPut allows saving credentials in plaintext in the config + // file. + // - If AllowPlaintextPut is set to false (default value), Put() will + // return an error when native store is not available. + // - If AllowPlaintextPut is set to true, Put() will save credentials in + // plaintext in the config file when native store is not available. + AllowPlaintextPut bool + + // DetectDefaultNativeStore enables detecting the platform-default native + // credentials store when the config file has no authentication information. + // + // If DetectDefaultNativeStore is set to true, the store will detect and set + // the default native credentials store in the "credsStore" field of the + // config file. + // - Windows: "wincred" + // - Linux: "pass" or "secretservice" + // - macOS: "osxkeychain" + // + // References: + // - https://docs.docker.com/engine/reference/commandline/login/#credentials-store + // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties + DetectDefaultNativeStore bool +} + +// NewStore returns a Store based on the given configuration file. +// +// For Get(), Put() and Delete(), the returned Store will dynamically determine +// which underlying credentials store to use for the given server address. +// The underlying credentials store is determined in the following order: +// 1. Native server-specific credential helper +// 2. Native credentials store +// 3. The plain-text config file itself +// +// References: +// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store +// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewStore(configPath string, opts StoreOptions) (*DynamicStore, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + ds := &DynamicStore{ + config: cfg, + options: opts, + } + if opts.DetectDefaultNativeStore && !cfg.IsAuthConfigured() { + // no authentication configured, detect the default credentials store + ds.detectedCredsStore = getDefaultHelperSuffix() + } + return ds, nil +} + +// NewStoreFromDocker returns a Store based on the default docker config file. +// - If the $DOCKER_CONFIG environment variable is set, +// $DOCKER_CONFIG/config.json will be used. +// - Otherwise, the default location $HOME/.docker/config.json will be used. +// +// NewStoreFromDocker internally calls [NewStore]. +// +// References: +// - https://docs.docker.com/engine/reference/commandline/cli/#configuration-files +// - https://docs.docker.com/engine/reference/commandline/cli/#change-the-docker-directory +func NewStoreFromDocker(opt StoreOptions) (*DynamicStore, error) { + configPath, err := getDockerConfigPath() + if err != nil { + return nil, err + } + return NewStore(configPath, opt) +} + +// Get retrieves credentials from the store for the given server address. +func (ds *DynamicStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return ds.getStore(serverAddress).Get(ctx, serverAddress) +} + +// Put saves credentials into the store for the given server address. +// Put returns ErrPlaintextPutDisabled if native store is not available and +// [StoreOptions].AllowPlaintextPut is set to false. +func (ds *DynamicStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + if err := ds.getStore(serverAddress).Put(ctx, serverAddress, cred); err != nil { + return err + } + // save the detected creds store back to the config file on first put + return ds.setCredsStoreOnce.Do(func() error { + if ds.detectedCredsStore != "" { + if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil { + return fmt.Errorf("failed to set credsStore: %w", err) + } + } + return nil + }) +} + +// Delete removes credentials from the store for the given server address. +func (ds *DynamicStore) Delete(ctx context.Context, serverAddress string) error { + return ds.getStore(serverAddress).Delete(ctx, serverAddress) +} + +// IsAuthConfigured returns whether there is authentication configured in the +// config file or not. +// +// IsAuthConfigured returns true when: +// - The "credsStore" field is not empty +// - Or the "credHelpers" field is not empty +// - Or there is any entry in the "auths" field +func (ds *DynamicStore) IsAuthConfigured() bool { + return ds.config.IsAuthConfigured() +} + +// ConfigPath returns the path to the config file. +func (ds *DynamicStore) ConfigPath() string { + return ds.config.Path() +} + +// getHelperSuffix returns the credential helper suffix for the given server +// address. +func (ds *DynamicStore) getHelperSuffix(serverAddress string) string { + // 1. Look for a server-specific credential helper first + if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" { + return helper + } + // 2. Then look for the configured native store + if credsStore := ds.config.CredentialsStore(); credsStore != "" { + return credsStore + } + // 3. Use the detected default store + return ds.detectedCredsStore +} + +// getStore returns a store for the given server address. +func (ds *DynamicStore) getStore(serverAddress string) Store { + if helper := ds.getHelperSuffix(serverAddress); helper != "" { + return NewNativeStore(helper) + } + + fs := newFileStore(ds.config) + fs.DisablePut = !ds.options.AllowPlaintextPut + return fs +} + +// getDockerConfigPath returns the path to the default docker config file. +func getDockerConfigPath() (string, error) { + // first try the environment variable + configDir := os.Getenv(dockerConfigDirEnv) + if configDir == "" { + // then try home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + configDir = filepath.Join(homeDir, dockerConfigFileDir) + } + return filepath.Join(configDir, dockerConfigFileName), nil +} + +// storeWithFallbacks is a store that has multiple fallback stores. +type storeWithFallbacks struct { + stores []Store +} + +// NewStoreWithFallbacks returns a new store based on the given stores. +// - Get() searches the primary and the fallback stores +// for the credentials and returns when it finds the +// credentials in any of the stores. +// - Put() saves the credentials into the primary store. +// - Delete() deletes the credentials from the primary store. +func NewStoreWithFallbacks(primary Store, fallbacks ...Store) Store { + if len(fallbacks) == 0 { + return primary + } + return &storeWithFallbacks{ + stores: append([]Store{primary}, fallbacks...), + } +} + +// Get retrieves credentials from the StoreWithFallbacks for the given server. +// It searches the primary and the fallback stores for the credentials of serverAddress +// and returns when it finds the credentials in any of the stores. +func (sf *storeWithFallbacks) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + for _, s := range sf.stores { + cred, err := s.Get(ctx, serverAddress) + if err != nil { + return auth.EmptyCredential, err + } + if cred != auth.EmptyCredential { + return cred, nil + } + } + return auth.EmptyCredential, nil +} + +// Put saves credentials into the StoreWithFallbacks. It puts +// the credentials into the primary store. +func (sf *storeWithFallbacks) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + return sf.stores[0].Put(ctx, serverAddress, cred) +} + +// Delete removes credentials from the StoreWithFallbacks for the given server. +// It deletes the credentials from the primary store. +func (sf *storeWithFallbacks) Delete(ctx context.Context, serverAddress string) error { + return sf.stores[0].Delete(ctx, serverAddress) +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/credentials/trace/trace.go b/vendor/oras.land/oras-go/v2/registry/remote/credentials/trace/trace.go new file mode 100644 index 00000000..b7cd8683 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/credentials/trace/trace.go @@ -0,0 +1,94 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package trace + +import "context" + +// executableTraceContextKey is a value key used to retrieve the ExecutableTrace +// from Context. +type executableTraceContextKey struct{} + +// ExecutableTrace is a set of hooks used to trace the execution of binary +// executables. Any particular hook may be nil. +type ExecutableTrace struct { + // ExecuteStart is called before the execution of the executable. The + // executableName parameter is the name of the credential helper executable + // used with NativeStore. The action parameter is one of "store", "get" and + // "erase". + // + // Reference: + // - https://docs.docker.com/engine/reference/commandline/login#credentials-store + ExecuteStart func(executableName string, action string) + + // ExecuteDone is called after the execution of an executable completes. + // The executableName parameter is the name of the credential helper + // executable used with NativeStore. The action parameter is one of "store", + // "get" and "erase". The err parameter is the error (if any) returned from + // the execution. + // + // Reference: + // - https://docs.docker.com/engine/reference/commandline/login#credentials-store + ExecuteDone func(executableName string, action string, err error) +} + +// ContextExecutableTrace returns the ExecutableTrace associated with the +// context. If none, it returns nil. +func ContextExecutableTrace(ctx context.Context) *ExecutableTrace { + trace, _ := ctx.Value(executableTraceContextKey{}).(*ExecutableTrace) + return trace +} + +// WithExecutableTrace takes a Context and an ExecutableTrace, and returns a +// Context with the ExecutableTrace added as a Value. If the Context has a +// previously added trace, the hooks defined in the new trace will be added +// in addition to the previous ones. The recent hooks will be called first. +func WithExecutableTrace(ctx context.Context, trace *ExecutableTrace) context.Context { + if trace == nil { + return ctx + } + if oldTrace := ContextExecutableTrace(ctx); oldTrace != nil { + trace.compose(oldTrace) + } + return context.WithValue(ctx, executableTraceContextKey{}, trace) +} + +// compose takes an oldTrace and modifies the existing trace to include +// the hooks defined in the oldTrace. The hooks in the existing trace will +// be called first. +func (trace *ExecutableTrace) compose(oldTrace *ExecutableTrace) { + if oldStart := oldTrace.ExecuteStart; oldStart != nil { + start := trace.ExecuteStart + if start != nil { + trace.ExecuteStart = func(executableName, action string) { + start(executableName, action) + oldStart(executableName, action) + } + } else { + trace.ExecuteStart = oldStart + } + } + if oldDone := oldTrace.ExecuteDone; oldDone != nil { + done := trace.ExecuteDone + if done != nil { + trace.ExecuteDone = func(executableName, action string, err error) { + done(executableName, action, err) + oldDone(executableName, action, err) + } + } else { + trace.ExecuteDone = oldDone + } + } +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/errcode/errors.go b/vendor/oras.land/oras-go/v2/registry/remote/errcode/errors.go new file mode 100644 index 00000000..a32f1e5e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/errcode/errors.go @@ -0,0 +1,128 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errcode + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "unicode" +) + +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes +// - https://distribution.github.io/distribution/spec/api/#errors-2 +const ( + ErrorCodeBlobUnknown = "BLOB_UNKNOWN" + ErrorCodeBlobUploadInvalid = "BLOB_UPLOAD_INVALID" + ErrorCodeBlobUploadUnknown = "BLOB_UPLOAD_UNKNOWN" + ErrorCodeDigestInvalid = "DIGEST_INVALID" + ErrorCodeManifestBlobUnknown = "MANIFEST_BLOB_UNKNOWN" + ErrorCodeManifestInvalid = "MANIFEST_INVALID" + ErrorCodeManifestUnknown = "MANIFEST_UNKNOWN" + ErrorCodeNameInvalid = "NAME_INVALID" + ErrorCodeNameUnknown = "NAME_UNKNOWN" + ErrorCodeSizeInvalid = "SIZE_INVALID" + ErrorCodeUnauthorized = "UNAUTHORIZED" + ErrorCodeDenied = "DENIED" + ErrorCodeUnsupported = "UNSUPPORTED" +) + +// Error represents a response inner error returned by the remote +// registry. +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes +// - https://distribution.github.io/distribution/spec/api/#errors-2 +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Detail any `json:"detail,omitempty"` +} + +// Error returns a error string describing the error. +func (e Error) Error() string { + code := strings.Map(func(r rune) rune { + if r == '_' { + return ' ' + } + return unicode.ToLower(r) + }, e.Code) + if e.Message == "" { + return code + } + if e.Detail == nil { + return fmt.Sprintf("%s: %s", code, e.Message) + } + return fmt.Sprintf("%s: %s: %v", code, e.Message, e.Detail) +} + +// Errors represents a list of response inner errors returned by the remote +// server. +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes +// - https://distribution.github.io/distribution/spec/api/#errors-2 +type Errors []Error + +// Error returns a error string describing the error. +func (errs Errors) Error() string { + switch len(errs) { + case 0: + return "" + case 1: + return errs[0].Error() + } + var errmsgs []string + for _, err := range errs { + errmsgs = append(errmsgs, err.Error()) + } + return strings.Join(errmsgs, "; ") +} + +// Unwrap returns the inner error only when there is exactly one error. +func (errs Errors) Unwrap() error { + if len(errs) == 1 { + return errs[0] + } + return nil +} + +// ErrorResponse represents an error response. +type ErrorResponse struct { + Method string + URL *url.URL + StatusCode int + Errors Errors +} + +// Error returns a error string describing the error. +func (err *ErrorResponse) Error() string { + var errmsg string + if len(err.Errors) > 0 { + errmsg = err.Errors.Error() + } else { + errmsg = http.StatusText(err.StatusCode) + } + return fmt.Sprintf("%s %q: response status code %d: %s", err.Method, err.URL, err.StatusCode, errmsg) +} + +// Unwrap returns the internal errors of err if any. +func (err *ErrorResponse) Unwrap() error { + if len(err.Errors) == 0 { + return nil + } + return err.Errors +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/internal/errutil/errutil.go b/vendor/oras.land/oras-go/v2/registry/remote/internal/errutil/errutil.go new file mode 100644 index 00000000..52dc3612 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/internal/errutil/errutil.go @@ -0,0 +1,54 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errutil + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "oras.land/oras-go/v2/registry/remote/errcode" +) + +// maxErrorBytes specifies the default limit on how many response bytes are +// allowed in the server's error response. +// A typical error message is around 200 bytes. Hence, 8 KiB should be +// sufficient. +const maxErrorBytes int64 = 8 * 1024 // 8 KiB + +// ParseErrorResponse parses the error returned by the remote registry. +func ParseErrorResponse(resp *http.Response) error { + resultErr := &errcode.ErrorResponse{ + Method: resp.Request.Method, + URL: resp.Request.URL, + StatusCode: resp.StatusCode, + } + var body struct { + Errors errcode.Errors `json:"errors"` + } + lr := io.LimitReader(resp.Body, maxErrorBytes) + if err := json.NewDecoder(lr).Decode(&body); err == nil { + resultErr.Errors = body.Errors + } + return resultErr +} + +// IsErrorCode returns true if err is an Error and its Code equals to code. +func IsErrorCode(err error, code string) bool { + var ec errcode.Error + return errors.As(err, &ec) && ec.Code == code +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/manifest.go b/vendor/oras.land/oras-go/v2/registry/remote/manifest.go new file mode 100644 index 00000000..0e10297c --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/manifest.go @@ -0,0 +1,59 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/internal/docker" + "oras.land/oras-go/v2/internal/spec" +) + +// defaultManifestMediaTypes contains the default set of manifests media types. +var defaultManifestMediaTypes = []string{ + docker.MediaTypeManifest, + docker.MediaTypeManifestList, + ocispec.MediaTypeImageManifest, + ocispec.MediaTypeImageIndex, + spec.MediaTypeArtifactManifest, +} + +// defaultManifestAcceptHeader is the default set in the `Accept` header for +// resolving manifests from tags. +var defaultManifestAcceptHeader = strings.Join(defaultManifestMediaTypes, ", ") + +// isManifest determines if the given descriptor points to a manifest. +func isManifest(manifestMediaTypes []string, desc ocispec.Descriptor) bool { + if len(manifestMediaTypes) == 0 { + manifestMediaTypes = defaultManifestMediaTypes + } + for _, mediaType := range manifestMediaTypes { + if desc.MediaType == mediaType { + return true + } + } + return false +} + +// manifestAcceptHeader generates the set in the `Accept` header for resolving +// manifests from tags. +func manifestAcceptHeader(manifestMediaTypes []string) string { + if len(manifestMediaTypes) == 0 { + return defaultManifestAcceptHeader + } + return strings.Join(manifestMediaTypes, ", ") +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/referrers.go b/vendor/oras.land/oras-go/v2/registry/remote/referrers.go new file mode 100644 index 00000000..720430d3 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/referrers.go @@ -0,0 +1,225 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "errors" + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/internal/descriptor" +) + +// zeroDigest represents a digest that consists of zeros. zeroDigest is used +// for pinging Referrers API. +const zeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +// referrersState represents the state of Referrers API. +type referrersState = int32 + +const ( + // referrersStateUnknown represents an unknown state of Referrers API. + referrersStateUnknown referrersState = iota + // referrersStateSupported represents that the repository is known to + // support Referrers API. + referrersStateSupported + // referrersStateUnsupported represents that the repository is known to + // not support Referrers API. + referrersStateUnsupported +) + +// referrerOperation represents an operation on a referrer. +type referrerOperation = int32 + +const ( + // referrerOperationAdd represents an addition operation on a referrer. + referrerOperationAdd referrerOperation = iota + // referrerOperationRemove represents a removal operation on a referrer. + referrerOperationRemove +) + +// referrerChange represents a change on a referrer. +type referrerChange struct { + referrer ocispec.Descriptor + operation referrerOperation +} + +var ( + // ErrReferrersCapabilityAlreadySet is returned by SetReferrersCapability() + // when the Referrers API capability has been already set. + ErrReferrersCapabilityAlreadySet = errors.New("referrers capability cannot be changed once set") + + // errNoReferrerUpdate is returned by applyReferrerChanges() when there + // is no any referrer update. + errNoReferrerUpdate = errors.New("no referrer update") +) + +const ( + // opDeleteReferrersIndex represents the operation for deleting a + // referrers index. + opDeleteReferrersIndex = "DeleteReferrersIndex" +) + +// ReferrersError records an error and the operation and the subject descriptor. +type ReferrersError struct { + // Op represents the failing operation. + Op string + // Subject is the descriptor of referenced artifact. + Subject ocispec.Descriptor + // Err is the entity of referrers error. + Err error +} + +// Error returns error msg of IgnorableError. +func (e *ReferrersError) Error() string { + return e.Err.Error() +} + +// Unwrap returns the inner error of IgnorableError. +func (e *ReferrersError) Unwrap() error { + return errors.Unwrap(e.Err) +} + +// IsIndexDelete tells if e is kind of error related to referrers +// index deletion. +func (e *ReferrersError) IsReferrersIndexDelete() bool { + return e.Op == opDeleteReferrersIndex +} + +// buildReferrersTag builds the referrers tag for the given manifest descriptor. +// Format: - +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#unavailable-referrers-api +func buildReferrersTag(desc ocispec.Descriptor) (string, error) { + if err := desc.Digest.Validate(); err != nil { + return "", fmt.Errorf("failed to build referrers tag for %s: %w", desc.Digest, err) + } + alg := desc.Digest.Algorithm().String() + encoded := desc.Digest.Encoded() + return alg + "-" + encoded, nil +} + +// isReferrersFilterApplied checks if requsted is in the applied filter list. +func isReferrersFilterApplied(applied, requested string) bool { + if applied == "" || requested == "" { + return false + } + filters := strings.Split(applied, ",") + for _, f := range filters { + if f == requested { + return true + } + } + return false +} + +// filterReferrers filters a slice of referrers by artifactType in place. +// The returned slice contains matching referrers. +func filterReferrers(refs []ocispec.Descriptor, artifactType string) []ocispec.Descriptor { + if artifactType == "" { + return refs + } + var j int + for i, ref := range refs { + if ref.ArtifactType == artifactType { + if i != j { + refs[j] = ref + } + j++ + } + } + return refs[:j] +} + +// applyReferrerChanges applies referrerChanges on referrers and returns the +// updated referrers. +// Returns errNoReferrerUpdate if there is no any referrers updates. +func applyReferrerChanges(referrers []ocispec.Descriptor, referrerChanges []referrerChange) ([]ocispec.Descriptor, error) { + referrersMap := make(map[descriptor.Descriptor]int, len(referrers)+len(referrerChanges)) + updatedReferrers := make([]ocispec.Descriptor, 0, len(referrers)+len(referrerChanges)) + var updateRequired bool + for _, r := range referrers { + if content.Equal(r, ocispec.Descriptor{}) { + // skip bad entry + updateRequired = true + continue + } + key := descriptor.FromOCI(r) + if _, ok := referrersMap[key]; ok { + // skip duplicates + updateRequired = true + continue + } + updatedReferrers = append(updatedReferrers, r) + referrersMap[key] = len(updatedReferrers) - 1 + } + + // apply changes + for _, change := range referrerChanges { + key := descriptor.FromOCI(change.referrer) + switch change.operation { + case referrerOperationAdd: + if _, ok := referrersMap[key]; !ok { + // add distinct referrers + updatedReferrers = append(updatedReferrers, change.referrer) + referrersMap[key] = len(updatedReferrers) - 1 + } + case referrerOperationRemove: + if pos, ok := referrersMap[key]; ok { + // remove referrers that are already in the map + updatedReferrers[pos] = ocispec.Descriptor{} + delete(referrersMap, key) + } + } + } + + // skip unnecessary update + if !updateRequired && len(referrersMap) == len(referrers) { + // if the result referrer map contains the same content as the + // original referrers, consider that there is no update on the + // referrers. + for _, r := range referrers { + key := descriptor.FromOCI(r) + if _, ok := referrersMap[key]; !ok { + updateRequired = true + } + } + if !updateRequired { + return nil, errNoReferrerUpdate + } + } + + return removeEmptyDescriptors(updatedReferrers, len(referrersMap)), nil +} + +// removeEmptyDescriptors in-place removes empty items from descs, given a hint +// of the number of non-empty descriptors. +func removeEmptyDescriptors(descs []ocispec.Descriptor, hint int) []ocispec.Descriptor { + j := 0 + for i, r := range descs { + if !content.Equal(r, ocispec.Descriptor{}) { + if i > j { + descs[j] = r + } + j++ + } + if j == hint { + break + } + } + return descs[:j] +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/registry.go b/vendor/oras.land/oras-go/v2/registry/remote/registry.go new file mode 100644 index 00000000..bb707c7e --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/registry.go @@ -0,0 +1,190 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package remote provides a client to the remote registry. +// Reference: https://github.com/distribution/distribution +package remote + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/internal/errutil" +) + +// RepositoryOptions is an alias of Repository to avoid name conflicts. +// It also hides all methods associated with Repository. +type RepositoryOptions Repository + +// Registry is an HTTP client to a remote registry. +type Registry struct { + // RepositoryOptions contains common options for Registry and Repository. + // It is also used as a template for derived repositories. + RepositoryOptions + + // RepositoryListPageSize specifies the page size when invoking the catalog + // API. + // If zero, the page size is determined by the remote registry. + // Reference: https://distribution.github.io/distribution/spec/api/#catalog + RepositoryListPageSize int +} + +// NewRegistry creates a client to the remote registry with the specified domain +// name. +// Example: localhost:5000 +func NewRegistry(name string) (*Registry, error) { + ref := registry.Reference{ + Registry: name, + } + if err := ref.ValidateRegistry(); err != nil { + return nil, err + } + return &Registry{ + RepositoryOptions: RepositoryOptions{ + Reference: ref, + }, + }, nil +} + +// client returns an HTTP client used to access the remote registry. +// A default HTTP client is return if the client is not configured. +func (r *Registry) client() Client { + if r.Client == nil { + return auth.DefaultClient + } + return r.Client +} + +// do sends an HTTP request and returns an HTTP response using the HTTP client +// returned by r.client(). +func (r *Registry) do(req *http.Request) (*http.Response, error) { + if r.HandleWarning == nil { + return r.client().Do(req) + } + + resp, err := r.client().Do(req) + if err != nil { + return nil, err + } + handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) + return resp, nil +} + +// Ping checks whether or not the registry implement Docker Registry API V2 or +// OCI Distribution Specification. +// Ping can be used to check authentication when an auth client is configured. +// +// References: +// - https://distribution.github.io/distribution/spec/api/#base +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#api +func (r *Registry) Ping(ctx context.Context) error { + url := buildRegistryBaseURL(r.PlainHTTP, r.Reference) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := r.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusNotFound: + return errdef.ErrNotFound + default: + return errutil.ParseErrorResponse(resp) + } +} + +// Repositories lists the name of repositories available in the registry. +// See also `RepositoryListPageSize`. +// +// If `last` is NOT empty, the entries in the response start after the +// repo specified by `last`. Otherwise, the response starts from the top +// of the Repositories list. +// +// Reference: https://distribution.github.io/distribution/spec/api/#catalog +func (r *Registry) Repositories(ctx context.Context, last string, fn func(repos []string) error) error { + ctx = auth.AppendScopesForHost(ctx, r.Reference.Host(), auth.ScopeRegistryCatalog) + url := buildRegistryCatalogURL(r.PlainHTTP, r.Reference) + var err error + for err == nil { + url, err = r.repositories(ctx, last, fn, url) + // clear `last` for subsequent pages + last = "" + } + if err != errNoLink { + return err + } + return nil +} + +// repositories returns a single page of repository list with the next link. +func (r *Registry) repositories(ctx context.Context, last string, fn func(repos []string) error, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + if r.RepositoryListPageSize > 0 || last != "" { + q := req.URL.Query() + if r.RepositoryListPageSize > 0 { + q.Set("n", strconv.Itoa(r.RepositoryListPageSize)) + } + if last != "" { + q.Set("last", last) + } + req.URL.RawQuery = q.Encode() + } + resp, err := r.do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errutil.ParseErrorResponse(resp) + } + var page struct { + Repositories []string `json:"repositories"` + } + lr := limitReader(resp.Body, r.MaxMetadataBytes) + if err := json.NewDecoder(lr).Decode(&page); err != nil { + return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) + } + if err := fn(page.Repositories); err != nil { + return "", err + } + + return parseLink(resp) +} + +// Repository returns a repository reference by the given name. +func (r *Registry) Repository(ctx context.Context, name string) (registry.Repository, error) { + ref := registry.Reference{ + Registry: r.Reference.Registry, + Repository: name, + } + return newRepositoryWithOptions(ref, &r.RepositoryOptions) +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/repository.go b/vendor/oras.land/oras-go/v2/registry/remote/repository.go new file mode 100644 index 00000000..fed993df --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/repository.go @@ -0,0 +1,1681 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/internal/cas" + "oras.land/oras-go/v2/internal/httputil" + "oras.land/oras-go/v2/internal/ioutil" + "oras.land/oras-go/v2/internal/spec" + "oras.land/oras-go/v2/internal/syncutil" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/errcode" + "oras.land/oras-go/v2/registry/remote/internal/errutil" +) + +const ( + // headerDockerContentDigest is the "Docker-Content-Digest" header. + // If present on the response, it contains the canonical digest of the + // uploaded blob. + // + // References: + // - https://distribution.github.io/distribution/spec/api/#digest-header + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pull + headerDockerContentDigest = "Docker-Content-Digest" + + // headerOCIFiltersApplied is the "OCI-Filters-Applied" header. + // If present on the response, it contains a comma-separated list of the + // applied filters. + // + // Reference: + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers + headerOCIFiltersApplied = "OCI-Filters-Applied" + + // headerOCISubject is the "OCI-Subject" header. + // If present on the response, it contains the digest of the subject, + // indicating that Referrers API is supported by the registry. + headerOCISubject = "OCI-Subject" +) + +// filterTypeArtifactType is the "artifactType" filter applied on the list of +// referrers. +// +// References: +// - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +// - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#listing-referrers +const filterTypeArtifactType = "artifactType" + +// Client is an interface for a HTTP client. +type Client interface { + // Do sends an HTTP request and returns an HTTP response. + // + // Unlike http.RoundTripper, Client can attempt to interpret the response + // and handle higher-level protocol details such as redirects and + // authentication. + // + // Like http.RoundTripper, Client should not modify the request, and must + // always close the request body. + Do(*http.Request) (*http.Response, error) +} + +// Repository is an HTTP client to a remote repository. +type Repository struct { + // Client is the underlying HTTP client used to access the remote registry. + // If nil, auth.DefaultClient is used. + Client Client + + // Reference references the remote repository. + Reference registry.Reference + + // PlainHTTP signals the transport to access the remote repository via HTTP + // instead of HTTPS. + PlainHTTP bool + + // ManifestMediaTypes is used in `Accept` header for resolving manifests + // from references. It is also used in identifying manifests and blobs from + // descriptors. If an empty list is present, default manifest media types + // are used. + ManifestMediaTypes []string + + // TagListPageSize specifies the page size when invoking the tag list API. + // If zero, the page size is determined by the remote registry. + // Reference: https://distribution.github.io/distribution/spec/api/#tags + TagListPageSize int + + // ReferrerListPageSize specifies the page size when invoking the Referrers + // API. + // If zero, the page size is determined by the remote registry. + // + // NOTE: Pagination for the Referrers API is not defined in the distribution + // spec, so not all registries support it. ReferrerListPageSize may be + // ignored if pagination is unsupported by the remote registry. + // + // Reference: https://github.com/oras-project/oras-go/issues/841 + ReferrerListPageSize int + + // MaxMetadataBytes specifies a limit on how many response bytes are allowed + // in the server's response to the metadata APIs, such as catalog list, tag + // list, and referrers list. + // If less than or equal to zero, a default (currently 4MiB) is used. + MaxMetadataBytes int64 + + // SkipReferrersGC specifies whether to delete the dangling referrers + // index when referrers tag schema is utilized. + // - If false, the old referrers index will be deleted after the new one + // is successfully uploaded. + // - If true, the old referrers index is kept. + // By default, it is disabled (set to false). See also: + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#referrers-tag-schema + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pushing-manifests-with-subject + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#deleting-manifests + SkipReferrersGC bool + + // HandleWarning handles the warning returned by the remote server. + // Callers SHOULD deduplicate warnings from multiple associated responses. + // + // References: + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings + // - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + HandleWarning func(warning Warning) + + // NOTE: Must keep fields in sync with clone(). + + // referrersState represents that if the repository supports Referrers API. + // default: referrersStateUnknown + referrersState referrersState + + // referrersPingLock locks the pingReferrers() method and allows only + // one go-routine to send the request. + referrersPingLock sync.Mutex + + // referrersMergePool provides a way to manage concurrent updates to a + // referrers index tagged by referrers tag schema. + referrersMergePool syncutil.Pool[syncutil.Merge[referrerChange]] +} + +// NewRepository creates a client to the remote repository identified by a +// reference. +// Example: localhost:5000/hello-world +func NewRepository(reference string) (*Repository, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + return nil, err + } + return &Repository{ + Reference: ref, + }, nil +} + +// newRepositoryWithOptions returns a Repository with the given Reference and +// RepositoryOptions. +// +// RepositoryOptions are part of the Registry struct and set its defaults. +// RepositoryOptions shares the same struct definition as Repository, which +// contains unexported state that must not be copied to multiple Repositories. +// To handle this we explicitly copy only the fields that we want to reproduce. +func newRepositoryWithOptions(ref registry.Reference, opts *RepositoryOptions) (*Repository, error) { + if err := ref.ValidateRepository(); err != nil { + return nil, err + } + repo := (*Repository)(opts).clone() + repo.Reference = ref + return repo, nil +} + +// clone makes a copy of the Repository being careful not to copy non-copyable fields (sync.Mutex and syncutil.Pool types) +func (r *Repository) clone() *Repository { + return &Repository{ + Client: r.Client, + Reference: r.Reference, + PlainHTTP: r.PlainHTTP, + ManifestMediaTypes: slices.Clone(r.ManifestMediaTypes), + TagListPageSize: r.TagListPageSize, + ReferrerListPageSize: r.ReferrerListPageSize, + MaxMetadataBytes: r.MaxMetadataBytes, + SkipReferrersGC: r.SkipReferrersGC, + HandleWarning: r.HandleWarning, + } +} + +// SetReferrersCapability indicates the Referrers API capability of the remote +// repository. true: capable; false: not capable. +// +// SetReferrersCapability is valid only when it is called for the first time. +// SetReferrersCapability returns ErrReferrersCapabilityAlreadySet if the +// Referrers API capability has been already set. +// - When the capability is set to true, the Referrers() function will always +// request the Referrers API. Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +// - When the capability is set to false, the Referrers() function will always +// request the Referrers Tag. Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#referrers-tag-schema +// - When the capability is not set, the Referrers() function will automatically +// determine which API to use. +func (r *Repository) SetReferrersCapability(capable bool) error { + var state referrersState + if capable { + state = referrersStateSupported + } else { + state = referrersStateUnsupported + } + if swapped := atomic.CompareAndSwapInt32(&r.referrersState, referrersStateUnknown, state); !swapped { + if fact := r.loadReferrersState(); fact != state { + return fmt.Errorf("%w: current capability = %v, new capability = %v", + ErrReferrersCapabilityAlreadySet, + fact == referrersStateSupported, + capable) + } + } + return nil +} + +// setReferrersState atomically loads r.referrersState. +func (r *Repository) loadReferrersState() referrersState { + return atomic.LoadInt32(&r.referrersState) +} + +// client returns an HTTP client used to access the remote repository. +// A default HTTP client is return if the client is not configured. +func (r *Repository) client() Client { + if r.Client == nil { + return auth.DefaultClient + } + return r.Client +} + +// do sends an HTTP request and returns an HTTP response using the HTTP client +// returned by r.client(). +func (r *Repository) do(req *http.Request) (*http.Response, error) { + if r.HandleWarning == nil { + return r.client().Do(req) + } + + resp, err := r.client().Do(req) + if err != nil { + return nil, err + } + handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning) + return resp, nil +} + +// blobStore detects the blob store for the given descriptor. +func (r *Repository) blobStore(desc ocispec.Descriptor) registry.BlobStore { + if isManifest(r.ManifestMediaTypes, desc) { + return r.Manifests() + } + return r.Blobs() +} + +// Fetch fetches the content identified by the descriptor. +func (r *Repository) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) { + return r.blobStore(target).Fetch(ctx, target) +} + +// Push pushes the content, matching the expected descriptor. +func (r *Repository) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { + return r.blobStore(expected).Push(ctx, expected, content) +} + +// Mount makes the blob with the given digest in fromRepo +// available in the repository signified by the receiver. +// +// This avoids the need to pull content down from fromRepo only to push it to r. +// +// If the registry does not implement mounting, getContent will be used to get the +// content to push. If getContent is nil, the content will be pulled from the source +// repository. If getContent returns an error, it will be wrapped inside the error +// returned from Mount. +func (r *Repository) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error)) error { + return r.Blobs().(registry.Mounter).Mount(ctx, desc, fromRepo, getContent) +} + +// Exists returns true if the described content exists. +func (r *Repository) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { + return r.blobStore(target).Exists(ctx, target) +} + +// Delete removes the content identified by the descriptor. +func (r *Repository) Delete(ctx context.Context, target ocispec.Descriptor) error { + return r.blobStore(target).Delete(ctx, target) +} + +// Blobs provides access to the blob CAS only, which contains config blobs, +// layers, and other generic blobs. +func (r *Repository) Blobs() registry.BlobStore { + return &blobStore{repo: r} +} + +// Manifests provides access to the manifest CAS only. +func (r *Repository) Manifests() registry.ManifestStore { + return &manifestStore{repo: r} +} + +// Resolve resolves a reference to a manifest descriptor. +// See also `ManifestMediaTypes`. +func (r *Repository) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { + return r.Manifests().Resolve(ctx, reference) +} + +// Tag tags a manifest descriptor with a reference string. +func (r *Repository) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { + return r.Manifests().Tag(ctx, desc, reference) +} + +// PushReference pushes the manifest with a reference tag. +func (r *Repository) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { + return r.Manifests().PushReference(ctx, expected, content, reference) +} + +// FetchReference fetches the manifest identified by the reference. +// The reference can be a tag or digest. +func (r *Repository) FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) { + return r.Manifests().FetchReference(ctx, reference) +} + +// ParseReference resolves a tag or a digest reference to a fully qualified +// reference from a base reference r.Reference. +// Tag, digest, or fully qualified references are accepted as input. +// +// If reference is a fully qualified reference, then ParseReference parses it +// and returns the parsed reference. If the parsed reference does not share +// the same base reference with the Repository r, ParseReference returns a +// wrapped error ErrInvalidReference. +func (r *Repository) ParseReference(reference string) (registry.Reference, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + ref = registry.Reference{ + Registry: r.Reference.Registry, + Repository: r.Reference.Repository, + Reference: reference, + } + + // reference is not a FQDN + if index := strings.IndexByte(reference, '@'); index != -1 { + // `@` implies *digest*, so drop the *tag* (irrespective of what it is). + ref.Reference = reference[index+1:] + err = ref.ValidateReferenceAsDigest() + } else { + err = ref.ValidateReference() + } + + if err != nil { + return registry.Reference{}, err + } + } else if ref.Registry != r.Reference.Registry || ref.Repository != r.Reference.Repository { + return registry.Reference{}, fmt.Errorf( + "%w: mismatch between received %q and expected %q", + errdef.ErrInvalidReference, ref, r.Reference, + ) + } + + if len(ref.Reference) == 0 { + return registry.Reference{}, errdef.ErrInvalidReference + } + + return ref, nil +} + +// Tags lists the tags available in the repository. +// See also `TagListPageSize`. +// If `last` is NOT empty, the entries in the response start after the +// tag specified by `last`. Otherwise, the response starts from the top +// of the Tags list. +// +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#content-discovery +// - https://distribution.github.io/distribution/spec/api/#tags +func (r *Repository) Tags(ctx context.Context, last string, fn func(tags []string) error) error { + ctx = auth.AppendRepositoryScope(ctx, r.Reference, auth.ActionPull) + url := buildRepositoryTagListURL(r.PlainHTTP, r.Reference) + var err error + for err == nil { + url, err = r.tags(ctx, last, fn, url) + // clear `last` for subsequent pages + last = "" + } + if err != errNoLink { + return err + } + return nil +} + +// tags returns a single page of tag list with the next link. +func (r *Repository) tags(ctx context.Context, last string, fn func(tags []string) error, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + if r.TagListPageSize > 0 || last != "" { + q := req.URL.Query() + if r.TagListPageSize > 0 { + q.Set("n", strconv.Itoa(r.TagListPageSize)) + } + if last != "" { + q.Set("last", last) + } + req.URL.RawQuery = q.Encode() + } + resp, err := r.do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errutil.ParseErrorResponse(resp) + } + var page struct { + Tags []string `json:"tags"` + } + lr := limitReader(resp.Body, r.MaxMetadataBytes) + if err := json.NewDecoder(lr).Decode(&page); err != nil { + return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) + } + if err := fn(page.Tags); err != nil { + return "", err + } + + return parseLink(resp) +} + +// Predecessors returns the descriptors of image or artifact manifests directly +// referencing the given manifest descriptor. +// Predecessors internally leverages Referrers. +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +func (r *Repository) Predecessors(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + var res []ocispec.Descriptor + if err := r.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error { + res = append(res, referrers...) + return nil + }); err != nil { + return nil, err + } + return res, nil +} + +// Referrers lists the descriptors of image or artifact manifests directly +// referencing the given manifest descriptor. +// +// fn is called for each page of the referrers result. +// If artifactType is not empty, only referrers of the same artifact type are +// fed to fn. +// +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +func (r *Repository) Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { + state := r.loadReferrersState() + if state == referrersStateUnsupported { + // The repository is known to not support Referrers API, fallback to + // referrers tag schema. + return r.referrersByTagSchema(ctx, desc, artifactType, fn) + } + + err := r.referrersByAPI(ctx, desc, artifactType, fn) + if state == referrersStateSupported { + // The repository is known to support Referrers API, no fallback. + return err + } + + // The referrers state is unknown. + if err != nil { + if errors.Is(err, errdef.ErrUnsupported) { + // Referrers API is not supported, fallback to referrers tag schema. + r.SetReferrersCapability(false) + return r.referrersByTagSchema(ctx, desc, artifactType, fn) + } + return err + } + + r.SetReferrersCapability(true) + return nil +} + +// referrersByAPI lists the descriptors of manifests directly referencing +// the given manifest descriptor by requesting Referrers API. +// fn is called for the referrers result. If artifactType is not empty, +// only referrers of the same artifact type are fed to fn. +func (r *Repository) referrersByAPI(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { + ref := r.Reference + ref.Reference = desc.Digest.String() + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + + url := buildReferrersURL(r.PlainHTTP, ref, artifactType) + var err error + for err == nil { + url, err = r.referrersPageByAPI(ctx, artifactType, fn, url) + } + if err == errNoLink { + return nil + } + return err +} + +// referrersPageByAPI lists a single page of the descriptors of manifests +// directly referencing the given manifest descriptor. fn is called for +// a page of referrersPageByAPI result. +// If artifactType is not empty, only referrersPageByAPI of the same +// artifact type are fed to fn. +// referrersPageByAPI returns the link url for the next page. +func (r *Repository) referrersPageByAPI(ctx context.Context, artifactType string, fn func(referrers []ocispec.Descriptor) error, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + if r.ReferrerListPageSize > 0 { + q := req.URL.Query() + q.Set("n", strconv.Itoa(r.ReferrerListPageSize)) + req.URL.RawQuery = q.Encode() + } + + resp, err := r.do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + if errResp := errutil.ParseErrorResponse(resp); errutil.IsErrorCode(errResp, errcode.ErrorCodeNameUnknown) { + // The repository is not found, Referrers API status is unknown + return "", errResp + } + // Referrers API is not supported. + return "", fmt.Errorf("failed to query referrers API: %w", errdef.ErrUnsupported) + default: + return "", errutil.ParseErrorResponse(resp) + } + + // also check the content type + if ct := resp.Header.Get("Content-Type"); ct != ocispec.MediaTypeImageIndex { + return "", fmt.Errorf("unknown content returned (%s), expecting image index: %w", ct, errdef.ErrUnsupported) + } + + var index ocispec.Index + lr := limitReader(resp.Body, r.MaxMetadataBytes) + if err := json.NewDecoder(lr).Decode(&index); err != nil { + return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err) + } + + referrers := index.Manifests + if artifactType != "" { + // check both filters header and filters annotations for compatibility + // latest spec for filters header: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers + // older spec for filters annotations: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#listing-referrers + filtersHeader := resp.Header.Get(headerOCIFiltersApplied) + filtersAnnotation := index.Annotations[spec.AnnotationReferrersFiltersApplied] + if !isReferrersFilterApplied(filtersHeader, filterTypeArtifactType) && + !isReferrersFilterApplied(filtersAnnotation, filterTypeArtifactType) { + // perform client side filtering if the filter is not applied on the server side + referrers = filterReferrers(referrers, artifactType) + } + } + if len(referrers) > 0 { + if err := fn(referrers); err != nil { + return "", err + } + } + return parseLink(resp) +} + +// referrersByTagSchema lists the descriptors of manifests directly +// referencing the given manifest descriptor by requesting referrers tag. +// fn is called for the referrers result. If artifactType is not empty, +// only referrers of the same artifact type are fed to fn. +// reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#backwards-compatibility +func (r *Repository) referrersByTagSchema(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error { + referrersTag, err := buildReferrersTag(desc) + if err != nil { + return err + } + _, referrers, err := r.referrersFromIndex(ctx, referrersTag) + if err != nil { + if errors.Is(err, errdef.ErrNotFound) { + // no referrers to the manifest + return nil + } + return err + } + + filtered := filterReferrers(referrers, artifactType) + if len(filtered) == 0 { + return nil + } + return fn(filtered) +} + +// referrersFromIndex queries the referrers index using the the given referrers +// tag. If Succeeded, returns the descriptor of referrers index and the +// referrers list. +func (r *Repository) referrersFromIndex(ctx context.Context, referrersTag string) (ocispec.Descriptor, []ocispec.Descriptor, error) { + desc, rc, err := r.FetchReference(ctx, referrersTag) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + defer rc.Close() + + if err := limitSize(desc, r.MaxMetadataBytes); err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to read referrers index from referrers tag %s: %w", referrersTag, err) + } + var index ocispec.Index + if err := decodeJSON(rc, desc, &index); err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to decode referrers index from referrers tag %s: %w", referrersTag, err) + } + + return desc, index.Manifests, nil +} + +// pingReferrers returns true if the Referrers API is available for r. +func (r *Repository) pingReferrers(ctx context.Context) (bool, error) { + switch r.loadReferrersState() { + case referrersStateSupported: + return true, nil + case referrersStateUnsupported: + return false, nil + } + + // referrers state is unknown + // limit the rate of pinging referrers API + r.referrersPingLock.Lock() + defer r.referrersPingLock.Unlock() + + switch r.loadReferrersState() { + case referrersStateSupported: + return true, nil + case referrersStateUnsupported: + return false, nil + } + + ref := r.Reference + ref.Reference = zeroDigest + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + + url := buildReferrersURL(r.PlainHTTP, ref, "") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + resp, err := r.do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + supported := resp.Header.Get("Content-Type") == ocispec.MediaTypeImageIndex + r.SetReferrersCapability(supported) + return supported, nil + case http.StatusNotFound: + if err := errutil.ParseErrorResponse(resp); errutil.IsErrorCode(err, errcode.ErrorCodeNameUnknown) { + // repository not found + return false, err + } + r.SetReferrersCapability(false) + return false, nil + default: + return false, errutil.ParseErrorResponse(resp) + } +} + +// delete removes the content identified by the descriptor in the entity "blobs" +// or "manifests". +func (r *Repository) delete(ctx context.Context, target ocispec.Descriptor, isManifest bool) error { + ref := r.Reference + ref.Reference = target.Digest.String() + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionDelete) + buildURL := buildRepositoryBlobURL + if isManifest { + buildURL = buildRepositoryManifestURL + } + url := buildURL(r.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + + resp, err := r.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusAccepted: + return verifyContentDigest(resp, target.Digest) + case http.StatusNotFound: + return fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) + default: + return errutil.ParseErrorResponse(resp) + } +} + +// blobStore accesses the blob part of the repository. +type blobStore struct { + repo *Repository +} + +// Fetch fetches the content identified by the descriptor. +func (s *blobStore) Fetch(ctx context.Context, target ocispec.Descriptor) (rc io.ReadCloser, err error) { + ref := s.repo.Reference + ref.Reference = target.Digest.String() + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := s.repo.do(req) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + resp.Body.Close() + } + }() + + switch resp.StatusCode { + case http.StatusOK: + if size := resp.ContentLength; size != -1 && size != target.Size { + return nil, fmt.Errorf("%s %q: mismatch Content-Length", resp.Request.Method, resp.Request.URL) + } + if err := verifyContentDigest(resp, target.Digest); err != nil { + return nil, err + } + + // check server range request capability. + // Docker spec allows range header form of "Range: bytes=-". + // However, the remote server may still not RFC 7233 compliant. + // Reference: https://distribution.github.io/distribution/spec/api/#blob + if rangeUnit := resp.Header.Get("Accept-Ranges"); rangeUnit == "bytes" { + return httputil.NewReadSeekCloser(s.repo.client(), req, resp.Body, target.Size), nil + } + return resp.Body, nil + case http.StatusNotFound: + return nil, fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) + default: + return nil, errutil.ParseErrorResponse(resp) + } +} + +// Mount mounts the given descriptor from fromRepo into s. +func (s *blobStore) Mount(ctx context.Context, desc ocispec.Descriptor, fromRepo string, getContent func() (io.ReadCloser, error)) error { + // pushing usually requires both pull and push actions. + // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 + ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionPush) + + // We also need pull access to the source repo. + fromRef := s.repo.Reference + fromRef.Repository = fromRepo + ctx = auth.AppendRepositoryScope(ctx, fromRef, auth.ActionPull) + + url := buildRepositoryBlobMountURL(s.repo.PlainHTTP, s.repo.Reference, desc.Digest, fromRepo) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + resp, err := s.repo.do(req) + if err != nil { + return err + } + if resp.StatusCode == http.StatusCreated { + defer resp.Body.Close() + // Check the server seems to be behaving. + return verifyContentDigest(resp, desc.Digest) + } + if resp.StatusCode != http.StatusAccepted { + defer resp.Body.Close() + return errutil.ParseErrorResponse(resp) + } + resp.Body.Close() + // From the [spec]: + // + // "If a registry does not support cross-repository mounting + // or is unable to mount the requested blob, + // it SHOULD return a 202. + // This indicates that the upload session has begun + // and that the client MAY proceed with the upload." + // + // So we need to get the content from somewhere in order to + // push it. If the caller has provided a getContent function, we + // can use that, otherwise pull the content from the source repository. + // + // [spec]: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#mounting-a-blob-from-another-repository + + var r io.ReadCloser + if getContent != nil { + r, err = getContent() + } else { + r, err = s.sibling(fromRepo).Fetch(ctx, desc) + } + if err != nil { + return fmt.Errorf("cannot read source blob: %w", err) + } + defer r.Close() + return s.completePushAfterInitialPost(ctx, req, resp, desc, r) +} + +// sibling returns a blob store for another repository in the same +// registry. +func (s *blobStore) sibling(otherRepoName string) *blobStore { + otherRepo := s.repo.clone() + otherRepo.Reference.Repository = otherRepoName + return &blobStore{ + repo: otherRepo, + } +} + +// Push pushes the content, matching the expected descriptor. +// Existing content is not checked by Push() to minimize the number of out-going +// requests. +// Push is done by conventional 2-step monolithic upload instead of a single +// `POST` request for better overall performance. It also allows early fail on +// authentication errors. +// +// References: +// - https://distribution.github.io/distribution/spec/api/#pushing-an-image +// - https://distribution.github.io/distribution/spec/api/#initiate-blob-upload +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pushing-a-blob-monolithically +func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { + // start an upload + // pushing usually requires both pull and push actions. + // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 + ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionPush) + url := buildRepositoryBlobUploadURL(s.repo.PlainHTTP, s.repo.Reference) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + + resp, err := s.repo.do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusAccepted { + defer resp.Body.Close() + return errutil.ParseErrorResponse(resp) + } + resp.Body.Close() + return s.completePushAfterInitialPost(ctx, req, resp, expected, content) +} + +// completePushAfterInitialPost implements step 2 of the push protocol. This can be invoked either by +// Push or by Mount when the receiving repository does not implement the +// mount endpoint. +func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.Request, resp *http.Response, expected ocispec.Descriptor, content io.Reader) error { + reqHostname := req.URL.Hostname() + reqPort := req.URL.Port() + // monolithic upload + location, err := resp.Location() + if err != nil { + return err + } + // work-around solution for https://github.com/oras-project/oras-go/issues/177 + // For some registries, if the port 443 is explicitly set to the hostname + // like registry.wabbit-networks.io:443/myrepo, blob push will fail since + // the hostname of the Location header in the response is set to + // registry.wabbit-networks.io instead of registry.wabbit-networks.io:443. + locationHostname := location.Hostname() + locationPort := location.Port() + // if location port 443 is missing, add it back + if reqPort == "443" && locationHostname == reqHostname && locationPort == "" { + location.Host = locationHostname + ":" + reqPort + } + url := location.String() + req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, content) + if err != nil { + return err + } + if req.GetBody != nil && req.ContentLength != expected.Size { + // short circuit a size mismatch for built-in types. + return fmt.Errorf("mismatch content length %d: expect %d", req.ContentLength, expected.Size) + } + req.ContentLength = expected.Size + // the expected media type is ignored as in the API doc. + req.Header.Set("Content-Type", "application/octet-stream") + q := req.URL.Query() + q.Set("digest", expected.Digest.String()) + req.URL.RawQuery = q.Encode() + + // reuse credential from previous POST request + if auth := resp.Request.Header.Get("Authorization"); auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err = s.repo.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return errutil.ParseErrorResponse(resp) + } + return nil +} + +// Exists returns true if the described content exists. +func (s *blobStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { + _, err := s.Resolve(ctx, target.Digest.String()) + if err == nil { + return true, nil + } + if errors.Is(err, errdef.ErrNotFound) { + return false, nil + } + return false, err +} + +// Delete removes the content identified by the descriptor. +func (s *blobStore) Delete(ctx context.Context, target ocispec.Descriptor) error { + return s.repo.delete(ctx, target, false) +} + +// Resolve resolves a reference to a descriptor. +func (s *blobStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return ocispec.Descriptor{}, err + } + refDigest, err := ref.Digest() + if err != nil { + return ocispec.Descriptor{}, err + } + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return ocispec.Descriptor{}, err + } + + resp, err := s.repo.do(req) + if err != nil { + return ocispec.Descriptor{}, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return generateBlobDescriptor(resp, refDigest) + case http.StatusNotFound: + return ocispec.Descriptor{}, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) + default: + return ocispec.Descriptor{}, errutil.ParseErrorResponse(resp) + } +} + +// FetchReference fetches the blob identified by the reference. +// The reference must be a digest. +func (s *blobStore) FetchReference(ctx context.Context, reference string) (desc ocispec.Descriptor, rc io.ReadCloser, err error) { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + refDigest, err := ref.Digest() + if err != nil { + return ocispec.Descriptor{}, nil, err + } + + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryBlobURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + + resp, err := s.repo.do(req) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + defer func() { + if err != nil { + resp.Body.Close() + } + }() + + switch resp.StatusCode { + case http.StatusOK: // server does not support seek as `Range` was ignored. + if resp.ContentLength == -1 { + desc, err = s.Resolve(ctx, reference) + } else { + desc, err = generateBlobDescriptor(resp, refDigest) + } + if err != nil { + return ocispec.Descriptor{}, nil, err + } + + // check server range request capability. + // Docker spec allows range header form of "Range: bytes=-". + // However, the remote server may still not RFC 7233 compliant. + // Reference: https://distribution.github.io/distribution/spec/api/#blob + if rangeUnit := resp.Header.Get("Accept-Ranges"); rangeUnit == "bytes" { + return desc, httputil.NewReadSeekCloser(s.repo.client(), req, resp.Body, desc.Size), nil + } + return desc, resp.Body, nil + case http.StatusNotFound: + return ocispec.Descriptor{}, nil, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) + default: + return ocispec.Descriptor{}, nil, errutil.ParseErrorResponse(resp) + } +} + +// generateBlobDescriptor returns a descriptor generated from the response. +func generateBlobDescriptor(resp *http.Response, refDigest digest.Digest) (ocispec.Descriptor, error) { + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if mediaType == "" { + mediaType = "application/octet-stream" + } + + size := resp.ContentLength + if size == -1 { + return ocispec.Descriptor{}, fmt.Errorf("%s %q: unknown response Content-Length", resp.Request.Method, resp.Request.URL) + } + + if err := verifyContentDigest(resp, refDigest); err != nil { + return ocispec.Descriptor{}, err + } + + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: refDigest, + Size: size, + }, nil +} + +// manifestStore accesses the manifest part of the repository. +type manifestStore struct { + repo *Repository +} + +// Fetch fetches the content identified by the descriptor. +func (s *manifestStore) Fetch(ctx context.Context, target ocispec.Descriptor) (rc io.ReadCloser, err error) { + ref := s.repo.Reference + ref.Reference = target.Digest.String() + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", target.MediaType) + + resp, err := s.repo.do(req) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + resp.Body.Close() + } + }() + + switch resp.StatusCode { + case http.StatusOK: + // no-op + case http.StatusNotFound: + return nil, fmt.Errorf("%s: %w", target.Digest, errdef.ErrNotFound) + default: + return nil, errutil.ParseErrorResponse(resp) + } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return nil, fmt.Errorf("%s %q: invalid response Content-Type: %w", resp.Request.Method, resp.Request.URL, err) + } + if mediaType != target.MediaType { + return nil, fmt.Errorf("%s %q: mismatch response Content-Type %q: expect %q", resp.Request.Method, resp.Request.URL, mediaType, target.MediaType) + } + if size := resp.ContentLength; size != -1 && size != target.Size { + return nil, fmt.Errorf("%s %q: mismatch Content-Length", resp.Request.Method, resp.Request.URL) + } + if err := verifyContentDigest(resp, target.Digest); err != nil { + return nil, err + } + return resp.Body, nil +} + +// Push pushes the content, matching the expected descriptor. +func (s *manifestStore) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error { + return s.pushWithIndexing(ctx, expected, content, expected.Digest.String()) +} + +// Exists returns true if the described content exists. +func (s *manifestStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) { + _, err := s.Resolve(ctx, target.Digest.String()) + if err == nil { + return true, nil + } + if errors.Is(err, errdef.ErrNotFound) { + return false, nil + } + return false, err +} + +// Delete removes the manifest content identified by the descriptor. +func (s *manifestStore) Delete(ctx context.Context, target ocispec.Descriptor) error { + return s.deleteWithIndexing(ctx, target) +} + +// deleteWithIndexing removes the manifest content identified by the descriptor, +// and indexes referrers for the manifest when needed. +func (s *manifestStore) deleteWithIndexing(ctx context.Context, target ocispec.Descriptor) error { + switch target.MediaType { + case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: + if state := s.repo.loadReferrersState(); state == referrersStateSupported { + // referrers API is available, no client-side indexing needed + return s.repo.delete(ctx, target, true) + } + + if err := limitSize(target, s.repo.MaxMetadataBytes); err != nil { + return err + } + ctx = auth.AppendRepositoryScope(ctx, s.repo.Reference, auth.ActionPull, auth.ActionDelete) + manifestJSON, err := content.FetchAll(ctx, s, target) + if err != nil { + return err + } + if err := s.indexReferrersForDelete(ctx, target, manifestJSON); err != nil { + return err + } + } + + return s.repo.delete(ctx, target, true) +} + +// indexReferrersForDelete indexes referrers for manifests with a subject field +// on manifest delete. +// +// References: +// - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#deleting-manifests +// - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#deleting-manifests +func (s *manifestStore) indexReferrersForDelete(ctx context.Context, desc ocispec.Descriptor, manifestJSON []byte) error { + var manifest struct { + Subject *ocispec.Descriptor `json:"subject"` + } + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) + } + if manifest.Subject == nil { + // no subject, no indexing needed + return nil + } + + subject := *manifest.Subject + ok, err := s.repo.pingReferrers(ctx) + if err != nil { + return err + } + if ok { + // referrers API is available, no client-side indexing needed + return nil + } + return s.updateReferrersIndex(ctx, subject, referrerChange{desc, referrerOperationRemove}) +} + +// Resolve resolves a reference to a descriptor. +// See also `ManifestMediaTypes`. +func (s *manifestStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return ocispec.Descriptor{}, err + } + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return ocispec.Descriptor{}, err + } + req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) + + resp, err := s.repo.do(req) + if err != nil { + return ocispec.Descriptor{}, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return s.generateDescriptor(resp, ref, req.Method) + case http.StatusNotFound: + return ocispec.Descriptor{}, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) + default: + return ocispec.Descriptor{}, errutil.ParseErrorResponse(resp) + } +} + +// FetchReference fetches the manifest identified by the reference. +// The reference can be a tag or digest. +func (s *manifestStore) FetchReference(ctx context.Context, reference string) (desc ocispec.Descriptor, rc io.ReadCloser, err error) { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull) + url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + req.Header.Set("Accept", manifestAcceptHeader(s.repo.ManifestMediaTypes)) + + resp, err := s.repo.do(req) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + defer func() { + if err != nil { + resp.Body.Close() + } + }() + + switch resp.StatusCode { + case http.StatusOK: + if resp.ContentLength == -1 { + desc, err = s.Resolve(ctx, reference) + } else { + desc, err = s.generateDescriptor(resp, ref, req.Method) + } + if err != nil { + return ocispec.Descriptor{}, nil, err + } + return desc, resp.Body, nil + case http.StatusNotFound: + return ocispec.Descriptor{}, nil, fmt.Errorf("%s: %w", ref, errdef.ErrNotFound) + default: + return ocispec.Descriptor{}, nil, errutil.ParseErrorResponse(resp) + } +} + +// Tag tags a manifest descriptor with a reference string. +func (s *manifestStore) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return err + } + + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) + rc, err := s.Fetch(ctx, desc) + if err != nil { + return err + } + defer rc.Close() + + return s.push(ctx, desc, rc, ref.Reference) +} + +// PushReference pushes the manifest with a reference tag. +func (s *manifestStore) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { + ref, err := s.repo.ParseReference(reference) + if err != nil { + return err + } + return s.pushWithIndexing(ctx, expected, content, ref.Reference) +} + +// push pushes the manifest content, matching the expected descriptor. +func (s *manifestStore) push(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error { + ref := s.repo.Reference + ref.Reference = reference + // pushing usually requires both pull and push actions. + // Reference: https://github.com/distribution/distribution/blob/v2.7.1/registry/handlers/app.go#L921-L930 + ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush) + url := buildRepositoryManifestURL(s.repo.PlainHTTP, ref) + // unwrap the content for optimizations of built-in types. + body := ioutil.UnwrapNopCloser(content) + if _, ok := body.(io.ReadCloser); ok { + // undo unwrap if the nopCloser is intended. + body = content + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) + if err != nil { + return err + } + if req.GetBody != nil && req.ContentLength != expected.Size { + // short circuit a size mismatch for built-in types. + return fmt.Errorf("mismatch content length %d: expect %d", req.ContentLength, expected.Size) + } + req.ContentLength = expected.Size + req.Header.Set("Content-Type", expected.MediaType) + + // if the underlying client is an auth client, the content might be read + // more than once for obtaining the auth challenge and the actual request. + // To prevent double reading, the manifest is read and stored in the memory, + // and serve from the memory. + client := s.repo.client() + if _, ok := client.(*auth.Client); ok && req.GetBody == nil { + store := cas.NewMemory() + err := store.Push(ctx, expected, content) + if err != nil { + return err + } + req.GetBody = func() (io.ReadCloser, error) { + return store.Fetch(ctx, expected) + } + req.Body, err = req.GetBody() + if err != nil { + return err + } + } + resp, err := s.repo.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return errutil.ParseErrorResponse(resp) + } + s.checkOCISubjectHeader(resp) + return verifyContentDigest(resp, expected.Digest) +} + +// checkOCISubjectHeader checks the "OCI-Subject" header in the response and +// sets referrers capability accordingly. +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pushing-manifests-with-subject +func (s *manifestStore) checkOCISubjectHeader(resp *http.Response) { + // If the "OCI-Subject" header is set, it indicates that the registry + // supports the Referrers API and has processed the subject of the manifest. + if subjectHeader := resp.Header.Get(headerOCISubject); subjectHeader != "" { + s.repo.SetReferrersCapability(true) + } + + // If the "OCI-Subject" header is NOT set, it means that either the manifest + // has no subject OR the referrers API is NOT supported by the registry. + // + // Since we don't know whether the pushed manifest has a subject or not, + // we do not set the referrers capability to false at here. +} + +// pushWithIndexing pushes the manifest content matching the expected descriptor, +// and indexes referrers for the manifest when needed. +func (s *manifestStore) pushWithIndexing(ctx context.Context, expected ocispec.Descriptor, r io.Reader, reference string) error { + switch expected.MediaType { + case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: + if state := s.repo.loadReferrersState(); state == referrersStateSupported { + // referrers API is available, no client-side indexing needed + return s.push(ctx, expected, r, reference) + } + + if err := limitSize(expected, s.repo.MaxMetadataBytes); err != nil { + return err + } + manifestJSON, err := content.ReadAll(r, expected) + if err != nil { + return err + } + if err := s.push(ctx, expected, bytes.NewReader(manifestJSON), reference); err != nil { + return err + } + // check referrers API availability again after push + if state := s.repo.loadReferrersState(); state == referrersStateSupported { + // the subject has been processed the registry, no client-side + // indexing needed + return nil + } + return s.indexReferrersForPush(ctx, expected, manifestJSON) + default: + return s.push(ctx, expected, r, reference) + } +} + +// indexReferrersForPush indexes referrers for manifests with a subject field +// on manifest push. +// +// References: +// - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pushing-manifests-with-subject +// - Compatible spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#pushing-manifests-with-subject +func (s *manifestStore) indexReferrersForPush(ctx context.Context, desc ocispec.Descriptor, manifestJSON []byte) error { + var subject ocispec.Descriptor + switch desc.MediaType { + case spec.MediaTypeArtifactManifest: + var manifest spec.Artifact + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) + } + if manifest.Subject == nil { + // no subject, no indexing needed + return nil + } + subject = *manifest.Subject + desc.ArtifactType = manifest.ArtifactType + desc.Annotations = manifest.Annotations + case ocispec.MediaTypeImageManifest: + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) + } + if manifest.Subject == nil { + // no subject, no indexing needed + return nil + } + subject = *manifest.Subject + desc.ArtifactType = manifest.ArtifactType + if desc.ArtifactType == "" { + desc.ArtifactType = manifest.Config.MediaType + } + desc.Annotations = manifest.Annotations + case ocispec.MediaTypeImageIndex: + var manifest ocispec.Index + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("failed to decode manifest: %s: %s: %w", desc.Digest, desc.MediaType, err) + } + if manifest.Subject == nil { + // no subject, no indexing needed + return nil + } + subject = *manifest.Subject + desc.ArtifactType = manifest.ArtifactType + desc.Annotations = manifest.Annotations + default: + return nil + } + + // if the manifest has a subject but the remote registry does not process it, + // it means that the Referrers API is not supported by the registry. + s.repo.SetReferrersCapability(false) + return s.updateReferrersIndex(ctx, subject, referrerChange{desc, referrerOperationAdd}) +} + +// updateReferrersIndex updates the referrers index for desc referencing subject +// on manifest push and manifest delete. +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pushing-manifests-with-subject +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#deleting-manifests +func (s *manifestStore) updateReferrersIndex(ctx context.Context, subject ocispec.Descriptor, change referrerChange) (err error) { + referrersTag, err := buildReferrersTag(subject) + if err != nil { + return err + } + + var oldIndexDesc *ocispec.Descriptor + var oldReferrers []ocispec.Descriptor + prepare := func() error { + // 1. pull the original referrers list using the referrers tag schema + indexDesc, referrers, err := s.repo.referrersFromIndex(ctx, referrersTag) + if err != nil { + if errors.Is(err, errdef.ErrNotFound) { + // valid case: no old referrers index + return nil + } + return err + } + oldIndexDesc = &indexDesc + oldReferrers = referrers + return nil + } + update := func(referrerChanges []referrerChange) error { + // 2. apply the referrer changes on the referrers list + updatedReferrers, err := applyReferrerChanges(oldReferrers, referrerChanges) + if err != nil { + if err == errNoReferrerUpdate { + return nil + } + return err + } + + // 3. push the updated referrers list using referrers tag schema + if len(updatedReferrers) > 0 || s.repo.SkipReferrersGC { + // push a new index in either case: + // 1. the referrers list has been updated with a non-zero size + // 2. OR the updated referrers list is empty but referrers GC + // is skipped, in this case an empty index should still be pushed + // as the old index won't get deleted + newIndexDesc, newIndex, err := generateIndex(updatedReferrers) + if err != nil { + return fmt.Errorf("failed to generate referrers index for referrers tag %s: %w", referrersTag, err) + } + if err := s.push(ctx, newIndexDesc, bytes.NewReader(newIndex), referrersTag); err != nil { + return fmt.Errorf("failed to push referrers index tagged by %s: %w", referrersTag, err) + } + } + + // 4. delete the dangling original referrers index, if applicable + if s.repo.SkipReferrersGC || oldIndexDesc == nil { + return nil + } + if err := s.repo.delete(ctx, *oldIndexDesc, true); err != nil { + return &ReferrersError{ + Op: opDeleteReferrersIndex, + Err: fmt.Errorf("failed to delete dangling referrers index %s for referrers tag %s: %w", oldIndexDesc.Digest.String(), referrersTag, err), + Subject: subject, + } + } + return nil + } + + merge, done := s.repo.referrersMergePool.Get(referrersTag) + defer done() + return merge.Do(change, prepare, update) +} + +// ParseReference parses a reference to a fully qualified reference. +func (s *manifestStore) ParseReference(reference string) (registry.Reference, error) { + return s.repo.ParseReference(reference) +} + +// generateDescriptor returns a descriptor generated from the response. +// See the truth table at the top of `repository_test.go` +func (s *manifestStore) generateDescriptor(resp *http.Response, ref registry.Reference, httpMethod string) (ocispec.Descriptor, error) { + // 1. Validate Content-Type + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf( + "%s %q: invalid response `Content-Type` header; %w", + resp.Request.Method, + resp.Request.URL, + err, + ) + } + + // 2. Validate Size + if resp.ContentLength == -1 { + return ocispec.Descriptor{}, fmt.Errorf( + "%s %q: unknown response Content-Length", + resp.Request.Method, + resp.Request.URL, + ) + } + + // 3. Validate Client Reference + var refDigest digest.Digest + if d, err := ref.Digest(); err == nil { + refDigest = d + } + + // 4. Validate Server Digest (if present) + var serverHeaderDigest digest.Digest + if serverHeaderDigestStr := resp.Header.Get(headerDockerContentDigest); serverHeaderDigestStr != "" { + if serverHeaderDigest, err = digest.Parse(serverHeaderDigestStr); err != nil { + return ocispec.Descriptor{}, fmt.Errorf( + "%s %q: invalid response header value: `%s: %s`; %w", + resp.Request.Method, + resp.Request.URL, + headerDockerContentDigest, + serverHeaderDigestStr, + err, + ) + } + } + + /* 5. Now, look for specific error conditions; see truth table in method docstring */ + var contentDigest digest.Digest + + if len(serverHeaderDigest) == 0 { + if httpMethod == http.MethodHead { + if len(refDigest) == 0 { + // HEAD without server `Docker-Content-Digest` header is an + // immediate fail + return ocispec.Descriptor{}, fmt.Errorf( + "HTTP %s request missing required header %q", + httpMethod, headerDockerContentDigest, + ) + } + // Otherwise, just trust the client-supplied digest + contentDigest = refDigest + } else { + // GET without server `Docker-Content-Digest` header forces the + // expensive calculation + var calculatedDigest digest.Digest + if calculatedDigest, err = calculateDigestFromResponse(resp, s.repo.MaxMetadataBytes); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to calculate digest on response body; %w", err) + } + contentDigest = calculatedDigest + } + } else { + contentDigest = serverHeaderDigest + } + + if len(refDigest) > 0 && refDigest != contentDigest { + return ocispec.Descriptor{}, fmt.Errorf( + "%s %q: invalid response; digest mismatch in %s: received %q when expecting %q", + resp.Request.Method, resp.Request.URL, + headerDockerContentDigest, contentDigest, + refDigest, + ) + } + + // 6. Finally, if we made it this far, then all is good; return. + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: contentDigest, + Size: resp.ContentLength, + }, nil +} + +// calculateDigestFromResponse calculates the actual digest of the response body +// taking care not to destroy it in the process. +func calculateDigestFromResponse(resp *http.Response, maxMetadataBytes int64) (digest.Digest, error) { + defer resp.Body.Close() + + body := limitReader(resp.Body, maxMetadataBytes) + content, err := io.ReadAll(body) + if err != nil { + return "", fmt.Errorf("%s %q: failed to read response body: %w", resp.Request.Method, resp.Request.URL, err) + } + resp.Body = io.NopCloser(bytes.NewReader(content)) + + return digest.FromBytes(content), nil +} + +// verifyContentDigest verifies "Docker-Content-Digest" header if present. +// OCI distribution-spec states the Docker-Content-Digest header is optional. +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#legacy-docker-support-http-headers +func verifyContentDigest(resp *http.Response, expected digest.Digest) error { + digestStr := resp.Header.Get(headerDockerContentDigest) + + if len(digestStr) == 0 { + return nil + } + + contentDigest, err := digest.Parse(digestStr) + if err != nil { + return fmt.Errorf( + "%s %q: invalid response header: `%s: %s`", + resp.Request.Method, resp.Request.URL, + headerDockerContentDigest, digestStr, + ) + } + + if contentDigest != expected { + return fmt.Errorf( + "%s %q: invalid response; digest mismatch in %s: received %q when expecting %q", + resp.Request.Method, resp.Request.URL, + headerDockerContentDigest, contentDigest, + expected, + ) + } + + return nil +} + +// generateIndex generates an image index containing the given manifests list. +func generateIndex(manifests []ocispec.Descriptor) (ocispec.Descriptor, []byte, error) { + if manifests == nil { + manifests = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs + } + index := ocispec.Index{ + Versioned: specs.Versioned{ + SchemaVersion: 2, // historical value. does not pertain to OCI or docker version + }, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: manifests, + } + indexJSON, err := json.Marshal(index) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + indexDesc := content.NewDescriptorFromBytes(index.MediaType, indexJSON) + return indexDesc, indexJSON, nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/retry/client.go b/vendor/oras.land/oras-go/v2/registry/remote/retry/client.go new file mode 100644 index 00000000..5e986ea0 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/retry/client.go @@ -0,0 +1,114 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package retry + +import ( + "net/http" + "time" +) + +// DefaultClient is a client with the default retry policy. +var DefaultClient = NewClient() + +// NewClient creates an HTTP client with the default retry policy. +func NewClient() *http.Client { + return &http.Client{ + Transport: NewTransport(nil), + } +} + +// Transport is an HTTP transport with retry policy. +type Transport struct { + // Base is the underlying HTTP transport to use. + // If nil, http.DefaultTransport is used for round trips. + Base http.RoundTripper + + // Policy returns a retry Policy to use for the request. + // If nil, DefaultPolicy is used to determine if the request should be retried. + Policy func() Policy +} + +// NewTransport creates an HTTP Transport with the default retry policy. +func NewTransport(base http.RoundTripper) *Transport { + return &Transport{ + Base: base, + } +} + +// RoundTrip executes a single HTTP transaction, returning a Response for the +// provided Request. +// It relies on the configured Policy to determine if the request should be +// retried and to backoff. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + policy := t.policy() + attempt := 0 + for { + resp, respErr := t.roundTrip(req) + duration, err := policy.Retry(attempt, resp, respErr) + if err != nil { + if respErr == nil { + resp.Body.Close() + } + return nil, err + } + if duration < 0 { + return resp, respErr + } + + // rewind the body if possible + if req.Body != nil { + if req.GetBody == nil { + // body can't be rewound, so we can't retry + return resp, respErr + } + body, err := req.GetBody() + if err != nil { + // failed to rewind the body, so we can't retry + return resp, respErr + } + req.Body = body + } + + // close the response body if needed + if respErr == nil { + resp.Body.Close() + } + + timer := time.NewTimer(duration) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + attempt++ + } +} + +func (t *Transport) roundTrip(req *http.Request) (*http.Response, error) { + if t.Base == nil { + return http.DefaultTransport.RoundTrip(req) + } + return t.Base.RoundTrip(req) +} + +func (t *Transport) policy() Policy { + if t.Policy == nil { + return DefaultPolicy + } + return t.Policy() +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/retry/policy.go b/vendor/oras.land/oras-go/v2/registry/remote/retry/policy.go new file mode 100644 index 00000000..ebe8b373 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/retry/policy.go @@ -0,0 +1,154 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package retry + +import ( + "hash/maphash" + "math" + "math/rand/v2" + "net" + "net/http" + "strconv" + "time" +) + +// headerRetryAfter is the header key for Retry-After. +const headerRetryAfter = "Retry-After" + +// DefaultPolicy is a policy with fine-tuned retry parameters. +// It uses an exponential backoff with jitter. +var DefaultPolicy Policy = &GenericPolicy{ + Retryable: DefaultPredicate, + Backoff: DefaultBackoff, + MinWait: 200 * time.Millisecond, + MaxWait: 3 * time.Second, + MaxRetry: 5, +} + +// DefaultPredicate is a predicate that retries on 5xx errors, 429 Too Many +// Requests, 408 Request Timeout and on network dial timeout. +var DefaultPredicate Predicate = func(resp *http.Response, err error) (bool, error) { + if err != nil { + // retry on Dial timeout + if err, ok := err.(net.Error); ok && err.Timeout() { + return true, nil + } + return false, err + } + + if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests { + return true, nil + } + + if resp.StatusCode == 0 || resp.StatusCode >= 500 { + return true, nil + } + + return false, nil +} + +// DefaultBackoff is a backoff that uses an exponential backoff with jitter. +// It uses a base of 250ms, a factor of 2 and a jitter of 10%. +var DefaultBackoff Backoff = ExponentialBackoff(250*time.Millisecond, 2, 0.1) + +// Policy is a retry policy. +type Policy interface { + // Retry returns the duration to wait before retrying the request. + // It returns a negative value if the request should not be retried. + // The attempt is used to: + // - calculate the backoff duration, the default backoff is an exponential backoff. + // - determine if the request should be retried. + // The attempt starts at 0 and should be less than MaxRetry for the request to + // be retried. + Retry(attempt int, resp *http.Response, err error) (time.Duration, error) +} + +// Predicate is a function that returns true if the request should be retried. +type Predicate func(resp *http.Response, err error) (bool, error) + +// Backoff is a function that returns the duration to wait before retrying the +// request. The attempt, is the next attempt number. The response is the +// response from the previous request. +type Backoff func(attempt int, resp *http.Response) time.Duration + +// ExponentialBackoff returns a Backoff that uses an exponential backoff with +// jitter. The backoff is calculated as: +// +// temp = backoff * factor ^ attempt +// interval = temp * (1 - jitter) + rand.Int64N(2 * jitter * temp) +// +// The HTTP response is checked for a Retry-After header. If it is present, the +// value is used as the backoff duration. +func ExponentialBackoff(backoff time.Duration, factor, jitter float64) Backoff { + return func(attempt int, resp *http.Response) time.Duration { + var h maphash.Hash + h.SetSeed(maphash.MakeSeed()) + rand := rand.New(rand.NewPCG(0, h.Sum64())) + + // check Retry-After + if resp != nil && resp.StatusCode == http.StatusTooManyRequests { + if v := resp.Header.Get(headerRetryAfter); v != "" { + if retryAfter, _ := strconv.ParseInt(v, 10, 64); retryAfter > 0 { + return time.Duration(retryAfter) * time.Second + } + } + } + + // do exponential backoff with jitter + temp := float64(backoff) * math.Pow(factor, float64(attempt)) + return time.Duration(temp*(1-jitter)) + time.Duration(rand.Int64N(int64(2*jitter*temp))) + } +} + +// GenericPolicy is a generic retry policy. +type GenericPolicy struct { + // Retryable is a predicate that returns true if the request should be + // retried. + Retryable Predicate + + // Backoff is a function that returns the duration to wait before retrying. + Backoff Backoff + + // MinWait is the minimum duration to wait before retrying. + MinWait time.Duration + + // MaxWait is the maximum duration to wait before retrying. + MaxWait time.Duration + + // MaxRetry is the maximum number of retries. + MaxRetry int +} + +// Retry returns the duration to wait before retrying the request. +// It returns -1 if the request should not be retried. +func (p *GenericPolicy) Retry(attempt int, resp *http.Response, err error) (time.Duration, error) { + if attempt >= p.MaxRetry { + return -1, nil + } + if ok, err := p.Retryable(resp, err); err != nil { + return -1, err + } else if !ok { + return -1, nil + } + backoff := p.Backoff(attempt, resp) + if backoff < p.MinWait { + backoff = p.MinWait + } + if backoff > p.MaxWait { + backoff = p.MaxWait + } + return backoff, nil +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/url.go b/vendor/oras.land/oras-go/v2/registry/remote/url.go new file mode 100644 index 00000000..64f6670a --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/url.go @@ -0,0 +1,119 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "fmt" + "net/url" + "strings" + + "github.com/opencontainers/go-digest" + "oras.land/oras-go/v2/registry" +) + +// buildScheme returns HTTP scheme used to access the remote registry. +func buildScheme(plainHTTP bool) string { + if plainHTTP { + return "http" + } + return "https" +} + +// buildRegistryBaseURL builds the URL for accessing the base API. +// Format: :///v2/ +// Reference: https://distribution.github.io/distribution/spec/api/#base +func buildRegistryBaseURL(plainHTTP bool, ref registry.Reference) string { + return fmt.Sprintf("%s://%s/v2/", buildScheme(plainHTTP), ref.Host()) +} + +// buildRegistryCatalogURL builds the URL for accessing the catalog API. +// Format: :///v2/_catalog +// Reference: https://distribution.github.io/distribution/spec/api/#catalog +func buildRegistryCatalogURL(plainHTTP bool, ref registry.Reference) string { + return fmt.Sprintf("%s://%s/v2/_catalog", buildScheme(plainHTTP), ref.Host()) +} + +// buildRepositoryBaseURL builds the base endpoint of the remote repository. +// Format: :///v2/ +func buildRepositoryBaseURL(plainHTTP bool, ref registry.Reference) string { + return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository) +} + +// buildRepositoryTagListURL builds the URL for accessing the tag list API. +// Format: :///v2//tags/list +// Reference: https://distribution.github.io/distribution/spec/api/#tags +func buildRepositoryTagListURL(plainHTTP bool, ref registry.Reference) string { + return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list" +} + +// buildRepositoryManifestURL builds the URL for accessing the manifest API. +// Format: :///v2//manifests/ +// Reference: https://distribution.github.io/distribution/spec/api/#manifest +func buildRepositoryManifestURL(plainHTTP bool, ref registry.Reference) string { + return strings.Join([]string{ + buildRepositoryBaseURL(plainHTTP, ref), + "manifests", + ref.Reference, + }, "/") +} + +// buildRepositoryBlobURL builds the URL for accessing the blob API. +// Format: :///v2//blobs/ +// Reference: https://distribution.github.io/distribution/spec/api/#blob +func buildRepositoryBlobURL(plainHTTP bool, ref registry.Reference) string { + return strings.Join([]string{ + buildRepositoryBaseURL(plainHTTP, ref), + "blobs", + ref.Reference, + }, "/") +} + +// buildRepositoryBlobUploadURL builds the URL for blob uploading. +// Format: :///v2//blobs/uploads/ +// Reference: https://distribution.github.io/distribution/spec/api/#initiate-blob-upload +func buildRepositoryBlobUploadURL(plainHTTP bool, ref registry.Reference) string { + return buildRepositoryBaseURL(plainHTTP, ref) + "/blobs/uploads/" +} + +// buildRepositoryBlobMountURLbuilds the URL for cross-repository mounting. +// Format: :///v2//blobs/uploads/?mount=&from= +// Reference: https://distribution.github.io/distribution/spec/api/#blob +func buildRepositoryBlobMountURL(plainHTTP bool, ref registry.Reference, d digest.Digest, fromRepo string) string { + return fmt.Sprintf("%s?mount=%s&from=%s", + buildRepositoryBlobUploadURL(plainHTTP, ref), + d, + fromRepo, + ) +} + +// buildReferrersURL builds the URL for querying the Referrers API. +// Format: :///v2//referrers/?artifactType= +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +func buildReferrersURL(plainHTTP bool, ref registry.Reference, artifactType string) string { + var query string + if artifactType != "" { + v := url.Values{} + v.Set("artifactType", artifactType) + query = "?" + v.Encode() + } + + return fmt.Sprintf( + "%s/referrers/%s%s", + buildRepositoryBaseURL(plainHTTP, ref), + ref.Reference, + query, + ) +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/utils.go b/vendor/oras.land/oras-go/v2/registry/remote/utils.go new file mode 100644 index 00000000..797169f4 --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/utils.go @@ -0,0 +1,94 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/errdef" +) + +// defaultMaxMetadataBytes specifies the default limit on how many response +// bytes are allowed in the server's response to the metadata APIs. +// See also: Repository.MaxMetadataBytes +var defaultMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB + +// errNoLink is returned by parseLink() when no Link header is present. +var errNoLink = errors.New("no Link header in response") + +// parseLink returns the URL of the response's "Link" header, if present. +func parseLink(resp *http.Response) (string, error) { + link := resp.Header.Get("Link") + if link == "" { + return "", errNoLink + } + if link[0] != '<' { + return "", fmt.Errorf("invalid next link %q: missing '<'", link) + } + if i := strings.IndexByte(link, '>'); i == -1 { + return "", fmt.Errorf("invalid next link %q: missing '>'", link) + } else { + link = link[1:i] + } + + linkURL, err := resp.Request.URL.Parse(link) + if err != nil { + return "", err + } + return linkURL.String(), nil +} + +// limitReader returns a Reader that reads from r but stops with EOF after n +// bytes. If n is less than or equal to zero, defaultMaxMetadataBytes is used. +func limitReader(r io.Reader, n int64) io.Reader { + if n <= 0 { + n = defaultMaxMetadataBytes + } + return io.LimitReader(r, n) +} + +// limitSize returns ErrSizeExceedsLimit if the size of desc exceeds the limit n. +// If n is less than or equal to zero, defaultMaxMetadataBytes is used. +func limitSize(desc ocispec.Descriptor, n int64) error { + if n <= 0 { + n = defaultMaxMetadataBytes + } + if desc.Size > n { + return fmt.Errorf( + "content size %v exceeds MaxMetadataBytes %v: %w", + desc.Size, + n, + errdef.ErrSizeExceedsLimit) + } + return nil +} + +// decodeJSON safely reads the JSON content described by desc, and +// decodes it into v. +func decodeJSON(r io.Reader, desc ocispec.Descriptor, v any) error { + jsonBytes, err := content.ReadAll(r, desc) + if err != nil { + return err + } + return json.Unmarshal(jsonBytes, v) +} diff --git a/vendor/oras.land/oras-go/v2/registry/remote/warning.go b/vendor/oras.land/oras-go/v2/registry/remote/warning.go new file mode 100644 index 00000000..02d758ea --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/remote/warning.go @@ -0,0 +1,100 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remote + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // headerWarning is the "Warning" header. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + headerWarning = "Warning" + + // warnCode299 is the 299 warn-code. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + warnCode299 = 299 + + // warnAgentUnknown represents an unknown warn-agent. + // Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5 + warnAgentUnknown = "-" +) + +// errUnexpectedWarningFormat is returned by parseWarningHeader when +// an unexpected warning format is encountered. +var errUnexpectedWarningFormat = errors.New("unexpected warning format") + +// WarningValue represents the value of the Warning header. +// +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings +// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 +type WarningValue struct { + // Code is the warn-code. + Code int + // Agent is the warn-agent. + Agent string + // Text is the warn-text. + Text string +} + +// Warning contains the value of the warning header and may contain +// other information related to the warning. +// +// References: +// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings +// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5 +type Warning struct { + // WarningValue is the value of the warning header. + WarningValue +} + +// parseWarningHeader parses the warning header into WarningValue. +func parseWarningHeader(header string) (WarningValue, error) { + if len(header) < 9 || !strings.HasPrefix(header, `299 - "`) || !strings.HasSuffix(header, `"`) { + // minimum header value: `299 - "x"` + return WarningValue{}, fmt.Errorf("%s: %w", header, errUnexpectedWarningFormat) + } + + // validate text only as code and agent are fixed + quotedText := header[6:] // behind `299 - `, quoted by " + text, err := strconv.Unquote(quotedText) + if err != nil { + return WarningValue{}, fmt.Errorf("%s: unexpected text: %w: %v", header, errUnexpectedWarningFormat, err) + } + + return WarningValue{ + Code: warnCode299, + Agent: warnAgentUnknown, + Text: text, + }, nil +} + +// handleWarningHeaders parses the warning headers and handles the parsed +// warnings using handleWarning. +func handleWarningHeaders(headers []string, handleWarning func(Warning)) { + for _, h := range headers { + if value, err := parseWarningHeader(h); err == nil { + // ignore warnings in unexpected formats + handleWarning(Warning{ + WarningValue: value, + }) + } + } +} diff --git a/vendor/oras.land/oras-go/v2/registry/repository.go b/vendor/oras.land/oras-go/v2/registry/repository.go new file mode 100644 index 00000000..367f2d0f --- /dev/null +++ b/vendor/oras.land/oras-go/v2/registry/repository.go @@ -0,0 +1,226 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "encoding/json" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/internal/descriptor" + "oras.land/oras-go/v2/internal/spec" +) + +// Repository is an ORAS target and an union of the blob and the manifest CASs. +// +// As specified by https://distribution.github.io/distribution/spec/api/, it is natural to +// assume that content.Resolver interface only works for manifests. Tagging a +// blob may be resulted in an `ErrUnsupported` error. However, this interface +// does not restrict tagging blobs. +// +// Since a repository is an union of the blob and the manifest CASs, all +// operations defined in the `BlobStore` are executed depending on the media +// type of the given descriptor accordingly. +// +// Furthermore, this interface also provides the ability to enforce the +// separation of the blob and the manifests CASs. +type Repository interface { + content.Storage + content.Deleter + content.TagResolver + ReferenceFetcher + ReferencePusher + ReferrerLister + TagLister + + // Blobs provides access to the blob CAS only, which contains config blobs, + // layers, and other generic blobs. + Blobs() BlobStore + + // Manifests provides access to the manifest CAS only. + Manifests() ManifestStore +} + +// BlobStore is a CAS with the ability to stat and delete its content. +type BlobStore interface { + content.Storage + content.Deleter + content.Resolver + ReferenceFetcher +} + +// ManifestStore is a CAS with the ability to stat and delete its content. +// Besides, ManifestStore provides reference tagging. +type ManifestStore interface { + BlobStore + content.Tagger + ReferencePusher +} + +// ReferencePusher provides advanced push with the tag service. +type ReferencePusher interface { + // PushReference pushes the manifest with a reference tag. + PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error +} + +// ReferenceFetcher provides advanced fetch with the tag service. +type ReferenceFetcher interface { + // FetchReference fetches the content identified by the reference. + FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) +} + +// ReferrerLister provides the Referrers API. +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +type ReferrerLister interface { + Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error +} + +// TagLister lists tags by the tag service. +type TagLister interface { + // Tags lists the tags available in the repository. + // Since the returned tag list may be paginated by the underlying + // implementation, a function should be passed in to process the paginated + // tag list. + // + // `last` argument is the `last` parameter when invoking the tags API. + // If `last` is NOT empty, the entries in the response start after the + // tag specified by `last`. Otherwise, the response starts from the top + // of the Tags list. + // + // Note: When implemented by a remote registry, the tags API is called. + // However, not all registries supports pagination or conforms the + // specification. + // + // References: + // - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#content-discovery + // - https://distribution.github.io/distribution/spec/api/#tags + // See also `Tags()` in this package. + Tags(ctx context.Context, last string, fn func(tags []string) error) error +} + +// Mounter allows cross-repository blob mounts. +// For backward compatibility reasons, this is not implemented by +// BlobStore: use a type assertion to check availability. +type Mounter interface { + // Mount makes the blob with the given descriptor in fromRepo + // available in the repository signified by the receiver. + Mount(ctx context.Context, + desc ocispec.Descriptor, + fromRepo string, + getContent func() (io.ReadCloser, error), + ) error +} + +// Tags lists the tags available in the repository. +func Tags(ctx context.Context, repo TagLister) ([]string, error) { + var res []string + if err := repo.Tags(ctx, "", func(tags []string) error { + res = append(res, tags...) + return nil + }); err != nil { + return nil, err + } + return res, nil +} + +// Referrers lists the descriptors of image or artifact manifests directly +// referencing the given manifest descriptor. +// +// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers +func Referrers(ctx context.Context, store content.ReadOnlyGraphStorage, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) { + if !descriptor.IsManifest(desc) { + return nil, fmt.Errorf("the descriptor %v is not a manifest: %w", desc, errdef.ErrUnsupported) + } + + var results []ocispec.Descriptor + + // use the Referrer API if it is available + if rf, ok := store.(ReferrerLister); ok { + if err := rf.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error { + results = append(results, referrers...) + return nil + }); err != nil { + return nil, err + } + return results, nil + } + + predecessors, err := store.Predecessors(ctx, desc) + if err != nil { + return nil, err + } + for _, node := range predecessors { + switch node.MediaType { + case ocispec.MediaTypeImageManifest: + fetched, err := content.FetchAll(ctx, store, node) + if err != nil { + return nil, err + } + var manifest ocispec.Manifest + if err := json.Unmarshal(fetched, &manifest); err != nil { + return nil, err + } + if manifest.Subject == nil || !content.Equal(*manifest.Subject, desc) { + continue + } + node.ArtifactType = manifest.ArtifactType + if node.ArtifactType == "" { + node.ArtifactType = manifest.Config.MediaType + } + node.Annotations = manifest.Annotations + case ocispec.MediaTypeImageIndex: + fetched, err := content.FetchAll(ctx, store, node) + if err != nil { + return nil, err + } + var index ocispec.Index + if err := json.Unmarshal(fetched, &index); err != nil { + return nil, err + } + if index.Subject == nil || !content.Equal(*index.Subject, desc) { + continue + } + node.ArtifactType = index.ArtifactType + node.Annotations = index.Annotations + case spec.MediaTypeArtifactManifest: + fetched, err := content.FetchAll(ctx, store, node) + if err != nil { + return nil, err + } + var artifact spec.Artifact + if err := json.Unmarshal(fetched, &artifact); err != nil { + return nil, err + } + if artifact.Subject == nil || !content.Equal(*artifact.Subject, desc) { + continue + } + node.ArtifactType = artifact.ArtifactType + node.Annotations = artifact.Annotations + default: + continue + } + if artifactType == "" || artifactType == node.ArtifactType { + // the field artifactType in referrers descriptor is allowed to be empty + // https://github.com/opencontainers/distribution-spec/issues/458 + results = append(results, node) + } + } + return results, nil +}