From 68872628750675f7d2683b6a8be0c345a57f3eb1 Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Thu, 21 Feb 2019 04:15:15 -0800 Subject: [PATCH] pkg/rhcos/release: Extract RHCOS build from release image Since e2b31b22 (bootkube: Supply machine-os-content to MCO, 2019-01-29, #1149), we have been using the machine-os-content image to seed the machine-config operator. With this commit, use the RHCOS build ID from that image's annotations to calculate our AMI, etc. as well. This gives one less degree of freedom for breaking things ;). Users who want to test clusters based on a different RHCOS build should bump the value in their release image, just like users testing operator updates and other changes. The new pkg/asset/release subpackage allows users to continue using pkg/rhcos without pulling in all of containers/image as a dependency. The pull-secret handling is a bit of a hack, leaning on the fact that users are likely providing clean secrets from [1]. Hopefully soon containers/image will grow an API for injecting in-memory bytes into their more-robust Docker-auth-config parser, but my attempt at that [2] is unlikely to land in the next few days, so I've cludged together a minimal implementation here. [1]: https://cloud.openshift.com/clusters/install#pull-secret [2]: https://github.com/containers/image/pull/588 --- hack/build.sh | 6 +- pkg/asset/ignition/bootstrap/bootstrap.go | 19 +-- pkg/asset/release/release.go | 41 +++++ pkg/asset/rhcos/image.go | 29 ++-- pkg/rhcos/ami.go | 4 +- pkg/rhcos/builds.go | 53 +------ pkg/rhcos/qemu.go | 4 +- pkg/rhcos/release/auth.go | 48 ++++++ pkg/rhcos/release/release.go | 180 ++++++++++++++++++++++ 9 files changed, 296 insertions(+), 88 deletions(-) create mode 100644 pkg/asset/release/release.go create mode 100644 pkg/rhcos/release/auth.go create mode 100644 pkg/rhcos/release/release.go diff --git a/hack/build.sh b/hack/build.sh index 8b79ed465e6..6c91c35d516 100755 --- a/hack/build.sh +++ b/hack/build.sh @@ -42,11 +42,7 @@ release) TAGS="${TAGS} release" if test -n "${RELEASE_IMAGE}" then - LDFLAGS="${LDFLAGS} -X github.com/openshift/installer/pkg/asset/ignition/bootstrap.defaultReleaseImage=${RELEASE_IMAGE}" - fi - if test -n "${RHCOS_BUILD_NAME}" - then - LDFLAGS="${LDFLAGS} -X github.com/openshift/installer/pkg/rhcos.buildName=${RHCOS_BUILD_NAME}" + LDFLAGS="${LDFLAGS} -X github.com/openshift/installer/pkg/asset/release.defaultImage=${RELEASE_IMAGE}" fi if test "${SKIP_GENERATION}" != y then diff --git a/pkg/asset/ignition/bootstrap/bootstrap.go b/pkg/asset/ignition/bootstrap/bootstrap.go index d24f660184a..06aa570e24d 100644 --- a/pkg/asset/ignition/bootstrap/bootstrap.go +++ b/pkg/asset/ignition/bootstrap/bootstrap.go @@ -24,6 +24,7 @@ import ( "github.com/openshift/installer/pkg/asset/kubeconfig" "github.com/openshift/installer/pkg/asset/machines" "github.com/openshift/installer/pkg/asset/manifests" + "github.com/openshift/installer/pkg/asset/release" "github.com/openshift/installer/pkg/asset/tls" "github.com/openshift/installer/pkg/types" ) @@ -35,10 +36,6 @@ const ( ignitionUser = "core" ) -var ( - defaultReleaseImage = "registry.svc.ci.openshift.org/openshift/origin-release:v4.0" -) - // bootstrapTemplateData is the data to use to replace values in bootstrap // template files. type bootstrapTemplateData struct { @@ -66,6 +63,7 @@ func (a *Bootstrap) Dependencies() []asset.Asset { &machines.Master{}, &manifests.Manifests{}, &manifests.Openshift{}, + new(release.Image), &tls.AdminKubeConfigCABundle{}, &tls.AggregatorCA{}, &tls.AggregatorCABundle{}, @@ -116,9 +114,10 @@ func (a *Bootstrap) Dependencies() []asset.Asset { // Generate generates the ignition config for the Bootstrap asset. func (a *Bootstrap) Generate(dependencies asset.Parents) error { installConfig := &installconfig.InstallConfig{} - dependencies.Get(installConfig) + releaseImage := new(release.Image) + dependencies.Get(installConfig, releaseImage) - templateData, err := a.getTemplateData(installConfig.Config) + templateData, err := a.getTemplateData(installConfig.Config, string(*releaseImage)) if err != nil { return errors.Wrap(err, "failed to get bootstrap templates") } @@ -170,18 +169,12 @@ func (a *Bootstrap) Files() []*asset.File { } // getTemplateData returns the data to use to execute bootstrap templates. -func (a *Bootstrap) getTemplateData(installConfig *types.InstallConfig) (*bootstrapTemplateData, error) { +func (a *Bootstrap) getTemplateData(installConfig *types.InstallConfig, releaseImage string) (*bootstrapTemplateData, error) { etcdEndpoints := make([]string, *installConfig.ControlPlane.Replicas) for i := range etcdEndpoints { etcdEndpoints[i] = fmt.Sprintf("https://etcd-%d.%s:2379", i, installConfig.ClusterDomain()) } - releaseImage := defaultReleaseImage - if ri, ok := os.LookupEnv("OPENSHIFT_INSTALL_RELEASE_IMAGE_OVERRIDE"); ok && ri != "" { - logrus.Warn("Found override for ReleaseImage. Please be warned, this is not advised") - releaseImage = ri - } - return &bootstrapTemplateData{ EtcdCertSignerImage: etcdCertSignerImage, PullSecret: installConfig.PullSecret, diff --git a/pkg/asset/release/release.go b/pkg/asset/release/release.go new file mode 100644 index 00000000000..a6be5f440cb --- /dev/null +++ b/pkg/asset/release/release.go @@ -0,0 +1,41 @@ +// Package release contains assets for the release image (also known +// as the update payload). +package release + +import ( + "os" + + "github.com/sirupsen/logrus" + + "github.com/openshift/installer/pkg/asset" +) + +var ( + defaultImage = "registry.svc.ci.openshift.org/openshift/origin-release:v4.0" +) + +// Image is the pull-spec for the release image. +type Image string + +var _ asset.Asset = (*Image)(nil) + +// Name returns the human-friendly name of the asset. +func (i *Image) Name() string { + return "Release Image" +} + +// Dependencies returns no dependencies. +func (i *Image) Dependencies() []asset.Asset { + return nil +} + +// Generate the release image. +func (i *Image) Generate(p asset.Parents) error { + releaseImage := defaultImage + if ri := os.Getenv("OPENSHIFT_INSTALL_RELEASE_IMAGE_OVERRIDE"); ri != "" { + logrus.Warn("Found override for Image. Please be warned, this is not advised") + releaseImage = ri + } + *i = Image(releaseImage) + return nil +} diff --git a/pkg/asset/rhcos/image.go b/pkg/asset/rhcos/image.go index 956b85bc320..19d3545103c 100644 --- a/pkg/asset/rhcos/image.go +++ b/pkg/asset/rhcos/image.go @@ -3,15 +3,15 @@ package rhcos import ( "context" - "os" "time" "github.com/pkg/errors" - "github.com/sirupsen/logrus" "github.com/openshift/installer/pkg/asset" "github.com/openshift/installer/pkg/asset/installconfig" + releaseasset "github.com/openshift/installer/pkg/asset/release" "github.com/openshift/installer/pkg/rhcos" + "github.com/openshift/installer/pkg/rhcos/release" "github.com/openshift/installer/pkg/types/aws" "github.com/openshift/installer/pkg/types/libvirt" "github.com/openshift/installer/pkg/types/none" @@ -30,34 +30,35 @@ func (i *Image) Name() string { return "Image" } -// Dependencies returns no dependencies. +// Dependencies returns the assets on which the Image asset depends. func (i *Image) Dependencies() []asset.Asset { return []asset.Asset{ &installconfig.InstallConfig{}, + new(releaseasset.Image), } } // Generate the RHCOS image location. func (i *Image) Generate(p asset.Parents) error { - if oi, ok := os.LookupEnv("OPENSHIFT_INSTALL_OS_IMAGE_OVERRIDE"); ok && oi != "" { - logrus.Warn("Found override for OS Image. Please be warned, this is not advised") - *i = Image(oi) - return nil - } - ic := &installconfig.InstallConfig{} - p.Get(ic) + releaseImage := new(releaseasset.Image) + p.Get(ic, releaseImage) config := ic.Config + ctx := context.TODO() + build, err := release.RHCOSBuild(ctx, string(*releaseImage), []byte(ic.Config.PullSecret)) + if err != nil { + return err + } + var osimage string - var err error - ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() switch config.Platform.Name() { case aws.Name: - osimage, err = rhcos.AMI(ctx, rhcos.DefaultChannel, config.Platform.AWS.Region) + osimage, err = rhcos.AMI(ctx, rhcos.DefaultChannel, build, config.Platform.AWS.Region) case libvirt.Name: - osimage, err = rhcos.QEMU(ctx, rhcos.DefaultChannel) + osimage, err = rhcos.QEMU(ctx, rhcos.DefaultChannel, build) case openstack.Name: osimage = "rhcos" case none.Name: diff --git a/pkg/rhcos/ami.go b/pkg/rhcos/ami.go index ee43dba5f89..cc4ceaba759 100644 --- a/pkg/rhcos/ami.go +++ b/pkg/rhcos/ami.go @@ -7,8 +7,8 @@ import ( ) // AMI fetches the HVM AMI ID of the latest Red Hat Enterprise Linux CoreOS release. -func AMI(ctx context.Context, channel, region string) (string, error) { - meta, err := fetchLatestMetadata(ctx, channel) +func AMI(ctx context.Context, channel, build, region string) (string, error) { + meta, err := fetchMetadata(ctx, channel, build) if err != nil { return "", errors.Wrap(err, "failed to fetch RHCOS metadata") } diff --git a/pkg/rhcos/builds.go b/pkg/rhcos/builds.go index 015b50ef615..31f26c28d8c 100644 --- a/pkg/rhcos/builds.go +++ b/pkg/rhcos/builds.go @@ -15,10 +15,6 @@ var ( // DefaultChannel is the default RHCOS channel for the cluster. DefaultChannel = "maipo" - // buildName is the name of the build in the channel that will be picked up - // empty string means the first one in the build list (latest) will be used - buildName = "" - baseURL = "https://releases-rhcos.svc.ci.openshift.org/storage/releases" ) @@ -36,16 +32,7 @@ type metadata struct { OSTreeVersion string `json:"ostree-version"` } -func fetchLatestMetadata(ctx context.Context, channel string) (metadata, error) { - build := buildName - var err error - if build == "" { - build, err = fetchLatestBuild(ctx, channel) - if err != nil { - return metadata{}, errors.Wrap(err, "failed to fetch latest build") - } - } - +func fetchMetadata(ctx context.Context, channel string, build string) (metadata, error) { url := fmt.Sprintf("%s/%s/%s/meta.json", baseURL, channel, build) logrus.Debugf("Fetching RHCOS metadata from %q", url) req, err := http.NewRequest("GET", url, nil) @@ -76,41 +63,3 @@ func fetchLatestMetadata(ctx context.Context, channel string) (metadata, error) return meta, nil } - -func fetchLatestBuild(ctx context.Context, channel string) (string, error) { - url := fmt.Sprintf("%s/%s/builds.json", baseURL, channel) - logrus.Debugf("Fetching RHCOS builds from %q", url) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", errors.Wrap(err, "failed to build request") - } - - client := &http.Client{} - resp, err := client.Do(req.WithContext(ctx)) - if err != nil { - return "", errors.Wrap(err, "failed to fetch builds") - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("incorrect HTTP response (%s)", resp.Status) - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", errors.Wrap(err, "failed to read HTTP response") - } - - var builds struct { - Builds []string `json:"builds"` - } - if err := json.Unmarshal(body, &builds); err != nil { - return "", errors.Wrap(err, "failed to parse HTTP response") - } - - if len(builds.Builds) == 0 { - return "", errors.Errorf("no builds found") - } - - return builds.Builds[0], nil -} diff --git a/pkg/rhcos/qemu.go b/pkg/rhcos/qemu.go index 98b0d33e88b..5d423f064b7 100644 --- a/pkg/rhcos/qemu.go +++ b/pkg/rhcos/qemu.go @@ -8,8 +8,8 @@ import ( ) // QEMU fetches the URL of the latest Red Hat Enterprise Linux CoreOS release. -func QEMU(ctx context.Context, channel string) (string, error) { - meta, err := fetchLatestMetadata(ctx, channel) +func QEMU(ctx context.Context, channel string, build string) (string, error) { + meta, err := fetchMetadata(ctx, channel, build) if err != nil { return "", errors.Wrap(err, "failed to fetch RHCOS metadata") } diff --git a/pkg/rhcos/release/auth.go b/pkg/rhcos/release/auth.go new file mode 100644 index 00000000000..3ca0b22abc0 --- /dev/null +++ b/pkg/rhcos/release/auth.go @@ -0,0 +1,48 @@ +package release + +import ( + "encoding/base64" + "encoding/json" + "strings" + + "github.com/containers/image/types" + "github.com/docker/distribution/reference" + "github.com/pkg/errors" +) + +type auth struct { + Auth string `json:"auth"` +} + +type config struct { + Auths map[string]auth `json:"auths"` +} + +func addPullSecret(sys *types.SystemContext, pullSecret []byte, named reference.Named) error { + var auths config + err := json.Unmarshal(pullSecret, &auths) + if err != nil { + return err + } + + authority := reference.Domain(named) + auth, ok := auths.Auths[authority] // hack: skipping normalization + if ok { + decoded, err := base64.StdEncoding.DecodeString(auth.Auth) + if err != nil { + return err + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return errors.Errorf("invalid pull-secret entry for %q", authority) + } + + sys.DockerAuthConfig = &types.DockerAuthConfig{ + Username: parts[0], + Password: parts[1], + } + } + + return nil +} diff --git a/pkg/rhcos/release/release.go b/pkg/rhcos/release/release.go new file mode 100644 index 00000000000..7c85a995b88 --- /dev/null +++ b/pkg/rhcos/release/release.go @@ -0,0 +1,180 @@ +// Package release extracts the RHCOS build version from an +// OpenShift release image. +package release + +import ( + "archive/tar" + "compress/gzip" + "context" + "io" + "io/ioutil" + + "github.com/containers/image/docker" + "github.com/containers/image/image" + "github.com/containers/image/pkg/blobinfocache" + "github.com/containers/image/signature" + "github.com/containers/image/transports" + "github.com/containers/image/types" + "github.com/docker/distribution/reference" + imagev1 "github.com/openshift/api/image/v1" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +// RHCOSBuild extracts the RHCOS build version from an OpenShift +// release image. +func RHCOSBuild(ctx context.Context, releaseImagePullSpec string, pullSecret []byte) (string, error) { + imageReferencesBytes, err := imageReferences(ctx, releaseImagePullSpec, pullSecret) + if err != nil { + return "", err + } + + pullSpec, err := osContentPullSpec(imageReferencesBytes) + if err != nil { + return "", err + } + + imageSource, image, err := pull(ctx, pullSpec, pullSecret) + if err != nil { + return "", err + } + defer imageSource.Close() + + inspect, err := image.Inspect(ctx) + if err != nil { + return "", err + } + + version, ok := inspect.Labels["version"] + if !ok { + return "", errors.Errorf("%s has no version annotation", pullSpec) + } + + return version, nil +} + +// pull pulls an image. If the pull secret includes credentials for +// the pull-spec authority, those are used to authenticate requests. +// The signature on the returned image, if any, is validated agaist +// the signature policy from /etc/containers/policy.json. +func pull(ctx context.Context, pullSpec string, pullSecret []byte) (types.ImageSource, types.Image, error) { + named, err := reference.ParseNamed(pullSpec) + if err != nil { + return nil, nil, err + } + + reference, err := docker.NewReference(named) + if err != nil { + return nil, nil, err + } + + sys := &types.SystemContext{} + err = addPullSecret(sys, pullSecret, named) + if err != nil { + return nil, nil, err + } + + imageSource, err := reference.NewImageSource(ctx, sys) + if err != nil { + return nil, nil, err + } + defer func() { + if err != nil { + imageSource.Close() + } + }() + + unparsed := image.UnparsedInstance(imageSource, nil) + + policy, err := signature.DefaultPolicy(sys) + if err != nil { + return nil, nil, err + } + + policyContext, err := signature.NewPolicyContext(policy) + if err != nil { + return nil, nil, err + } + + if allowed, err := policyContext.IsRunningImageAllowed(ctx, unparsed); !allowed || err != nil { + return nil, nil, errors.Wrapf(err, "%s rejected", pullSpec) + } + + img, err := image.FromUnparsedImage(ctx, sys, unparsed) + return imageSource, img, err +} + +func imageReferences(ctx context.Context, releaseImagePullSpec string, pullSecret []byte) ([]byte, error) { + imageSource, image, err := pull(ctx, releaseImagePullSpec, pullSecret) + if err != nil { + return nil, err + } + defer imageSource.Close() + + layers := image.LayerInfos() + for i := len(layers) - 1; i >= 0; i-- { + layer := layers[i] + blob, _, err := imageSource.GetBlob(ctx, layer, blobinfocache.NoCache) + if err != nil { + return nil, err + } + defer blob.Close() + + // fmt.Println(layer.MediaType) // TODO: blank from quay? Just assume this is a gzipped tar? + gzipReader, err := gzip.NewReader(blob) + if err != nil { + return nil, err + } + + tarReader := tar.NewReader(gzipReader) + for { + hdr, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + if hdr.Name == "release-manifests/image-references" { + return ioutil.ReadAll(tarReader) + } + } + } + + return nil, errors.Errorf("no image reference found in %s", transports.ImageName(image.Reference())) +} + +func osContentPullSpec(imageReferences []byte) (string, error) { + scheme := runtime.NewScheme() + imagev1.Install(scheme) + decoder := serializer.NewCodecFactory(scheme).UniversalDecoder( + imagev1.GroupVersion, + ) + obj, _, err := decoder.Decode(imageReferences, nil, nil) + if err != nil { + return "", err + } + + imageStream, ok := obj.(*imagev1.ImageStream) + if !ok { + return "", errors.Errorf("image references is a %q, not an image stream", obj.GetObjectKind()) + } + + for _, tag := range imageStream.Spec.Tags { + if tag.Name == "machine-os-content" { + if tag.From == nil { + return "", errors.New("machine-os-content tag has an empty 'from' property") + } + + if tag.From.Kind != "DockerImage" { + return "", errors.Errorf("unrecognized machine-os-content tag from kind %q", tag.From.Kind) + } + + return tag.From.Name, nil + } + } + + return "", errors.New("no machine-os-content tag found") +}