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") +}