mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-11-12 08:36:36 -05:00
versions: generate k8s image patches (incl etcd) (#2764)
* versions: generate k8s image patches (incl etcd)
This commit is contained in:
parent
8c1972c335
commit
837b24bf54
11 changed files with 288 additions and 130 deletions
|
|
@ -6,7 +6,14 @@ go_library(
|
|||
srcs = ["generate.go"],
|
||||
importpath = "github.com/edgelesssys/constellation/v2/internal/versions/hash-generator",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = ["@org_golang_x_tools//go/ast/astutil"],
|
||||
deps = [
|
||||
"@com_github_regclient_regclient//:regclient",
|
||||
"@com_github_regclient_regclient//types/ref",
|
||||
"@com_github_vincent_petithory_dataurl//:dataurl",
|
||||
"@io_k8s_kubernetes//cmd/kubeadm/app/apis/kubeadm",
|
||||
"@io_k8s_kubernetes//cmd/kubeadm/app/images",
|
||||
"@org_golang_x_tools//go/ast/astutil",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,20 @@ Copyright (c) Edgeless Systems GmbH
|
|||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// hash-generator updates the binary hashes and kubeadm patches in versions.go in place.
|
||||
//
|
||||
// This binary is usually invoked by the //bazel/ci:go_generate target, but you can run it
|
||||
// manually, too. Clear a hash or a data URL in versions.go and execute
|
||||
//
|
||||
// bazel run //internal/versions/hash-generator -- --update=false $PWD/internal/versions/versions.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
|
|
@ -19,52 +27,113 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/regclient/regclient"
|
||||
"github.com/regclient/regclient/types/ref"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
"golang.org/x/tools/go/ast/astutil"
|
||||
"k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm"
|
||||
"k8s.io/kubernetes/cmd/kubeadm/app/images"
|
||||
)
|
||||
|
||||
func mustGetHash(url string) string {
|
||||
// remove quotes around url
|
||||
url = url[1 : len(url)-1]
|
||||
const (
|
||||
defaultRegistry = "registry.k8s.io"
|
||||
etcdComponent = "etcd"
|
||||
defaultFilePath = "./versions.go"
|
||||
)
|
||||
|
||||
// Get the data
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
var supportedComponents = []string{"kube-apiserver", "kube-controller-manager", "kube-scheduler", "etcd"}
|
||||
|
||||
func quote(s string) string {
|
||||
return fmt.Sprintf(`"%s"`, s)
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimSuffix(s, `"`), `"`)
|
||||
}
|
||||
|
||||
// pinKubernetesImage takes a component and a version and returns the corresponding container image pinned by hash.
|
||||
//
|
||||
// The version string is a Kubernetes version tag, which is used to derive the tags of the component images.
|
||||
// The image hash is obtained directly from the default registry, registry.k8s.io.
|
||||
func pinKubernetesImage(comp, ver string) (string, error) {
|
||||
if !slices.Contains(supportedComponents, comp) {
|
||||
return "", fmt.Errorf("k8s component %q not supported: valid components: %#v", comp, supportedComponents)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
ref := ref.Ref{Scheme: "reg", Registry: defaultRegistry, Repository: comp, Tag: ver}
|
||||
if comp == etcdComponent {
|
||||
cfg := &kubeadm.ClusterConfiguration{
|
||||
KubernetesVersion: ver,
|
||||
ImageRepository: defaultRegistry,
|
||||
}
|
||||
|
||||
img := images.GetEtcdImage(cfg)
|
||||
_, tag, _ := strings.Cut(img, ":")
|
||||
ref.Tag = tag
|
||||
}
|
||||
log.Printf("Getting hash for image %#v", ref)
|
||||
|
||||
rc := regclient.New()
|
||||
m, err := rc.ManifestGet(context.Background(), ref)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("could not obtain image manifest: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s:%s@%s", ref.Registry, ref.Repository, ref.Tag, m.GetDescriptor().Digest.String()), nil
|
||||
}
|
||||
|
||||
func generateKubeadmPatch(comp, ver string) (string, error) {
|
||||
img, err := pinKubernetesImage(comp, ver)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content, err := json.Marshal([]map[string]string{{
|
||||
"op": "replace",
|
||||
"path": "/spec/containers/0/image",
|
||||
"value": img,
|
||||
}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dataurl.New(content, "application/json").String(), nil
|
||||
}
|
||||
|
||||
// hashURLContent downloads a binary blob from the given URL and calculates its SHA256 hash.
|
||||
//
|
||||
// URLs passed to this function are expected to have upstream signatures with a .sha256 suffix. This upstream signature
|
||||
// will be verified, too.
|
||||
//
|
||||
// nolint:noctx // This is a cli that does not benefit from passing contexts around.
|
||||
func hashURLContent(url string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
panic("bad status: " + resp.Status)
|
||||
return "", fmt.Errorf("unexpected HTTP response code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Generate SHA256 hash of the file
|
||||
sha := sha256.New()
|
||||
if _, err := io.Copy(sha, resp.Body); err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("could not calculate response body hash: %w", err)
|
||||
}
|
||||
fileHash := sha.Sum(nil)
|
||||
|
||||
// Get upstream hash
|
||||
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url+".sha256", http.NoBody)
|
||||
resp, err = http.Get(url + ".sha256")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("could not fetch upstream digest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
panic("bad status: " + resp.Status)
|
||||
return "", fmt.Errorf("unexpected HTTP response code for upstream digest: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Compare hashes
|
||||
|
|
@ -73,106 +142,150 @@ func mustGetHash(url string) string {
|
|||
// Some .sha256 files contain additional information afterwards.
|
||||
upstreamHash := make([]byte, 64)
|
||||
if _, err = resp.Body.Read(upstreamHash); err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("could not read upstream hash: %w", err)
|
||||
}
|
||||
if string(upstreamHash) != fmt.Sprintf("%x", fileHash) {
|
||||
panic("hash mismatch")
|
||||
return "", fmt.Errorf("computed hash %x does not match upstream hash %s", fileHash, string(upstreamHash))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("\"sha256:%x\"", fileHash)
|
||||
return fmt.Sprintf("sha256:%x", fileHash), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("Generating hashes...")
|
||||
type updater struct {
|
||||
k8sVersion string
|
||||
}
|
||||
|
||||
const filePath = "./versions.go"
|
||||
// maybeSetVersion keeps track of the ambient ClusterVersion of components.
|
||||
func (u *updater) maybeSetVersion(n ast.Node) {
|
||||
kv, ok := n.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, ok := kv.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "ClusterVersion" {
|
||||
return
|
||||
}
|
||||
val, ok := kv.Value.(*ast.BasicLit)
|
||||
if !ok || val.Kind != token.STRING {
|
||||
return
|
||||
}
|
||||
|
||||
u.k8sVersion = val.Value[1 : len(val.Value)-1]
|
||||
}
|
||||
|
||||
func (u *updater) updateComponents(cursor *astutil.Cursor) bool {
|
||||
n := cursor.Node()
|
||||
|
||||
u.maybeSetVersion(n)
|
||||
//
|
||||
// Find CompositeLit of type 'components.Components'
|
||||
//
|
||||
comp, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selExpr, ok := comp.Type.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if selExpr.Sel.Name != "Components" {
|
||||
return true
|
||||
}
|
||||
xIdent, ok := selExpr.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if xIdent.Name != "components" {
|
||||
return true
|
||||
}
|
||||
|
||||
log.Printf("Iterating over components for cluster version %q", u.k8sVersion)
|
||||
|
||||
//
|
||||
// Iterate over the components
|
||||
//
|
||||
for _, componentElt := range comp.Elts {
|
||||
component := componentElt.(*ast.CompositeLit)
|
||||
|
||||
var url, hash, installPath *ast.KeyValueExpr
|
||||
|
||||
for _, e := range component.Elts {
|
||||
kv, ok := e.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ident, ok := kv.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch ident.Name {
|
||||
case "Url":
|
||||
url = kv
|
||||
case "Hash":
|
||||
hash = kv
|
||||
case "InstallPath":
|
||||
installPath = kv
|
||||
}
|
||||
}
|
||||
|
||||
urlValue := unquote(url.Value.(*ast.BasicLit).Value)
|
||||
if urlValue == "" || strings.HasPrefix(urlValue, "data:") {
|
||||
// This can't be a downloadable component, so we assume this is supposed to be a kubeadm patch.
|
||||
if urlValue != "" && !*updateHash {
|
||||
continue
|
||||
}
|
||||
// all patch InstallPaths look like `patchFilePath("$COMPONENT")`
|
||||
comp := unquote(installPath.Value.(*ast.CallExpr).Args[0].(*ast.BasicLit).Value)
|
||||
log.Println("Generating kubeadm patch for", comp)
|
||||
dataURL, err := generateKubeadmPatch(comp, u.k8sVersion)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not generate kubeadm patch for %q: %v", comp, err)
|
||||
}
|
||||
url.Value.(*ast.BasicLit).Value = quote(dataURL)
|
||||
} else {
|
||||
if hash.Value.(*ast.BasicLit).Value != `""` && !*updateHash {
|
||||
continue
|
||||
}
|
||||
log.Println("Generating hash for", urlValue)
|
||||
h, err := hashURLContent(urlValue)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not hash URL %q: %v", urlValue, err)
|
||||
}
|
||||
hash.Value.(*ast.BasicLit).Value = quote(h)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
var updateHash = flag.Bool("update", true, "update existing hashes and data URLs")
|
||||
|
||||
func main() {
|
||||
log.Println("Generating hashes...")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
filePath := flag.Arg(0)
|
||||
if filePath == "" {
|
||||
filePath = defaultFilePath
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("Could not parse file %q: %v", filePath, err)
|
||||
}
|
||||
|
||||
var componentListsCtr, componentCtr int
|
||||
|
||||
newFile := astutil.Apply(file, func(cursor *astutil.Cursor) bool {
|
||||
n := cursor.Node()
|
||||
|
||||
//
|
||||
// Find CompositeLit of type 'components.Components'
|
||||
//
|
||||
comp, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selExpr, ok := comp.Type.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if selExpr.Sel.Name != "Components" {
|
||||
return true
|
||||
}
|
||||
xIdent, ok := selExpr.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if xIdent.Name != "components" {
|
||||
return true
|
||||
}
|
||||
componentListsCtr++
|
||||
|
||||
//
|
||||
// Iterate over the components
|
||||
//
|
||||
for _, componentElt := range comp.Elts {
|
||||
component := componentElt.(*ast.CompositeLit)
|
||||
componentCtr++
|
||||
|
||||
var url *ast.KeyValueExpr
|
||||
var hash *ast.KeyValueExpr
|
||||
|
||||
for _, e := range component.Elts {
|
||||
kv, ok := e.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ident, ok := kv.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch ident.Name {
|
||||
case "Url":
|
||||
url = kv
|
||||
case "Hash":
|
||||
hash = kv
|
||||
}
|
||||
}
|
||||
|
||||
urlValue := url.Value.(*ast.BasicLit).Value
|
||||
if strings.HasPrefix(urlValue, `"data:`) {
|
||||
// TODO(burgerdev): support patch generation
|
||||
continue
|
||||
}
|
||||
fmt.Println("Generating hash for", urlValue)
|
||||
hash.Value.(*ast.BasicLit).Value = mustGetHash(urlValue)
|
||||
}
|
||||
|
||||
return true
|
||||
}, nil,
|
||||
)
|
||||
updater := &updater{}
|
||||
newFile := astutil.Apply(file, updater.updateComponents, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
printConfig := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
|
||||
|
||||
if err = printConfig.Fprint(&buf, fset, newFile); err != nil {
|
||||
log.Fatalf("error formatting file %s: %s", filePath, err)
|
||||
log.Fatalf("Could not format file %q: %v", filePath, err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, buf.Bytes(), 0o644); err != nil {
|
||||
log.Fatalf("error writing file %s: %s", filePath, err)
|
||||
log.Fatalf("Could not write file %q: %v", filePath, err)
|
||||
}
|
||||
if componentCtr == 0 {
|
||||
log.Fatalf("no components lists found")
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully generated hashes for %d components in %d component lists.\n", componentCtr, componentListsCtr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,10 @@ var VersionConfigs = map[ValidK8sVersion]KubernetesVersion{
|
|||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2t1YmUtc2NoZWR1bGVyOnYxLjI2LjExQHNoYTI1NjowNjg0ZTIzMTcyZDkyMDMxNDk3MTU4MGFiMTE1YTViNjc5YWMxZmFlMmNiOTRkODNlOTEwNWMwYjFlOTNhMWJjIn1d",
|
||||
InstallPath: patchFilePath("kube-scheduler"),
|
||||
},
|
||||
{
|
||||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2V0Y2Q6My41LjEwLTBAc2hhMjU2OjIyZjg5MmQ3NjcyYWRjMGI5Yzg2ZGY2Nzc5MmFmZGI4YjJkYzA4ODgwZjQ5ZjY2OWVhYWE1OWM0N2Q3OTA4YzIifV0=",
|
||||
InstallPath: patchFilePath("etcd"),
|
||||
},
|
||||
},
|
||||
// CloudControllerManagerImageAWS is the CCM image used on AWS.
|
||||
CloudControllerManagerImageAWS: "registry.k8s.io/provider-aws/cloud-controller-manager:v1.26.6@sha256:33445ab57f48938fe989ffe311dacee0044b82f2bd23cb7f7b563275926f0ce9", // renovate:container
|
||||
|
|
@ -309,6 +313,10 @@ var VersionConfigs = map[ValidK8sVersion]KubernetesVersion{
|
|||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2t1YmUtc2NoZWR1bGVyOnYxLjI3LjhAc2hhMjU2OjYyMzdlNzEwMGNjZGJiZDVlMGU3Y2ZmNzc5NjgzMWMxODVhMzk0NzE5OTgyM2YzOTEyODNjNzlkMDBhZmYwNzAifV0=",
|
||||
InstallPath: patchFilePath("kube-scheduler"),
|
||||
},
|
||||
{
|
||||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2V0Y2Q6My41LjEwLTBAc2hhMjU2OjIyZjg5MmQ3NjcyYWRjMGI5Yzg2ZGY2Nzc5MmFmZGI4YjJkYzA4ODgwZjQ5ZjY2OWVhYWE1OWM0N2Q3OTA4YzIifV0=",
|
||||
InstallPath: patchFilePath("etcd"),
|
||||
},
|
||||
},
|
||||
// CloudControllerManagerImageAWS is the CCM image used on AWS.
|
||||
CloudControllerManagerImageAWS: "registry.k8s.io/provider-aws/cloud-controller-manager:v1.27.2@sha256:42be09a2b13b4e69b42905639d6b005ebe1ca490aabefad427256abf2cc892c7", // renovate:container
|
||||
|
|
@ -372,6 +380,10 @@ var VersionConfigs = map[ValidK8sVersion]KubernetesVersion{
|
|||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2t1YmUtc2NoZWR1bGVyOnYxLjI4LjRAc2hhMjU2OjMzNWJiYTllODYxYjg4ZmE4YjdiYjkyNTBiY2Q2OWI3YTMzZjgzZGE0ZmVlOTNmOWZjMGVlZGM2ZjM0ZTI4YmEifV0=",
|
||||
InstallPath: patchFilePath("kube-scheduler"),
|
||||
},
|
||||
{
|
||||
Url: "data:application/json;base64,W3sib3AiOiJyZXBsYWNlIiwicGF0aCI6Ii9zcGVjL2NvbnRhaW5lcnMvMC9pbWFnZSIsInZhbHVlIjoicmVnaXN0cnkuazhzLmlvL2V0Y2Q6My41LjEwLTBAc2hhMjU2OjIyZjg5MmQ3NjcyYWRjMGI5Yzg2ZGY2Nzc5MmFmZGI4YjJkYzA4ODgwZjQ5ZjY2OWVhYWE1OWM0N2Q3OTA4YzIifV0=",
|
||||
InstallPath: patchFilePath("etcd"),
|
||||
},
|
||||
},
|
||||
// CloudControllerManagerImageAWS is the CCM image used on AWS.
|
||||
CloudControllerManagerImageAWS: "registry.k8s.io/provider-aws/cloud-controller-manager:v1.28.1@sha256:79b423ac8bc52d00f932b40de11fc3047a5ed1cbec47cda23bcf8f45ef583ed1", // renovate:container
|
||||
|
|
|
|||
|
|
@ -52,13 +52,16 @@ func TestVersionFromDockerImage(t *testing.T) {
|
|||
|
||||
func TestKubernetesImagePatchCompatibility(t *testing.T) {
|
||||
// This test ensures that pinned Kubernetes images correspond to the
|
||||
// supported Kubernetes versions. It prevents automatic upgrades until
|
||||
// a patch generator is added to the codebase.
|
||||
// TODO(burgerdev): remove after patches are generated automatically.
|
||||
// supported Kubernetes versions.
|
||||
for v, clusterConfig := range VersionConfigs {
|
||||
t.Run(string(v), func(t *testing.T) {
|
||||
for i, component := range clusterConfig.KubernetesComponents.GetUpgradableComponents() {
|
||||
if !strings.HasPrefix(component.Url, "data:") {
|
||||
// This test only applies to kubeadm patches.
|
||||
continue
|
||||
}
|
||||
if strings.Contains(component.InstallPath, "/etcd") {
|
||||
// The etcd version is not derived from the Kubernetes version
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%d-%s", i, path.Base(component.InstallPath)), func(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue