debian-forge-composer/internal/container/client.go
Ondřej Budai b997142db0 common: merge all *ToPtr methods to one generic ToPtr
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>
2023-01-09 14:03:18 +01:00

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, &copy.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
}