After introducing Go 1.18 to a project, it's required by law to convert at least one method to a generic one. Everyone hates IntToPtr, StringToPtr, BoolToPtr and Uint64ToPtr, so let's convert them to the ultimate generic ToPtr one. Signed-off-by: Ondřej Budai <ondrej@budai.cz>
487 lines
12 KiB
Go
487 lines
12 KiB
Go
// package container implements a client for a container
|
|
// registry. It can be used to upload container images.
|
|
package container
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "github.com/containers/image/v5/docker/archive"
|
|
_ "github.com/containers/image/v5/oci/archive"
|
|
_ "github.com/containers/image/v5/oci/layout"
|
|
"golang.org/x/sys/unix"
|
|
|
|
"github.com/osbuild/osbuild-composer/internal/common"
|
|
|
|
"github.com/containers/common/pkg/retry"
|
|
"github.com/containers/image/v5/copy"
|
|
"github.com/containers/image/v5/docker"
|
|
"github.com/containers/image/v5/docker/reference"
|
|
"github.com/containers/image/v5/manifest"
|
|
"github.com/containers/image/v5/signature"
|
|
"github.com/containers/image/v5/transports"
|
|
"github.com/containers/image/v5/types"
|
|
"github.com/opencontainers/go-digest"
|
|
|
|
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
|
|
)
|
|
|
|
const (
|
|
DefaultUserAgent = "osbuild-composer/1.0"
|
|
DefaultPolicyPath = "/etc/containers/policy.json"
|
|
)
|
|
|
|
// GetDefaultAuthFile returns the authentication file to use for the
|
|
// current environment.
|
|
//
|
|
// This is basically a re-implementation of `getPathToAuthWithOS` from
|
|
// containers/image/pkg/docker/config/config.go[1], but we ensure that
|
|
// the returned path is either accessible. This is needed since any
|
|
// other error than os.ErrNotExist will lead to an overall failure and
|
|
// thus prevent any operation even with public resources.
|
|
//
|
|
// [1] https://github.com/containers/image/blob/55ea76c7db702ed1af60924a0b57c8da533d9e5a/pkg/docker/config/config.go#L506
|
|
func GetDefaultAuthFile() string {
|
|
|
|
checkAccess := func(path string) bool {
|
|
err := unix.Access(path, unix.R_OK)
|
|
return err == nil
|
|
}
|
|
|
|
if authFile := os.Getenv("REGISTRY_AUTH_FILE"); authFile != "" {
|
|
if checkAccess(authFile) {
|
|
return authFile
|
|
}
|
|
}
|
|
|
|
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
|
|
if checkAccess(runtimeDir) {
|
|
return filepath.Join(runtimeDir, "containers", "auth.json")
|
|
}
|
|
}
|
|
|
|
if rundir := filepath.FromSlash("/run/containers"); checkAccess(rundir) {
|
|
return filepath.Join(rundir, strconv.Itoa(os.Getuid()), "auth.json")
|
|
}
|
|
|
|
return filepath.FromSlash("/var/empty/containers-auth.json")
|
|
}
|
|
|
|
// ApplyDefaultPath checks if the target includes a domain and if it doesn't adds the default ones
|
|
// to the returned string. If also returns a bool indicating whether the defaults were applied
|
|
func ApplyDefaultDomainPath(target, defaultDomain, defaultPath string) (string, bool) {
|
|
appliedDefaults := false
|
|
i := strings.IndexRune(target, '/')
|
|
if i == -1 || (!strings.ContainsAny(target[:i], ".:") && target[:i] != "localhost") {
|
|
if defaultDomain != "" {
|
|
base := defaultDomain
|
|
if defaultPath != "" {
|
|
base = fmt.Sprintf("%s/%s", base, defaultPath)
|
|
}
|
|
target = fmt.Sprintf("%s/%s", base, target)
|
|
appliedDefaults = true
|
|
}
|
|
}
|
|
|
|
return target, appliedDefaults
|
|
}
|
|
|
|
// A Client to interact with the given Target object at a
|
|
// container registry, like e.g. uploading an image to.
|
|
// All mentioned defaults are only set when using the
|
|
// NewClient constructor.
|
|
type Client struct {
|
|
Target reference.Named // the target object to interact with
|
|
|
|
ReportWriter io.Writer // used for writing status reports, defaults to os.Stdout
|
|
|
|
PrecomputeDigests bool // precompute digest in order to avoid uploads
|
|
MaxRetries int // how often to retry http requests
|
|
|
|
UserAgent string // user agent string to use for requests, defaults to DefaultUserAgent
|
|
|
|
// internal state
|
|
policy *signature.Policy
|
|
sysCtx *types.SystemContext
|
|
}
|
|
|
|
// NewClient constructs a new Client for target with default options.
|
|
// It will add the "latest" tag if target does not contain it.
|
|
func NewClient(target string) (*Client, error) {
|
|
|
|
ref, err := reference.ParseNormalizedNamed(target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse '%s': %w", target, err)
|
|
}
|
|
|
|
var policy *signature.Policy
|
|
if _, err := os.Stat(DefaultPolicyPath); err == nil {
|
|
policy, err = signature.NewPolicyFromFile(DefaultPolicyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
policy = &signature.Policy{
|
|
Default: []signature.PolicyRequirement{
|
|
signature.NewPRInsecureAcceptAnything(),
|
|
},
|
|
}
|
|
}
|
|
|
|
client := Client{
|
|
Target: reference.TagNameOnly(ref),
|
|
|
|
ReportWriter: os.Stdout,
|
|
PrecomputeDigests: true,
|
|
|
|
UserAgent: DefaultUserAgent,
|
|
|
|
sysCtx: &types.SystemContext{
|
|
RegistriesDirPath: "",
|
|
SystemRegistriesConfPath: "",
|
|
BigFilesTemporaryDir: "/var/tmp",
|
|
|
|
OSChoice: "linux",
|
|
|
|
AuthFilePath: GetDefaultAuthFile(),
|
|
},
|
|
policy: policy,
|
|
}
|
|
|
|
return &client, nil
|
|
}
|
|
|
|
// SetAuthFilePath sets the location of the `containers-auth.json(5)` file.
|
|
func (cl *Client) SetAuthFilePath(path string) {
|
|
cl.sysCtx.AuthFilePath = path
|
|
}
|
|
|
|
// GetAuthFilePath gets the location of the `containers-auth.json(5)` file.
|
|
func (cl *Client) GetAuthFilePath() string {
|
|
return cl.sysCtx.AuthFilePath
|
|
}
|
|
|
|
func (cl *Client) SetArchitectureChoice(arch string) {
|
|
// Translate some well-known Composer architecture strings
|
|
// into the corresponding container ones
|
|
|
|
variant := ""
|
|
|
|
switch arch {
|
|
case "x86_64":
|
|
arch = "amd64"
|
|
|
|
case "aarch64":
|
|
arch = "arm64"
|
|
if variant == "" {
|
|
variant = "v8"
|
|
}
|
|
|
|
case "armhfp":
|
|
arch = "arm"
|
|
if variant == "" {
|
|
variant = "v7"
|
|
}
|
|
|
|
//ppc64le and s390x are the same
|
|
}
|
|
|
|
cl.sysCtx.ArchitectureChoice = arch
|
|
cl.sysCtx.VariantChoice = variant
|
|
}
|
|
|
|
func (cl *Client) SetVariantChoice(variant string) {
|
|
cl.sysCtx.VariantChoice = variant
|
|
}
|
|
|
|
// SetCredentials will set username and password for Client
|
|
func (cl *Client) SetCredentials(username, password string) {
|
|
|
|
if cl.sysCtx.DockerAuthConfig == nil {
|
|
cl.sysCtx.DockerAuthConfig = &types.DockerAuthConfig{}
|
|
}
|
|
|
|
cl.sysCtx.DockerAuthConfig.Username = username
|
|
cl.sysCtx.DockerAuthConfig.Password = password
|
|
}
|
|
|
|
func (cl *Client) SetDockerCertPath(path string) {
|
|
cl.sysCtx.DockerCertPath = path
|
|
}
|
|
|
|
// SetSkipTLSVerify controls if TLS verification happens when
|
|
// making requests. If nil is passed it falls back to the default.
|
|
func (cl *Client) SetTLSVerify(verify *bool) {
|
|
if verify == nil {
|
|
cl.sysCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolUndefined
|
|
} else {
|
|
cl.sysCtx.DockerInsecureSkipTLSVerify = types.NewOptionalBool(!*verify)
|
|
}
|
|
}
|
|
|
|
// GetSkipTLSVerify returns current TLS verification state.
|
|
func (cl *Client) GetTLSVerify() *bool {
|
|
|
|
skip := cl.sysCtx.DockerInsecureSkipTLSVerify
|
|
|
|
if skip == types.OptionalBoolUndefined {
|
|
return nil
|
|
}
|
|
|
|
// NB: we invert the state, i.e. verify == (skip == false)
|
|
return common.ToPtr(skip == types.OptionalBoolFalse)
|
|
}
|
|
|
|
// SkipTLSVerify is a convenience helper that internally calls
|
|
// SetTLSVerify with false
|
|
func (cl *Client) SkipTLSVerify() {
|
|
cl.SetTLSVerify(common.ToPtr(false))
|
|
}
|
|
|
|
func parseImageName(name string) (types.ImageReference, error) {
|
|
|
|
parts := strings.SplitN(name, ":", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid image name '%s'", name)
|
|
}
|
|
|
|
transport := transports.Get(parts[0])
|
|
if transport == nil {
|
|
return nil, fmt.Errorf("unknown transport '%s'", parts[0])
|
|
}
|
|
|
|
return transport.ParseReference(parts[1])
|
|
}
|
|
|
|
// UploadImage takes an container image located at from and uploads it
|
|
// to the Target of Client. If tag is set, i.e. not the empty string,
|
|
// it will replace any previously set tag or digest of the target.
|
|
// Returns the digest of the manifest that was written to the server.
|
|
func (cl *Client) UploadImage(ctx context.Context, from, tag string) (digest.Digest, error) {
|
|
|
|
targetCtx := *cl.sysCtx
|
|
targetCtx.DockerRegistryPushPrecomputeDigests = cl.PrecomputeDigests
|
|
|
|
policyContext, err := signature.NewPolicyContext(cl.policy)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
srcRef, err := parseImageName(from)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid source name '%s': %w", from, err)
|
|
}
|
|
|
|
target := cl.Target
|
|
|
|
if tag != "" {
|
|
target = reference.TrimNamed(target)
|
|
target, err = reference.WithTag(target, tag)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error creating reference with tag '%s': %w", tag, err)
|
|
}
|
|
}
|
|
|
|
destRef, err := docker.NewReference(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
retryOpts := retry.RetryOptions{
|
|
MaxRetry: cl.MaxRetries,
|
|
}
|
|
|
|
var manifestDigest digest.Digest
|
|
|
|
err = retry.RetryIfNecessary(ctx, func() error {
|
|
manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, ©.Options{
|
|
RemoveSignatures: false,
|
|
SignBy: "",
|
|
SignPassphrase: "",
|
|
ReportWriter: cl.ReportWriter,
|
|
SourceCtx: cl.sysCtx,
|
|
DestinationCtx: &targetCtx,
|
|
ForceManifestMIMEType: "",
|
|
ImageListSelection: copy.CopyAllImages,
|
|
PreserveDigests: false,
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
manifestDigest, err = manifest.Digest(manifestBytes)
|
|
|
|
return err
|
|
|
|
}, &retryOpts)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return manifestDigest, nil
|
|
}
|
|
|
|
// A RawManifest contains the raw manifest Data and its MimeType
|
|
type RawManifest struct {
|
|
Data []byte
|
|
MimeType string
|
|
}
|
|
|
|
// Digest computes the digest from the raw manifest data
|
|
func (m RawManifest) Digest() (digest.Digest, error) {
|
|
return manifest.Digest(m.Data)
|
|
}
|
|
|
|
// GetManifest fetches the raw manifest data from the server. If digest is not empty
|
|
// it will override any given tag for the Client's Target.
|
|
func (cl *Client) GetManifest(ctx context.Context, digest digest.Digest) (r RawManifest, err error) {
|
|
target := cl.Target
|
|
|
|
if digest != "" {
|
|
t := reference.TrimNamed(cl.Target)
|
|
t, err = reference.WithDigest(t, digest)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
target = t
|
|
}
|
|
|
|
ref, err := docker.NewReference(target)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
src, err := ref.NewImageSource(ctx, cl.sysCtx)
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if e := src.Close(); e != nil {
|
|
err = fmt.Errorf("could not close image: %w", e)
|
|
}
|
|
}()
|
|
|
|
retryOpts := retry.RetryOptions{
|
|
MaxRetry: cl.MaxRetries,
|
|
}
|
|
|
|
if err = retry.RetryIfNecessary(ctx, func() error {
|
|
r.Data, r.MimeType, err = src.GetManifest(ctx, nil)
|
|
return err
|
|
}, &retryOpts); err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
type manifestList interface {
|
|
ChooseInstance(ctx *types.SystemContext) (digest.Digest, error)
|
|
}
|
|
|
|
type resolvedIds struct {
|
|
Manifest digest.Digest
|
|
Config digest.Digest
|
|
}
|
|
|
|
func (cl *Client) resolveManifestList(ctx context.Context, list manifestList) (resolvedIds, error) {
|
|
digest, err := list.ChooseInstance(cl.sysCtx)
|
|
if err != nil {
|
|
return resolvedIds{}, err
|
|
}
|
|
|
|
raw, err := cl.GetManifest(ctx, digest)
|
|
|
|
if err != nil {
|
|
return resolvedIds{}, fmt.Errorf("error getting manifest: %w", err)
|
|
}
|
|
|
|
return cl.resolveRawManifest(ctx, raw)
|
|
}
|
|
|
|
func (cl *Client) resolveRawManifest(ctx context.Context, rm RawManifest) (resolvedIds, error) {
|
|
|
|
var imageID digest.Digest
|
|
|
|
switch rm.MimeType {
|
|
case manifest.DockerV2ListMediaType:
|
|
list, err := manifest.Schema2ListFromManifest(rm.Data)
|
|
if err != nil {
|
|
return resolvedIds{}, err
|
|
}
|
|
|
|
return cl.resolveManifestList(ctx, list)
|
|
|
|
case imgspecv1.MediaTypeImageIndex:
|
|
index, err := manifest.OCI1IndexFromManifest(rm.Data)
|
|
if err != nil {
|
|
return resolvedIds{}, err
|
|
}
|
|
|
|
return cl.resolveManifestList(ctx, index)
|
|
|
|
case imgspecv1.MediaTypeImageManifest:
|
|
m, err := manifest.OCI1FromManifest(rm.Data)
|
|
if err != nil {
|
|
return resolvedIds{}, nil
|
|
}
|
|
imageID = m.ConfigInfo().Digest
|
|
|
|
case manifest.DockerV2Schema2MediaType:
|
|
m, err := manifest.Schema2FromManifest(rm.Data)
|
|
|
|
if err != nil {
|
|
return resolvedIds{}, nil
|
|
}
|
|
|
|
imageID = m.ConfigInfo().Digest
|
|
|
|
default:
|
|
return resolvedIds{}, fmt.Errorf("unsupported manifest format '%s'", rm.MimeType)
|
|
}
|
|
|
|
dg, err := rm.Digest()
|
|
|
|
if err != nil {
|
|
return resolvedIds{}, err
|
|
}
|
|
|
|
return resolvedIds{
|
|
Manifest: dg,
|
|
Config: imageID,
|
|
}, nil
|
|
}
|
|
|
|
// Resolve the Client's Target to the manifest digest and the corresponding image id
|
|
// which is the digest of the configuration object. It uses the architecture and
|
|
// variant specified via SetArchitectureChoice or the corresponding defaults for
|
|
// the host.
|
|
func (cl *Client) Resolve(ctx context.Context, name string) (Spec, error) {
|
|
|
|
raw, err := cl.GetManifest(ctx, "")
|
|
|
|
if err != nil {
|
|
return Spec{}, fmt.Errorf("error getting manifest: %w", err)
|
|
}
|
|
|
|
ids, err := cl.resolveRawManifest(ctx, raw)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
|
|
spec := NewSpec(cl.Target, ids.Manifest, ids.Config)
|
|
spec.TLSVerify = cl.GetTLSVerify()
|
|
|
|
return spec, nil
|
|
}
|