fix(lifecycle): support Kubernetes 1.33 upgrade flow (#6929)

* fix: wait for registry before pulling kubeadm images

* fix: support kubeadm upgrade to newer minors

* fix: recover kubelet config during upgrades

* fix: handle kubelet upgrade recovery edge cases

* fix: refresh rootfs sealctl before use

* fix: tolerate existing temporary registry

* fix: ignore kubeadm upgrade health check job

* fix(kubernetes): align cert identity handling with kubeadm v1.29+ (#83)

* build(lifecycle): auto-detect C compiler and CC

Add CC and CC_<platform> Makefile options and implement compiler
resolution for CGO-enabled sealos/sealctl builds. Update DEVELOPGUIDE
with usage and a cross-compile example.

Signed-off-by: Yun Pan <dinoallo@netc.it>

* fix(kubernetes): align cert identity handling with kubeadm v1.29+

- migrate remote control-plane certs across the v1.29 identity boundary
- repair remote admin.conf on all masters during upgrade
- keep local sealos PKI and kubeconfigs aligned with the v1.29+ model
- keep super-admin.conf local-only during renew and upgrade flows
- preserve pre-v1.31 long-lived cert policy and use v1beta4 fields on
  v1.31+
- fix v1.31 remote admin.conf migration config distribution across
  masters

Signed-off-by: Yun Pan <dinoallo@netc.it>

---------

Signed-off-by: Yun Pan <dinoallo@netc.it>

* fix(lifecycle): sync and upgrade codex

* fix: cert and add timeout for cluster role binding create

---------

Signed-off-by: Yun Pan <dinoallo@netc.it>
Co-authored-by: Yun Pan <dinoallo@netc.it>
This commit is contained in:
yy
2026-06-10 16:44:57 +08:00
committed by GitHub
parent 26590578cf
commit 14a1998910
30 changed files with 3559 additions and 172 deletions
+4 -1
View File
@@ -173,4 +173,7 @@ make build
1. clone code slow, your can use ghproxy: `git clone https://ghproxy.com/https://github.com/labring/sealos`
2. build download package slow, you can use goproxy: `go env -w GOPROXY=https://goproxy.cn,direct && make build`
3. `cgo: C compiler "x86_64-linux-gnu-gcc" not found: exec: "x86_64-linux-gnu-gcc": executable file not found in $PATH` you need install gnu-gcc, like: `apt-get install build-essential` or `yum -y install gcc-c++-x86_64-linux-gnu`
3. Build of `sealos` or `sealctl` fails with a C compiler error.
For native Linux builds, the make rules now auto-detect an available host compiler from `cc`, `gcc`, or `clang`.
For cross-compiling CGO-enabled binaries, install the target toolchain or override it explicitly, for example:
`make build.multiarch BINS="sealos" CC_linux_arm64=aarch64-linux-gnu-gcc`
+6
View File
@@ -43,6 +43,12 @@ Options:
This option is available when using: make {build}.multiarch
Example: make build.multiarch PLATFORMS="linux_arm64 linux_amd64"
CC Override the C compiler for CGO-enabled binaries in single-platform builds.
Example: make build BINS="sealos" CC=gcc
CC_<platform> Override the C compiler for a specific platform in multi-platform builds.
Example: make build.multiarch BINS="sealos" CC_linux_arm64=aarch64-linux-gnu-gcc
V Set to 1 enable verbose build. Default is 0.
endef
export USAGE_OPTIONS
+46 -2
View File
@@ -15,8 +15,10 @@
package cmd
import (
"errors"
"fmt"
"path"
"strings"
"github.com/labring/sealos/pkg/runtime"
@@ -32,6 +34,8 @@ import (
func newCertCmd() *cobra.Command {
var altNames []string
var renewTargets []string
var groups []string
cmd := &cobra.Command{
Use: "cert",
@@ -46,8 +50,19 @@ func newCertCmd() *cobra.Command {
1. sealos cert --alt-names 39.105.169.253
2. edit .kube/config, set the apiserver address as 39.105.169.253, (don't forget to open the security group port for 6443, if you using public cloud)
3. kubectl get pod, to check if it works or not
`,
`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(renewTargets) != 0 {
if len(altNames) != 0 {
return errors.New("--alt-names and --renew cannot be used together")
}
} else if len(altNames) == 0 {
return errors.New("--alt-names is required unless --renew is set")
}
if cmd.Flags().Changed("groups") && len(renewTargets) == 0 {
return errors.New("--groups requires --renew")
}
processor.SyncNewVersionConfig(clusterName)
clusterPath := constants.Clusterfile(clusterName)
@@ -84,15 +99,44 @@ func newCertCmd() *cobra.Command {
return fmt.Errorf("create runtime failed: %v", err)
}
if cm, ok := rt.(runtime.CertManager); ok {
if len(renewTargets) != 0 {
renewOpts := runtime.CertRenewOptions{Targets: renewTargets}
if cmd.Flags().Changed("groups") {
renewOpts.Groups = normalizeFlagValues(groups)
}
logger.Info("using %s cert renew implement on targets %v", cf.GetCluster().GetDistribution(), renewTargets)
return cm.Renew(renewOpts)
}
logger.Info("using %s cert update implement", cf.GetCluster().GetDistribution())
return cm.UpdateCertSANs(altNames)
}
if len(renewTargets) != 0 {
return fmt.Errorf("renew targets %v are not supported for distribution %s", renewTargets, cf.GetCluster().GetDistribution())
}
return nil
},
}
cmd.Flags().StringVarP(&clusterName, "cluster", "c", "default", "name of cluster to applied exec action")
cmd.Flags().StringSliceVar(&altNames, "alt-names", []string{}, "add extra Subject Alternative Names for certs, domain or ip, eg. sealos.io or 10.103.97.2")
_ = cmd.MarkFlagRequired("alt-names")
cmd.Flags().StringSliceVar(&renewTargets, "renew", nil, "renew local cert targets; local admin.conf is synced to node $HOME/.kube/config, super-admin.conf stays local only")
cmd.Flags().StringSliceVar(&groups, "groups", nil, "override admin kubeconfig certificate groups when renewing admin.conf or all")
return cmd
}
func normalizeFlagValues(values []string) []string {
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
return normalized
}
+4 -1
View File
@@ -50,7 +50,10 @@ func (a *registryApplier) Apply(ctx Context, host string) error {
return err
}
return ctx.GetExecer().CmdAsync(host, ctx.GetBash().InitRegistryBash(host))
if err := ctx.GetExecer().CmdAsync(host, ctx.GetBash().InitRegistryBash(host)); err != nil {
return err
}
return helpers.WaitRegistryReady(ctx.GetExecer(), host, rc.Domain, rc.Port)
}
func (*registryApplier) Undo(ctx Context, host string) error {
+31 -1
View File
@@ -20,13 +20,43 @@ import "fmt"
// GenerateCert generate all cert.
func GenerateCert(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain string) error {
certConfig, err := NewSealosCertMetaData(certPATH, certEtcdPATH, altNames, serviceCIRD, hostName, hostIP, DNSDomain)
return GenerateCertForKubeVersion(certPATH, certEtcdPATH, altNames, hostIP, hostName, serviceCIRD, DNSDomain, "")
}
func GenerateCertForKubeVersion(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain, kubeVersion string) error {
certConfig, err := NewSealosCertMetaDataForKubeVersion(certPATH, certEtcdPATH, altNames, serviceCIRD, hostName, hostIP, DNSDomain, kubeVersion)
if err != nil {
return fmt.Errorf("generator cert config failed %v", err)
}
return certConfig.GenerateAll()
}
// RenewCert regenerates all local PKI files, including root CAs and leaf certificates.
func RenewCert(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain string) error {
return RenewCertForKubeVersion(certPATH, certEtcdPATH, altNames, hostIP, hostName, serviceCIRD, DNSDomain, "")
}
func RenewCertForKubeVersion(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain, kubeVersion string) error {
certConfig, err := NewSealosCertMetaDataForKubeVersion(certPATH, certEtcdPATH, altNames, serviceCIRD, hostName, hostIP, DNSDomain, kubeVersion)
if err != nil {
return fmt.Errorf("generator cert config failed %v", err)
}
return certConfig.RenewAll()
}
// RenewLeafCerts regenerates local leaf certificates while preserving the existing CAs.
func RenewLeafCerts(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain string) error {
return RenewLeafCertsForKubeVersion(certPATH, certEtcdPATH, altNames, hostIP, hostName, serviceCIRD, DNSDomain, "")
}
func RenewLeafCertsForKubeVersion(certPATH, certEtcdPATH string, altNames []string, hostIP, hostName, serviceCIRD, DNSDomain, kubeVersion string) error {
certConfig, err := NewSealosCertMetaDataForKubeVersion(certPATH, certEtcdPATH, altNames, serviceCIRD, hostName, hostIP, DNSDomain, kubeVersion)
if err != nil {
return fmt.Errorf("generator cert config failed %v", err)
}
return certConfig.RenewLeafCerts()
}
func GenerateRegistryCert(registryCertPath string, BaseName string) error {
regCertConfig := Config{
Path: registryCertPath,
+68
View File
@@ -0,0 +1,68 @@
// Copyright © 2026 sealos.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cert
import "github.com/Masterminds/semver/v3"
const (
legacyPrivilegedGroup = "system:masters"
clusterAdminsGroup = "kubeadm:cluster-admins"
)
var kube1290 = semver.MustParse("v1.29.0")
type localIdentityModel struct {
DefaultAdminOrganizations []string
IncludeSuperAdminKubeConfig bool
SuperAdminClientName string
SuperAdminOrganizations []string
APIServerKubeletOrganizations []string
APIServerEtcdClientOrganizations []string
EtcdHealthcheckOrganizations []string
}
func resolveLocalIdentityModel(kubeVersion string) localIdentityModel {
if kubeVersion != "" {
if version, err := semver.NewVersion(kubeVersion); err == nil && !version.LessThan(kube1290) {
return localIdentityModel{
DefaultAdminOrganizations: []string{clusterAdminsGroup},
IncludeSuperAdminKubeConfig: true,
SuperAdminClientName: "kubernetes-super-admin",
SuperAdminOrganizations: []string{legacyPrivilegedGroup},
APIServerKubeletOrganizations: []string{clusterAdminsGroup},
APIServerEtcdClientOrganizations: nil,
EtcdHealthcheckOrganizations: nil,
}
}
}
return localIdentityModel{
DefaultAdminOrganizations: []string{legacyPrivilegedGroup},
IncludeSuperAdminKubeConfig: false,
SuperAdminClientName: "kubernetes-super-admin",
SuperAdminOrganizations: []string{legacyPrivilegedGroup},
APIServerKubeletOrganizations: []string{legacyPrivilegedGroup},
APIServerEtcdClientOrganizations: []string{legacyPrivilegedGroup},
EtcdHealthcheckOrganizations: []string{legacyPrivilegedGroup},
}
}
func usesClusterAdminsIdentityModel(kubeVersion string) bool {
return resolveLocalIdentityModel(kubeVersion).IncludeSuperAdminKubeConfig
}
func UsesClusterAdminsIdentityModel(kubeVersion string) bool {
return usesClusterAdminsIdentityModel(kubeVersion)
}
+76 -6
View File
@@ -68,7 +68,7 @@ func CaList(CertPath, CertEtcdPath string) []Config {
}
}
func List(CertPath, CertEtcdPath string) []Config {
func List(CertPath, CertEtcdPath string, identity localIdentityModel) []Config {
return []Config{
{
Path: CertPath,
@@ -97,7 +97,7 @@ func List(CertPath, CertEtcdPath string) []Config {
BaseName: "apiserver-kubelet-client",
CAName: "kubernetes",
CommonName: "kube-apiserver-kubelet-client",
Organization: []string{"system:masters"},
Organization: identity.APIServerKubeletOrganizations,
Year: 100,
AltNames: AltNames{},
Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
@@ -119,7 +119,7 @@ func List(CertPath, CertEtcdPath string) []Config {
BaseName: "apiserver-etcd-client",
CAName: "etcd-ca",
CommonName: "kube-apiserver-etcd-client",
Organization: []string{"system:masters"},
Organization: identity.APIServerEtcdClientOrganizations,
Year: 100,
AltNames: AltNames{},
Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
@@ -152,7 +152,7 @@ func List(CertPath, CertEtcdPath string) []Config {
BaseName: "healthcheck-client",
CAName: "etcd-ca",
CommonName: "kube-etcd-healthcheck-client",
Organization: []string{"system:masters"},
Organization: identity.EtcdHealthcheckOrganizations,
Year: 100,
AltNames: AltNames{},
Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
@@ -166,6 +166,7 @@ type SealosCertMetaData struct {
NodeName string
NodeIP string
DNSDomain string
identity localIdentityModel
//证书生成的位置
CertPath string
CertEtcdPath string
@@ -183,10 +184,15 @@ const (
// apiServerIPAndDomains = MasterIP + VIP + CertSANS 暂时只有apiserver, 记得把cluster.local后缀加到apiServerIPAndDOmas里先
func NewSealosCertMetaData(certPATH, certEtcdPATH string, apiServerIPAndDomains []string, SvcCIDR, nodeName, nodeIP, DNSDomain string) (*SealosCertMetaData, error) {
return NewSealosCertMetaDataForKubeVersion(certPATH, certEtcdPATH, apiServerIPAndDomains, SvcCIDR, nodeName, nodeIP, DNSDomain, "")
}
func NewSealosCertMetaDataForKubeVersion(certPATH, certEtcdPATH string, apiServerIPAndDomains []string, SvcCIDR, nodeName, nodeIP, DNSDomain, kubeVersion string) (*SealosCertMetaData, error) {
data := &SealosCertMetaData{}
data.CertPath = certPATH
data.CertEtcdPath = certEtcdPATH
data.DNSDomain = DNSDomain
data.identity = resolveLocalIdentityModel(kubeVersion)
data.APIServer.IPs = make(map[string]net.IP)
data.APIServer.DNSNames = make(map[string]string)
@@ -278,8 +284,16 @@ func (meta *SealosCertMetaData) generatorServiceAccountKeyPaire() error {
}
func (meta *SealosCertMetaData) GenerateAll() error {
return meta.generateAll(false)
}
func (meta *SealosCertMetaData) RenewAll() error {
return meta.generateAll(true)
}
func (meta *SealosCertMetaData) RenewLeafCerts() error {
cas := CaList(meta.CertPath, meta.CertEtcdPath)
certs := List(meta.CertPath, meta.CertEtcdPath)
certs := List(meta.CertPath, meta.CertEtcdPath, meta.identity)
meta.apiServerAltName(&certs)
meta.etcdAltAndCommonName(&certs)
_ = meta.generatorServiceAccountKeyPaire()
@@ -287,7 +301,63 @@ func (meta *SealosCertMetaData) GenerateAll() error {
CACerts := map[string]*x509.Certificate{}
CAKeys := map[string]crypto.Signer{}
for _, ca := range cas {
caCert, caKey, err := NewCaCertAndKey(ca)
caCert, caKey, err := LoadCaCertAndKeyFromDisk(ca)
if err != nil {
return err
}
CACerts[ca.CommonName] = caCert
CAKeys[ca.CommonName] = caKey
}
for _, cert := range certs {
caCert, ok := CACerts[cert.CAName]
if !ok {
return fmt.Errorf("root ca cert not found %s", cert.CAName)
}
caKey, ok := CAKeys[cert.CAName]
if !ok {
return fmt.Errorf("root ca key not found %s", cert.CAName)
}
Cert, Key, err := NewCaCertAndKeyFromRoot(cert, caCert, caKey)
if err != nil {
return err
}
err = WriteCertAndKey(cert.Path, cert.BaseName, Cert, Key)
if err != nil {
return err
}
}
return nil
}
func (meta *SealosCertMetaData) generateAll(force bool) error {
cas := CaList(meta.CertPath, meta.CertEtcdPath)
certs := List(meta.CertPath, meta.CertEtcdPath, meta.identity)
meta.apiServerAltName(&certs)
meta.etcdAltAndCommonName(&certs)
_ = meta.generatorServiceAccountKeyPaire()
CACerts := map[string]*x509.Certificate{}
CAKeys := map[string]crypto.Signer{}
for _, ca := range cas {
var (
caCert *x509.Certificate
caKey crypto.Signer
err error
)
if force {
caKey, err = NewPrivateKey(x509.UnknownPublicKeyAlgorithm)
if err != nil {
return err
}
caCert, err = NewSelfSignedCACert(caKey, ca.CommonName, ca.Organization, ca.Year)
if err != nil {
return err
}
} else {
caCert, caKey, err = NewCaCertAndKey(ca)
}
if err != nil {
return err
}
+171
View File
@@ -15,7 +15,11 @@
package cert
import (
"os"
"path/filepath"
"testing"
certutil "k8s.io/client-go/util/cert"
)
func TestGenerateAll(t *testing.T) {
@@ -43,3 +47,170 @@ func TestGenerateAll(t *testing.T) {
})
}
}
func TestRenewAllRotatesRootCA(t *testing.T) {
basePath := t.TempDir()
certPath := filepath.Join(basePath, "pki")
etcdPath := filepath.Join(certPath, "etcd")
meta, err := NewSealosCertMetaData(certPath, etcdPath, []string{"test.com", "192.168.1.2"}, "10.64.0.0/10", "master1", "172.27.139.11", "cluster.local")
if err != nil {
t.Fatal(err)
}
if err = meta.GenerateAll(); err != nil {
t.Fatalf("GenerateAll() error = %v", err)
}
oldPEM, err := os.ReadFile(filepath.Join(certPath, "ca.crt"))
if err != nil {
t.Fatalf("ReadFile(old ca.crt) error = %v", err)
}
oldCerts, err := certutil.ParseCertsPEM(oldPEM)
if err != nil || len(oldCerts) == 0 {
t.Fatalf("ParseCertsPEM(old ca.crt) error = %v", err)
}
if err = meta.RenewAll(); err != nil {
t.Fatalf("RenewAll() error = %v", err)
}
newPEM, err := os.ReadFile(filepath.Join(certPath, "ca.crt"))
if err != nil {
t.Fatalf("ReadFile(new ca.crt) error = %v", err)
}
newCerts, err := certutil.ParseCertsPEM(newPEM)
if err != nil || len(newCerts) == 0 {
t.Fatalf("ParseCertsPEM(new ca.crt) error = %v", err)
}
if string(oldPEM) == string(newPEM) {
t.Fatalf("expected root CA certificate to change after RenewAll()")
}
}
func TestGenerateAllForV129UsesUpdatedIdentityModel(t *testing.T) {
basePath := t.TempDir()
certPath := filepath.Join(basePath, "pki")
etcdPath := filepath.Join(certPath, "etcd")
meta, err := NewSealosCertMetaDataForKubeVersion(certPath, etcdPath, []string{"test.com", "192.168.1.2"}, "10.64.0.0/10", "master1", "172.27.139.11", "cluster.local", kubeVersion129)
if err != nil {
t.Fatal(err)
}
if err = meta.GenerateAll(); err != nil {
t.Fatalf("GenerateAll() error = %v", err)
}
tests := []struct {
name string
path string
wantOrgs []string
}{
{
name: "apiserver-kubelet-client",
path: filepath.Join(certPath, "apiserver-kubelet-client.crt"),
wantOrgs: []string{clusterAdminsGroup},
},
{
name: "apiserver-etcd-client",
path: filepath.Join(certPath, "apiserver-etcd-client.crt"),
wantOrgs: nil,
},
{
name: "etcd-healthcheck-client",
path: filepath.Join(etcdPath, "healthcheck-client.crt"),
wantOrgs: nil,
},
}
for _, tt := range tests {
certs, err := certutil.CertsFromFile(tt.path)
if err != nil {
t.Fatalf("CertsFromFile(%s) error = %v", tt.name, err)
}
if len(certs) == 0 {
t.Fatalf("expected certificate for %s", tt.name)
}
if got := certs[0].Subject.Organization; len(got) != len(tt.wantOrgs) {
t.Fatalf("%s organizations = %v, want %v", tt.name, got, tt.wantOrgs)
} else {
for i := range got {
if got[i] != tt.wantOrgs[i] {
t.Fatalf("%s organizations = %v, want %v", tt.name, got, tt.wantOrgs)
}
}
}
}
}
func TestRenewLeafCertsForV129KeepsCAAndUsesUpdatedIdentityModel(t *testing.T) {
basePath := t.TempDir()
certPath := filepath.Join(basePath, "pki")
etcdPath := filepath.Join(certPath, "etcd")
meta, err := NewSealosCertMetaData(certPath, etcdPath, []string{"test.com", "192.168.1.2"}, "10.64.0.0/10", "master1", "172.27.139.11", "cluster.local")
if err != nil {
t.Fatal(err)
}
if err = meta.GenerateAll(); err != nil {
t.Fatalf("GenerateAll() error = %v", err)
}
oldCA, err := os.ReadFile(filepath.Join(certPath, "ca.crt"))
if err != nil {
t.Fatalf("ReadFile(ca.crt) error = %v", err)
}
if err = RenewLeafCertsForKubeVersion(certPath, etcdPath, []string{"test.com", "192.168.1.2"}, "172.27.139.11", "master1", "10.64.0.0/10", "cluster.local", kubeVersion129); err != nil {
t.Fatalf("RenewLeafCertsForKubeVersion() error = %v", err)
}
newCA, err := os.ReadFile(filepath.Join(certPath, "ca.crt"))
if err != nil {
t.Fatalf("ReadFile(ca.crt) after renew error = %v", err)
}
if string(oldCA) != string(newCA) {
t.Fatal("expected RenewLeafCertsForKubeVersion() to preserve the existing CA")
}
tests := []struct {
name string
path string
wantOrgs []string
}{
{
name: "apiserver-kubelet-client",
path: filepath.Join(certPath, "apiserver-kubelet-client.crt"),
wantOrgs: []string{clusterAdminsGroup},
},
{
name: "apiserver-etcd-client",
path: filepath.Join(certPath, "apiserver-etcd-client.crt"),
wantOrgs: nil,
},
{
name: "etcd-healthcheck-client",
path: filepath.Join(etcdPath, "healthcheck-client.crt"),
wantOrgs: nil,
},
}
for _, tt := range tests {
certs, err := certutil.CertsFromFile(tt.path)
if err != nil {
t.Fatalf("CertsFromFile(%s) error = %v", tt.name, err)
}
if len(certs) == 0 {
t.Fatalf("expected certificate for %s", tt.name)
}
if got := certs[0].Subject.Organization; len(got) != len(tt.wantOrgs) {
t.Fatalf("%s organizations = %v, want %v", tt.name, got, tt.wantOrgs)
} else {
for i := range got {
if got[i] != tt.wantOrgs[i] {
t.Fatalf("%s organizations = %v, want %v", tt.name, got, tt.wantOrgs)
}
}
}
}
}
+160 -13
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -50,35 +51,124 @@ type kubeConfigSpec struct {
ClientCertAuth *clientCertAuth
}
const adminKubeConfigFileName = "admin.conf"
const (
controllerManagerKubeConfigFileName = "controller-manager.conf"
schedulerKubeConfigFileName = "scheduler.conf"
kubeletKubeConfigFileName = "kubelet.conf"
superAdminKubeConfigFileName = "super-admin.conf"
)
// CreateJoinControlPlaneKubeConfigFiles will create and write to disk the kubeconfig files required by kubeadm
// join --control-plane workflow, plus the admin kubeconfig file used by the administrator and kubeadm itself; the
// kubelet.conf file must not be created because it will be created and signed by the kubelet TLS bootstrap process.
// If any kubeconfig files already exists, it used only if evaluated equal; otherwise an error is returned.
func CreateJoinControlPlaneKubeConfigFiles(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string) error {
return CreateJoinControlPlaneKubeConfigFilesForKubeVersion(outDir, cfg, nodeName, controlPlaneEndpoint, clusterName, "")
}
func CreateJoinControlPlaneKubeConfigFilesForKubeVersion(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName, kubeVersion string) error {
identity := resolveLocalIdentityModel(kubeVersion)
kubeConfigFileNames := []string{
adminKubeConfigFileName,
controllerManagerKubeConfigFileName,
schedulerKubeConfigFileName,
kubeletKubeConfigFileName,
}
if identity.IncludeSuperAdminKubeConfig {
kubeConfigFileNames = append(kubeConfigFileNames, superAdminKubeConfigFileName)
}
return createKubeConfigFiles(
outDir,
cfg,
nodeName,
controlPlaneEndpoint,
clusterName,
"admin.conf",
"controller-manager.conf",
"scheduler.conf",
"kubelet.conf", //master1上的kubeconfig跟随三个组件一起生成
identity,
kubeConfigFileNames...,
)
}
// RenewAdminKubeConfigFile re-signs and overwrites the local admin kubeconfig used by sealos.
func RenewAdminKubeConfigFile(outDir string, cfg Config, controlPlaneEndpoint, clusterName string, organizations []string) error {
return RenewAdminKubeConfigFileForKubeVersion(outDir, cfg, controlPlaneEndpoint, clusterName, organizations, "")
}
func RenewAdminKubeConfigFileForKubeVersion(outDir string, cfg Config, controlPlaneEndpoint, clusterName string, organizations []string, kubeVersion string) error {
spec, err := getAdminKubeConfigSpec(cfg, controlPlaneEndpoint, organizations, resolveLocalIdentityModel(kubeVersion))
if err != nil {
return err
}
config, err := buildKubeConfigFromSpec(spec, clusterName)
if err != nil {
return err
}
if err = os.MkdirAll(outDir, 0o700); err != nil {
return fmt.Errorf("failed to create kubeconfig output directory %q: %w", outDir, err)
}
kubeConfigFilePath := filepath.Join(outDir, adminKubeConfigFileName)
logger.Debug("[kubeconfig] Renewing %q kubeconfig file\n", kubeConfigFilePath)
if err = WriteToDisk(kubeConfigFilePath, config); err != nil {
return fmt.Errorf("failed to save kubeconfig file %q on disk: %w", kubeConfigFilePath, err)
}
return nil
}
// RenewKubeConfigFiles re-signs and overwrites the selected local kubeconfig files used by sealos.
func RenewKubeConfigFiles(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string, adminOrganizations []string, kubeConfigFileNames ...string) error {
return RenewKubeConfigFilesForKubeVersion(outDir, cfg, nodeName, controlPlaneEndpoint, clusterName, adminOrganizations, "", kubeConfigFileNames...)
}
func RenewKubeConfigFilesForKubeVersion(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string, adminOrganizations []string, kubeVersion string, kubeConfigFileNames ...string) error {
if len(kubeConfigFileNames) == 0 {
return errors.New("at least one kubeconfig file must be specified")
}
specs, err := getKubeConfigSpecs(cfg, nodeName, controlPlaneEndpoint, adminOrganizations, resolveLocalIdentityModel(kubeVersion))
if err != nil {
return err
}
if err = os.MkdirAll(outDir, 0o700); err != nil {
return fmt.Errorf("failed to create kubeconfig output directory %q: %w", outDir, err)
}
for _, kubeConfigFileName := range kubeConfigFileNames {
spec, exists := specs[kubeConfigFileName]
if !exists {
return fmt.Errorf("couldn't retrieve KubeConfigSpec for %s", kubeConfigFileName)
}
config, err := buildKubeConfigFromSpec(spec, clusterName)
if err != nil {
return err
}
kubeConfigFilePath := filepath.Join(outDir, kubeConfigFileName)
logger.Debug("[kubeconfig] Renewing %q kubeconfig file\n", kubeConfigFilePath)
if err = WriteToDisk(kubeConfigFilePath, config); err != nil {
return fmt.Errorf("failed to save kubeconfig file %q on disk: %w", kubeConfigFilePath, err)
}
}
return nil
}
// 方法没有被 ↑ 的方法调用,而是在cmd/kubeadm/app/cmd/phases/init/kubeconfig.go里调用
// cmd/kubeadm/app/phases/kubeconfig/kubeconfig.go
func CreateKubeConfigFile(kubeConfigFileName string, outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string) error {
logger.Info("creating kubeconfig file for %s", kubeConfigFileName)
return createKubeConfigFiles(outDir, cfg, kubeConfigFileName, nodeName, controlPlaneEndpoint, clusterName)
return createKubeConfigFiles(outDir, cfg, nodeName, controlPlaneEndpoint, clusterName, resolveLocalIdentityModel(""), kubeConfigFileName)
}
// createKubeConfigFiles creates all the requested kubeconfig files.
// If kubeconfig files already exists, they are used only if evaluated equal; otherwise an error is returned.
func createKubeConfigFiles(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string, kubeConfigFileNames ...string) error { // gets the KubeConfigSpecs, actualized for the current InitConfiguration
specs, err := getKubeConfigSpecs(cfg, nodeName, controlPlaneEndpoint)
func createKubeConfigFiles(outDir string, cfg Config, nodeName, controlPlaneEndpoint, clusterName string, identity localIdentityModel, kubeConfigFileNames ...string) error { // gets the KubeConfigSpecs, actualized for the current InitConfiguration
specs, err := getKubeConfigSpecs(cfg, nodeName, controlPlaneEndpoint, nil, identity)
if err != nil {
return err
}
@@ -105,9 +195,32 @@ func createKubeConfigFiles(outDir string, cfg Config, nodeName, controlPlaneEndp
return nil
}
func getAdminKubeConfigSpec(cfg Config, controlPlaneEndpoint string, organizations []string, identity localIdentityModel) (*kubeConfigSpec, error) {
caCert, caKey, err := LoadCaCertAndKeyFromDisk(cfg)
if err != nil {
return nil, fmt.Errorf("couldn't create a kubeconfig; the CA files couldn't be loaded: %w", err)
}
if len(controlPlaneEndpoint) == 0 {
return nil, errors.New("controlPlaneEndpoint can not be empty")
}
adminOrganizations := normalizeOrganizations(organizations, identity.DefaultAdminOrganizations)
return &kubeConfigSpec{
CACert: caCert,
APIServer: controlPlaneEndpoint,
ClientName: "kubernetes-admin",
ClientCertAuth: &clientCertAuth{
CAKey: caKey,
Organizations: adminOrganizations,
},
}, nil
}
// getKubeConfigSpecs returns all KubeConfigSpecs actualized to the context of the current InitConfiguration
// NB. this methods holds the information about how kubeadm creates kubeconfig files.
func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string) (map[string]*kubeConfigSpec, error) {
func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string, adminOrganizations []string, identity localIdentityModel) (map[string]*kubeConfigSpec, error) {
caCert, caKey, err := LoadCaCertAndKeyFromDisk(cfg)
if err != nil {
return nil, fmt.Errorf("couldn't create a kubeconfig; the CA files couldn't be loaded: %w", err)
@@ -121,14 +234,16 @@ func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string) (map[
return nil, errors.New("controlPlaneEndpoint can not be empty")
}
var kubeConfigSpec = map[string]*kubeConfigSpec{
normalizedAdminOrganizations := normalizeOrganizations(adminOrganizations, identity.DefaultAdminOrganizations)
specs := map[string]*kubeConfigSpec{
"admin.conf": {
CACert: caCert,
APIServer: controlPlaneEndpoint,
ClientName: "kubernetes-admin",
ClientCertAuth: &clientCertAuth{
CAKey: caKey,
Organizations: []string{"system:masters"},
Organizations: normalizedAdminOrganizations,
},
},
"kubelet.conf": {
@@ -140,7 +255,7 @@ func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string) (map[
Organizations: []string{"system:nodes"},
},
},
"controller-manager.conf": {
controllerManagerKubeConfigFileName: {
CACert: caCert,
APIServer: controlPlaneEndpoint,
ClientName: "system:kube-controller-manager",
@@ -148,7 +263,7 @@ func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string) (map[
CAKey: caKey,
},
},
"scheduler.conf": {
schedulerKubeConfigFileName: {
CACert: caCert,
APIServer: controlPlaneEndpoint,
ClientName: "system:kube-scheduler",
@@ -157,8 +272,40 @@ func getKubeConfigSpecs(cfg Config, nodeName, controlPlaneEndpoint string) (map[
},
},
}
if identity.IncludeSuperAdminKubeConfig {
specs[superAdminKubeConfigFileName] = &kubeConfigSpec{
CACert: caCert,
APIServer: controlPlaneEndpoint,
ClientName: identity.SuperAdminClientName,
ClientCertAuth: &clientCertAuth{
CAKey: caKey,
Organizations: identity.SuperAdminOrganizations,
},
}
}
return kubeConfigSpec, nil
return specs, nil
}
func normalizeOrganizations(organizations []string, defaultOrganizations []string) []string {
if organizations == nil {
return append([]string(nil), defaultOrganizations...)
}
normalized := make([]string, 0, len(organizations))
seen := make(map[string]struct{}, len(organizations))
for _, organization := range organizations {
organization = strings.TrimSpace(organization)
if organization == "" {
continue
}
if _, ok := seen[organization]; ok {
continue
}
seen[organization] = struct{}{}
normalized = append(normalized, organization)
}
return normalized
}
// buildKubeConfigFromSpec creates a kubeconfig object for the given kubeConfigSpec
+459
View File
@@ -0,0 +1,459 @@
package cert
import (
"os"
"path/filepath"
"slices"
"testing"
"k8s.io/client-go/tools/clientcmd"
certutil "k8s.io/client-go/util/cert"
)
const kubeVersion129 = "v1.29.0"
func TestRenewAdminKubeConfigFileCreatesAdminConfig(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
endpoint := "https://apiserver.test:6443"
if err := RenewAdminKubeConfigFile(outDir, cfg, endpoint, "kubernetes", nil); err != nil {
t.Fatalf("RenewAdminKubeConfigFile() error = %v", err)
}
adminConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() error = %v", err)
}
authInfo := adminConfig.AuthInfos["kubernetes-admin"]
if authInfo == nil {
t.Fatalf("expected kubernetes-admin auth info to exist")
}
certs, err := certutil.ParseCertsPEM(authInfo.ClientCertificateData)
if err != nil {
t.Fatalf("ParseCertsPEM() error = %v", err)
}
if len(certs) == 0 {
t.Fatalf("expected at least one client certificate")
}
if got, want := certs[0].Subject.CommonName, "kubernetes-admin"; got != want {
t.Fatalf("client certificate common name = %q, want %q", got, want)
}
if len(certs[0].Subject.Organization) != 1 || certs[0].Subject.Organization[0] != "system:masters" {
t.Fatalf("client certificate organizations = %v, want [system:masters]", certs[0].Subject.Organization)
}
currentCtx := adminConfig.Contexts[adminConfig.CurrentContext]
if currentCtx == nil {
t.Fatalf("expected current context %q to exist", adminConfig.CurrentContext)
}
cluster := adminConfig.Clusters[currentCtx.Cluster]
if cluster == nil {
t.Fatalf("expected cluster %q to exist", currentCtx.Cluster)
}
if got := cluster.Server; got != endpoint {
t.Fatalf("cluster server = %q, want %q", got, endpoint)
}
}
func TestRenewAdminKubeConfigFileForV129UsesClusterAdminsGroup(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCertForKubeVersion(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
kubeVersion129,
); err != nil {
t.Fatalf("GenerateCertForKubeVersion() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := RenewAdminKubeConfigFileForKubeVersion(outDir, cfg, "https://apiserver.test:6443", "kubernetes", nil, kubeVersion129); err != nil {
t.Fatalf("RenewAdminKubeConfigFileForKubeVersion() error = %v", err)
}
adminConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() error = %v", err)
}
authInfo := adminConfig.AuthInfos["kubernetes-admin"]
if authInfo == nil {
t.Fatalf("expected kubernetes-admin auth info to exist")
}
certs, err := certutil.ParseCertsPEM(authInfo.ClientCertificateData)
if err != nil {
t.Fatalf("ParseCertsPEM() error = %v", err)
}
if got, want := certs[0].Subject.Organization, []string{clusterAdminsGroup}; !sameMembers(got, want) {
t.Fatalf("client certificate organizations = %v, want %v", got, want)
}
}
func TestCreateJoinControlPlaneKubeConfigFilesForV129CreatesSuperAdmin(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCertForKubeVersion(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
kubeVersion129,
); err != nil {
t.Fatalf("GenerateCertForKubeVersion() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := CreateJoinControlPlaneKubeConfigFilesForKubeVersion(
outDir,
cfg,
"master0",
"https://apiserver.test:6443",
"kubernetes",
kubeVersion129,
); err != nil {
t.Fatalf("CreateJoinControlPlaneKubeConfigFilesForKubeVersion() error = %v", err)
}
if _, err := os.Stat(filepath.Join(outDir, superAdminKubeConfigFileName)); err != nil {
t.Fatalf("expected %s to be created: %v", superAdminKubeConfigFileName, err)
}
superAdminConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, superAdminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile(super-admin.conf) error = %v", err)
}
authInfo := superAdminConfig.AuthInfos["kubernetes-super-admin"]
if authInfo == nil {
t.Fatalf("expected kubernetes-super-admin auth info to exist")
}
certs, err := certutil.ParseCertsPEM(authInfo.ClientCertificateData)
if err != nil {
t.Fatalf("ParseCertsPEM() error = %v", err)
}
if got, want := certs[0].Subject.Organization, []string{legacyPrivilegedGroup}; !sameMembers(got, want) {
t.Fatalf("super-admin organizations = %v, want %v", got, want)
}
}
func TestCreateJoinControlPlaneKubeConfigFilesForPre129SkipsSuperAdmin(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := CreateJoinControlPlaneKubeConfigFilesForKubeVersion(
outDir,
cfg,
"master0",
"https://apiserver.test:6443",
"kubernetes",
"v1.28.9",
); err != nil {
t.Fatalf("CreateJoinControlPlaneKubeConfigFilesForKubeVersion() error = %v", err)
}
if _, err := os.Stat(filepath.Join(outDir, superAdminKubeConfigFileName)); !os.IsNotExist(err) {
t.Fatalf("expected %s to be absent for pre-v1.29 configs, got err=%v", superAdminKubeConfigFileName, err)
}
}
func TestRenewAdminKubeConfigFileOverwritesExistingAdminConfig(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := CreateJoinControlPlaneKubeConfigFiles(
outDir,
cfg,
"master0",
"https://old-apiserver.test:6443",
"kubernetes",
); err != nil {
t.Fatalf("CreateJoinControlPlaneKubeConfigFiles() error = %v", err)
}
oldConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() before renew error = %v", err)
}
oldAuthInfo := oldConfig.AuthInfos["kubernetes-admin"]
if oldAuthInfo == nil {
t.Fatalf("expected kubernetes-admin auth info before renew")
}
newEndpoint := "https://new-apiserver.test:6443"
if err := RenewAdminKubeConfigFile(outDir, cfg, newEndpoint, "kubernetes", nil); err != nil {
t.Fatalf("RenewAdminKubeConfigFile() error = %v", err)
}
newConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() after renew error = %v", err)
}
newAuthInfo := newConfig.AuthInfos["kubernetes-admin"]
if newAuthInfo == nil {
t.Fatalf("expected kubernetes-admin auth info after renew")
}
if string(oldAuthInfo.ClientCertificateData) == string(newAuthInfo.ClientCertificateData) {
t.Fatalf("expected renewed admin kubeconfig to contain a different client certificate")
}
currentCtx := newConfig.Contexts[newConfig.CurrentContext]
if currentCtx == nil {
t.Fatalf("expected current context %q to exist", newConfig.CurrentContext)
}
cluster := newConfig.Clusters[currentCtx.Cluster]
if cluster == nil {
t.Fatalf("expected cluster %q to exist", currentCtx.Cluster)
}
if got := cluster.Server; got != newEndpoint {
t.Fatalf("cluster server = %q, want %q", got, newEndpoint)
}
}
func TestRenewKubeConfigFilesOverwritesSelectedConfigs(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := CreateJoinControlPlaneKubeConfigFiles(
outDir,
cfg,
"master0",
"https://old-apiserver.test:6443",
"kubernetes",
); err != nil {
t.Fatalf("CreateJoinControlPlaneKubeConfigFiles() error = %v", err)
}
if err := RenewKubeConfigFiles(
outDir,
cfg,
"master0",
"https://new-apiserver.test:6443",
"kubernetes",
nil,
controllerManagerKubeConfigFileName,
schedulerKubeConfigFileName,
); err != nil {
t.Fatalf("RenewKubeConfigFiles() error = %v", err)
}
for _, name := range []string{controllerManagerKubeConfigFileName, schedulerKubeConfigFileName} {
kubeConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, name))
if err != nil {
t.Fatalf("LoadFromFile(%q) error = %v", name, err)
}
currentCtx := kubeConfig.Contexts[kubeConfig.CurrentContext]
if currentCtx == nil {
t.Fatalf("expected current context %q to exist for %s", kubeConfig.CurrentContext, name)
}
cluster := kubeConfig.Clusters[currentCtx.Cluster]
if cluster == nil {
t.Fatalf("expected cluster %q to exist for %s", currentCtx.Cluster, name)
}
if got := cluster.Server; got != "https://new-apiserver.test:6443" {
t.Fatalf("%s cluster server = %q, want %q", name, got, "https://new-apiserver.test:6443")
}
}
}
func TestRenewAdminKubeConfigFileAllowsCustomOrganizations(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := RenewAdminKubeConfigFile(outDir, cfg, "https://apiserver.test:6443", "kubernetes", []string{"platform:devs", "platform:ops"}); err != nil {
t.Fatalf("RenewAdminKubeConfigFile() error = %v", err)
}
adminConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() error = %v", err)
}
authInfo := adminConfig.AuthInfos["kubernetes-admin"]
if authInfo == nil {
t.Fatalf("expected kubernetes-admin auth info to exist")
}
certs, err := certutil.ParseCertsPEM(authInfo.ClientCertificateData)
if err != nil {
t.Fatalf("ParseCertsPEM() error = %v", err)
}
if got, want := slices.Clone(certs[0].Subject.Organization), []string{"platform:devs", "platform:ops"}; !sameMembers(got, want) {
t.Fatalf("client certificate organizations = %v, want %v", got, want)
}
}
func TestRenewAdminKubeConfigFileAllowsEmptyOrganizations(t *testing.T) {
baseDir := t.TempDir()
pkiDir := filepath.Join(baseDir, "pki")
etcdDir := filepath.Join(pkiDir, "etcd")
outDir := filepath.Join(baseDir, "etc")
if err := GenerateCert(
pkiDir,
etcdDir,
[]string{"10.96.0.1", "apiserver.test"},
"192.168.0.10",
"master0",
"10.64.0.0/10",
"cluster.local",
); err != nil {
t.Fatalf("GenerateCert() error = %v", err)
}
cfg := Config{
Path: pkiDir,
BaseName: "ca",
}
if err := RenewAdminKubeConfigFile(outDir, cfg, "https://apiserver.test:6443", "kubernetes", []string{}); err != nil {
t.Fatalf("RenewAdminKubeConfigFile() error = %v", err)
}
adminConfig, err := clientcmd.LoadFromFile(filepath.Join(outDir, adminKubeConfigFileName))
if err != nil {
t.Fatalf("LoadFromFile() error = %v", err)
}
authInfo := adminConfig.AuthInfos["kubernetes-admin"]
if authInfo == nil {
t.Fatalf("expected kubernetes-admin auth info to exist")
}
certs, err := certutil.ParseCertsPEM(authInfo.ClientCertificateData)
if err != nil {
t.Fatalf("ParseCertsPEM() error = %v", err)
}
if len(certs[0].Subject.Organization) != 0 {
t.Fatalf("client certificate organizations = %v, want none", certs[0].Subject.Organization)
}
}
func sameMembers(got, want []string) bool {
if len(got) != len(want) {
return false
}
slices.Sort(got)
want = slices.Clone(want)
slices.Sort(want)
return slices.Equal(got, want)
}
@@ -98,7 +98,7 @@ func (ke *kubeExpansion) UpdateKubeletConfig(ctx context.Context, kubeletConfig
if err != nil {
return err
}
cm.Data[ckubeadm.KubeletBaseConfigurationConfigMap] = kubeletConfig
cm.Data[ckubeadm.KubeletBaseConfigurationConfigMapKey] = kubeletConfig
_, err = ke.client.CoreV1().ConfigMaps(metav1.NamespaceSystem).Update(ctx, cm, metav1.UpdateOptions{})
return err
}
@@ -20,7 +20,11 @@ import (
"context"
"testing"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
ckubeadm "k8s.io/kubernetes/cmd/kubeadm/app/constants"
)
func TestGetKubeadmConfig(t *testing.T) {
@@ -53,3 +57,32 @@ func TestGetKubeadmConfig(t *testing.T) {
})
}
}
func TestUpdateKubeletConfigUsesConfigMapDataKey(t *testing.T) {
client := fake.NewSimpleClientset(&v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ckubeadm.KubeletBaseConfigurationConfigMap,
Namespace: metav1.NamespaceSystem,
},
Data: map[string]string{
ckubeadm.KubeletBaseConfigurationConfigMapKey: "old-config",
},
})
const want = "new-config"
ke := NewKubeExpansion(client)
if err := ke.UpdateKubeletConfig(context.Background(), want); err != nil {
t.Fatalf("UpdateKubeletConfig() error = %v", err)
}
cm, err := client.CoreV1().ConfigMaps(metav1.NamespaceSystem).Get(context.Background(), ckubeadm.KubeletBaseConfigurationConfigMap, metav1.GetOptions{})
if err != nil {
t.Fatalf("get kubelet configmap: %v", err)
}
if got := cm.Data[ckubeadm.KubeletBaseConfigurationConfigMapKey]; got != want {
t.Fatalf("kubelet config key = %q, want %q", got, want)
}
if _, ok := cm.Data[ckubeadm.KubeletBaseConfigurationConfigMap]; ok {
t.Fatalf("unexpected data under configmap name key %q", ckubeadm.KubeletBaseConfigurationConfigMap)
}
}
+147 -31
View File
@@ -43,6 +43,7 @@ import (
const (
localhost = "127.0.0.1"
defaultTemporaryPort = "5050"
registryPIDFileName = "registry.pid"
)
const (
@@ -70,21 +71,6 @@ func (s *impl) Sync(ctx context.Context, hosts ...string) error {
return nil
}
logger.Info("trying default http mode to sync images to hosts %v", hosts)
// run `sealctl registry serve` to start a temporary registry
for i := range hosts {
cmdCtx, cancel := context.WithCancel(ctx)
// defer cancel async commands
defer cancel()
go func(ctx context.Context, host string) {
logger.Debug("running temporary registry on host %s", host)
if err := s.execer.CmdAsyncWithContext(ctx, host, getRegistryServeCommand(s.pathResolver, defaultTemporaryPort)); err != nil {
// ignore expected signal killed error when context cancel
if !strings.Contains(err.Error(), "signal: killed") && !strings.Contains(err.Error(), "context canceled") {
logger.Error(err)
}
}
}(cmdCtx, hosts[i])
}
type syncOption struct {
target string
@@ -92,22 +78,67 @@ func (s *impl) Sync(ctx context.Context, hosts ...string) error {
}
syncOptionChan := make(chan *syncOption, len(hosts))
go func() {
for i := range hosts {
go func(target string) {
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ep := sync.ParseRegistryAddress(trimPortStr(target), defaultTemporaryPort)
if err := httputils.WaitUntilEndpointAlive(probeCtx, "http://"+ep); err != nil {
logger.Warn("cannot connect to remote temporary registry %s: %v, fallback using ssh mode instead", ep, err)
syncOptionChan <- &syncOption{target: target, typ: sshMode}
} else {
syncOptionChan <- &syncOption{target: ep, typ: httpMode}
}
}(hosts[i])
var (
cancelFuncs []context.CancelFunc
temporaryRegistries []string
)
defer func() {
for _, cancel := range cancelFuncs {
cancel()
}
for _, host := range temporaryRegistries {
if err := s.cleanupRemoteTemporaryRegistry(host); err != nil {
logger.Debug("failed to cleanup remote temporary registry on host %s: %v", host, err)
}
}
}()
// run `sealctl registry serve` to start a temporary registry
for i := range hosts {
ep := sync.ParseRegistryAddress(trimPortStr(hosts[i]), defaultTemporaryPort)
if err := checkRemoteTemporaryRegistry(ctx, hosts[i], defaultTemporaryPort); err == nil {
logger.Debug("remote temporary registry %s is already alive, reuse it", ep)
syncOptionChan <- &syncOption{target: ep, typ: httpMode}
continue
}
if err := s.cleanupRemoteTemporaryRegistry(hosts[i]); err != nil {
logger.Debug("failed to cleanup stale remote temporary registry on host %s: %v", hosts[i], err)
}
registryServeCommand, ok := s.getRegistryServeCommand(hosts[i], defaultTemporaryPort)
if !ok {
logger.Debug("remote temporary registry is unsupported on host %s, fallback using ssh mode instead", hosts[i])
syncOptionChan <- &syncOption{target: hosts[i], typ: sshMode}
continue
}
cmdCtx, cancel := context.WithCancel(ctx)
cancelFuncs = append(cancelFuncs, cancel)
temporaryRegistries = append(temporaryRegistries, hosts[i])
go func(ctx context.Context, host, registryServeCommand string) {
logger.Debug("running temporary registry on host %s", host)
if err := s.execer.CmdAsyncWithContext(ctx, host, registryServeCommand); err != nil {
// ignore expected signal killed error when context cancel
if !strings.Contains(err.Error(), "signal: killed") && !strings.Contains(err.Error(), "context canceled") {
if waitRemoteTemporaryRegistry(ctx, host, defaultTemporaryPort) == nil {
logger.Debug("remote temporary registry on host %s is already alive, ignore serve command error: %v", host, err)
return
}
logger.Error(err)
}
}
}(cmdCtx, hosts[i], registryServeCommand)
go func(target, ep string) {
if err := waitRemoteTemporaryRegistry(ctx, target, defaultTemporaryPort); err != nil {
logger.Warn("cannot connect to remote temporary registry %s: %v, fallback using ssh mode instead", ep, err)
syncOptionChan <- &syncOption{target: target, typ: sshMode}
} else {
syncOptionChan <- &syncOption{target: ep, typ: httpMode}
}
}(hosts[i], ep)
}
eg, _ := errgroup.WithContext(ctx)
for i := 0; i < len(hosts); i++ {
opt, ok := <-syncOptionChan
@@ -133,6 +164,29 @@ func (s *impl) Sync(ctx context.Context, hosts ...string) error {
return eg.Wait()
}
func (s *impl) cleanupRemoteTemporaryRegistry(host string) error {
output, err := s.execer.Cmd(host, getRegistryServeCleanupCommand(s.pathResolver))
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func waitRemoteTemporaryRegistry(ctx context.Context, target, port string) error {
return waitRemoteTemporaryRegistryWithTimeout(ctx, target, port, 3*time.Second)
}
func checkRemoteTemporaryRegistry(ctx context.Context, target, port string) error {
return waitRemoteTemporaryRegistryWithTimeout(ctx, target, port, 500*time.Millisecond)
}
func waitRemoteTemporaryRegistryWithTimeout(ctx context.Context, target, port string, timeout time.Duration) error {
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ep := sync.ParseRegistryAddress(trimPortStr(target), port)
return httputils.WaitUntilEndpointAlive(probeCtx, "http://"+ep)
}
func trimPortStr(s string) string {
if idx := strings.Index(s, ":"); idx > 0 {
return s[:idx]
@@ -140,12 +194,74 @@ func trimPortStr(s string) string {
return s
}
func getRegistryServeCommand(pathResolver constants.PathResolver, port string) string {
return fmt.Sprintf("%s registry serve filesystem -p %s --disable-logging=true %s",
pathResolver.RootFSSealctlPath(), port, pathResolver.RootFSRegistryPath(),
type registryServeFlags struct {
portFlag string
pidFile bool
disableLogging bool
}
func (s *impl) getRegistryServeCommand(host, port string) (string, bool) {
output, err := s.execer.Cmd(host, getRegistryServeProbeCommand(s.pathResolver, port))
if err != nil {
logger.Debug("failed to probe remote temporary registry on host %s: %v", host, err)
return "", false
}
fields := strings.Fields(strings.TrimSpace(string(output)))
if len(fields) == 0 {
return "", false
}
flags := registryServeFlags{portFlag: fields[0]}
switch flags.portFlag {
case "--port", "-p":
default:
return "", false
}
for _, field := range fields[1:] {
switch field {
case "--pid-file":
flags.pidFile = true
case "--disable-logging":
flags.disableLogging = true
}
}
return getRegistryServeCommand(s.pathResolver, port, flags), true
}
func getRegistryServeProbeCommand(pathResolver constants.PathResolver, port string) string {
return fmt.Sprintf("if %[1]s registry serve filesystem --port %[2]s --help >/dev/null 2>&1; then printf -- '--port'; elif %[1]s registry serve filesystem -p %[2]s --help >/dev/null 2>&1; then printf -- '-p'; else exit 0; fi; if %[1]s registry serve filesystem --pid-file %[3]s --help >/dev/null 2>&1; then printf ' --pid-file'; fi; if %[1]s registry serve filesystem --disable-logging=true --help >/dev/null 2>&1; then printf ' --disable-logging'; fi",
pathResolver.RootFSSealctlPath(), port, registryPIDFile(pathResolver),
)
}
func getRegistryServeCommand(pathResolver constants.PathResolver, port string, flags registryServeFlags) string {
args := []string{
pathResolver.RootFSSealctlPath(),
"registry", "serve", "filesystem",
flags.portFlag, port,
}
if flags.pidFile {
args = append(args, "--pid-file", registryPIDFile(pathResolver))
}
if flags.disableLogging {
args = append(args, "--disable-logging=true")
}
args = append(args, pathResolver.RootFSRegistryPath())
return strings.Join(args, " ") + " >/dev/null 2>&1"
}
func getRegistryServeCleanupCommand(pathResolver constants.PathResolver) string {
return fmt.Sprintf("if [ -s %[1]s ]; then pid=$(cat %[1]s 2>/dev/null || true); if [ -n \"$pid\" ] && [ -r \"/proc/$pid/cmdline\" ]; then cmd=$(tr '\\000' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null || true); case \"$cmd\" in *\"%[2]s registry serve filesystem\"*\"%[3]s\"*) kill \"$pid\" 2>/dev/null || true;; esac; fi; rm -f %[1]s; fi",
registryPIDFile(pathResolver),
pathResolver.RootFSSealctlPath(),
pathResolver.RootFSRegistryPath(),
)
}
func registryPIDFile(pathResolver constants.PathResolver) string {
return filepath.Join(pathResolver.RootFSPath(), registryPIDFileName)
}
func syncViaSSH(_ context.Context, s *impl, target string, localDir string) error {
return ssh.CopyDir(s.execer, target, localDir, s.pathResolver.RootFSRegistryPath(), nil)
}
@@ -0,0 +1,84 @@
/*
Copyright 2026 sealos.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package registry
import (
"strings"
"testing"
)
type testPathResolver struct{}
func (testPathResolver) Root() string { return "/var/lib/sealos/data/default" }
func (testPathResolver) RootFSPath() string { return "/var/lib/sealos/data/default/rootfs" }
func (testPathResolver) RootFSEtcPath() string { return "/var/lib/sealos/data/default/rootfs/etc" }
func (testPathResolver) RootFSStaticsPath() string {
return "/var/lib/sealos/data/default/rootfs/statics"
}
func (testPathResolver) RootFSScriptsPath() string {
return "/var/lib/sealos/data/default/rootfs/scripts"
}
func (testPathResolver) RootFSRegistryPath() string {
return "/var/lib/sealos/data/default/rootfs/registry"
}
func (testPathResolver) RootFSManifestsPath() string {
return "/var/lib/sealos/data/default/rootfs/manifests"
}
func (testPathResolver) RootFSBinPath() string { return "/var/lib/sealos/data/default/rootfs/bin" }
func (testPathResolver) RootFSSealctlPath() string {
return "/var/lib/sealos/data/default/rootfs/opt/sealctl"
}
func (testPathResolver) ConfigsPath() string { return "/var/lib/sealos/data/default/etc" }
func (testPathResolver) RunRoot() string { return "/var/lib/sealos/default" }
func (testPathResolver) PkiPath() string { return "/var/lib/sealos/default/pki" }
func (testPathResolver) PkiEtcdPath() string { return "/var/lib/sealos/default/pki/etcd" }
func (testPathResolver) AdminFile() string { return "/var/lib/sealos/default/admin.conf" }
func (testPathResolver) EtcPath() string { return "/var/lib/sealos/default/etc" }
func (testPathResolver) TmpPath() string { return "/var/lib/sealos/default/tmp" }
func TestGetRegistryServeCommandIncludesSupportedFlags(t *testing.T) {
got := getRegistryServeCommand(testPathResolver{}, "5050", registryServeFlags{
portFlag: "--port",
pidFile: true,
disableLogging: true,
})
want := "/var/lib/sealos/data/default/rootfs/opt/sealctl registry serve filesystem --port 5050 --pid-file /var/lib/sealos/data/default/rootfs/registry.pid --disable-logging=true /var/lib/sealos/data/default/rootfs/registry >/dev/null 2>&1"
if got != want {
t.Fatalf("unexpected serve command:\nwant: %s\n got: %s", want, got)
}
}
func TestGetRegistryServeCommandOmitsUnsupportedOptionalFlags(t *testing.T) {
got := getRegistryServeCommand(testPathResolver{}, "5050", registryServeFlags{portFlag: "-p"})
want := "/var/lib/sealos/data/default/rootfs/opt/sealctl registry serve filesystem -p 5050 /var/lib/sealos/data/default/rootfs/registry >/dev/null 2>&1"
if got != want {
t.Fatalf("unexpected serve command:\nwant: %s\n got: %s", want, got)
}
}
func TestGetRegistryServeCleanupCommandIsScopedToTemporaryRegistry(t *testing.T) {
got := getRegistryServeCleanupCommand(testPathResolver{})
for _, want := range []string{
"/var/lib/sealos/data/default/rootfs/registry.pid",
"/proc/$pid/cmdline",
"/var/lib/sealos/data/default/rootfs/opt/sealctl registry serve filesystem",
"/var/lib/sealos/data/default/rootfs/registry",
} {
if !strings.Contains(got, want) {
t.Fatalf("cleanup command %q does not contain %q", got, want)
}
}
}
@@ -21,7 +21,10 @@ import (
"errors"
"fmt"
"io/fs"
"os"
osexec "os/exec"
"path/filepath"
"runtime"
"strings"
"golang.org/x/sync/errgroup"
@@ -34,6 +37,7 @@ import (
v2 "github.com/labring/sealos/pkg/types/v1beta1"
executils "github.com/labring/sealos/pkg/utils/exec"
"github.com/labring/sealos/pkg/utils/file"
"github.com/labring/sealos/pkg/utils/hash"
"github.com/labring/sealos/pkg/utils/logger"
"github.com/labring/sealos/pkg/utils/maps"
stringsutil "github.com/labring/sealos/pkg/utils/strings"
@@ -113,6 +117,7 @@ func (f *defaultRootfs) mountRootfs(cluster *v2.Cluster, ipList []string) error
return errors.New("cannot mount a cluster without rootfs, this is an unexpected bug")
}
rootfsEnvs := v2.MergeEnvWithBuiltinKeys(rootfs.Env, *rootfs)
localSealctlPath := findLocalSealctl()
for idx := range ipList {
ip := ipList[idx]
@@ -133,6 +138,9 @@ func (f *defaultRootfs) mountRootfs(cluster *v2.Cluster, ipList []string) error
envs := envProcessor.Getenv(ip)
envs = maps.Merge(rootfsEnvs, envs)
envs[v2.ImageRunModeEnvSysKey] = strings.Join(cluster.GetRolesByIP(ip), ",")
if err := syncSealctlToRootfs(execer, ip, localSealctlPath, pathResolver.RootFSSealctlPath()); err != nil {
logger.Warn("failed to sync sealctl to %s: %v", ip, err)
}
renderCommand := getRenderCommand(pathResolver.RootFSSealctlPath(), target)
return execer.CmdAsync(ip, stringsutil.RenderShellWithEnv(renderCommand, envs))
@@ -152,6 +160,9 @@ func (f *defaultRootfs) mountRootfs(cluster *v2.Cluster, ipList []string) error
if err := copyFn(mountInfo, master0, targetDir); err != nil {
return err
}
if err := syncSealctlToRootfs(execer, master0, localSealctlPath, pathResolver.RootFSSealctlPath()); err != nil {
logger.Warn("failed to sync sealctl to %s: %v", master0, err)
}
renderCommand := getRenderCommand(pathResolver.RootFSSealctlPath(), targetDir)
return execer.CmdAsync(master0, stringsutil.RenderShellWithEnv(renderCommand, mountInfo.Env))
}
@@ -163,7 +174,7 @@ func (f *defaultRootfs) mountRootfs(cluster *v2.Cluster, ipList []string) error
func getRenderCommand(binary string, target string) string {
// skip if sealctl doesn't has subcommand render
return fmt.Sprintf("%s render --debug=%v --clear %s 2>/dev/null || true", binary,
return fmt.Sprintf("%s render --debug=%v --clear %s >/dev/null 2>&1 || true", binary,
logger.IsDebugMode(),
strings.Join([]string{
filepath.Join(target, constants.EtcDirName),
@@ -172,6 +183,98 @@ func getRenderCommand(binary string, target string) string {
}, " "))
}
func findLocalSealctl() string {
if runtime.GOOS != "linux" {
return ""
}
var candidates []string
if path := os.Getenv("SEALOS_SEALCTL_PATH"); path != "" {
candidates = append(candidates, path)
}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "sealctl"))
}
if path, err := osexec.LookPath("sealctl"); err == nil {
candidates = append(candidates, path)
}
candidates = append(candidates, "/tmp/sealctl")
for _, candidate := range candidates {
if isExecutableFile(candidate) {
return candidate
}
}
return ""
}
func isExecutableFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir() && info.Mode()&0111 != 0
}
func syncSealctlToRootfs(execer exec.Interface, host, localSealctlPath, remoteSealctlPath string) error {
if localSealctlPath == "" {
return nil
}
if !isRemoteArchCompatible(execer, host) {
return nil
}
if isRemoteSealctlCurrent(execer, host, localSealctlPath, remoteSealctlPath) {
return nil
}
tmpPath := remoteSealctlPath + ".new"
if err := execer.Copy(host, localSealctlPath, tmpPath); err != nil {
return err
}
return execer.CmdAsync(host, fmt.Sprintf("install -m 0755 %s %s && rm -f %s",
shellQuote(tmpPath),
shellQuote(remoteSealctlPath),
shellQuote(tmpPath),
))
}
func isRemoteSealctlCurrent(execer exec.Interface, host, localSealctlPath, remoteSealctlPath string) bool {
localDigest := hash.FileDigest(localSealctlPath)
if localDigest == "" {
return false
}
output, err := execer.Cmd(host, fmt.Sprintf("if [ -f %s ]; then sha256sum %s | cut -d' ' -f1; fi",
shellQuote(remoteSealctlPath),
shellQuote(remoteSealctlPath),
))
if err != nil {
logger.Debug("failed to get remote sealctl digest on host %s: %v", host, err)
return false
}
return strings.TrimSpace(string(output)) == localDigest
}
func isRemoteArchCompatible(execer exec.Interface, host string) bool {
output, err := execer.Cmd(host, "uname -m")
if err != nil {
logger.Debug("failed to detect remote arch on host %s: %v", host, err)
return false
}
return normalizeArch(strings.TrimSpace(string(output))) == runtime.GOARCH
}
func normalizeArch(arch string) string {
switch arch {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
default:
return arch
}
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func (f *defaultRootfs) unmountRootfs(cluster *v2.Cluster, ipList []string) error {
clusterRootfsDir := constants.NewPathResolver(cluster.Name).Root()
rmRootfs := fmt.Sprintf("rm -rf %s", clusterRootfsDir)
+16
View File
@@ -17,6 +17,7 @@ package helpers
import (
"fmt"
"path"
"strings"
"github.com/labring/image-cri-shim/pkg/types"
@@ -66,6 +67,21 @@ func GetRegistryInfo(execer exec.Interface, rootfs, defaultRegistry string) *v1b
return readConfig
}
// WaitRegistryReady waits until the registry endpoint is reachable from host.
func WaitRegistryReady(execer exec.Interface, host, domain, port string) error {
registryAddr := fmt.Sprintf("%s:%s", domain, port)
logger.Info("waiting for registry %s to be ready on %s", registryAddr, host)
cmd := fmt.Sprintf(`registry_host=%s; registry_port=%s; if ! command -v bash >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then exit 1; fi; i=0; while [ "$i" -lt 120 ]; do if timeout 1 bash -c '</dev/tcp/'"$registry_host"'/'"$registry_port" >/dev/null 2>&1; then exit 0; fi; i=$((i + 1)); sleep 1; done; exit 1`, shellQuote(domain), shellQuote(port))
if err := execer.CmdAsync(host, cmd); err != nil {
return fmt.Errorf("registry %s is not ready on %s: %w", registryAddr, host, err)
}
return nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
}
func GetImageCRIShimInfo(execer exec.Interface, config, defaultIP string) *types.Config {
out, _ := execer.Cmd(defaultIP, fmt.Sprintf("cat %s", config))
logger.Debug("image shim data info: %s", string(out))
+6 -1
View File
@@ -28,8 +28,13 @@ type Ruler interface {
SyncNodeIPVS(masters, nodes []string) error
}
type CertRenewOptions struct {
Targets []string
Groups []string
}
type CertManager interface {
Renew() error
Renew(opts CertRenewOptions) error
UpdateCertSANs(certSANs []string) error
}
+303 -38
View File
@@ -18,11 +18,17 @@ import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"
"golang.org/x/sync/errgroup"
"github.com/labring/sealos/pkg/cert"
"github.com/labring/sealos/pkg/runtime"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/util/json"
"github.com/labring/sealos/pkg/client-go/kubernetes"
"github.com/labring/sealos/pkg/utils/file"
@@ -32,13 +38,304 @@ import (
const (
AdminConf = "admin.conf"
SuperAdminConf = "super-admin.conf"
ControllerConf = "controller-manager.conf"
SchedulerConf = "scheduler.conf"
KubeletConf = "kubelet.conf"
)
func (k *KubeadmRuntime) Renew() error {
return errors.New("not implement")
func (k *KubeadmRuntime) localKubeVersion() string {
if version := k.getKubeVersion(); version != "" {
return version
}
return k.getKubeVersionFromImage()
}
func (k *KubeadmRuntime) Renew(opts runtime.CertRenewOptions) error {
normalizedTargets, renewAll, err := normalizeRenewTargets(opts.Targets)
if err != nil {
return err
}
if err = validateRenewGroups(normalizedTargets, renewAll, opts.Groups); err != nil {
return err
}
if renewAll {
return k.renewAllLocalCertMaterials(opts.Groups)
}
hostName, err := k.execHostname(k.getMaster0IPAndPort())
if err != nil {
return fmt.Errorf("get hostname failed %v", err)
}
localTargets := effectiveLocalKubeConfigRenewTargets(normalizedTargets, k.localKubeVersion())
if err = renewLocalKubeConfigFiles(k, hostName, localTargets, opts.Groups); err != nil {
return err
}
if shouldEnsureAdminClusterRoleBinding(k.localKubeVersion(), opts.Groups, false, localTargets) {
if err = k.ensureAdminClusterRoleBinding(); err != nil {
return err
}
}
if containsRenewTarget(normalizedTargets, AdminConf) {
if err := k.syncLocalAdminKubeConfigCopies(); err != nil {
return err
}
}
k.cli = nil
return nil
}
func containsRenewTarget(targets []string, want string) bool {
for _, target := range targets {
if target == want {
return true
}
}
return false
}
func effectiveLocalKubeConfigRenewTargets(targets []string, kubeVersion string) []string {
effectiveTargets := append([]string{}, targets...)
if containsRenewTarget(targets, AdminConf) && certUsesClusterAdminsIdentityModel(kubeVersion) && !containsRenewTarget(targets, SuperAdminConf) {
effectiveTargets = append(effectiveTargets, SuperAdminConf)
}
return effectiveTargets
}
func (k *KubeadmRuntime) renewAllLocalCertMaterials(adminOrganizations []string) error {
if err := k.mergeWithBuiltinKubeadmConfig(); err != nil {
if strings.TrimSpace(k.getServiceCIDR()) == "" {
return fmt.Errorf("load cluster networking and certSANs for local cert renew: %w", err)
}
logger.Warn("failed to refresh kubeadm config for local cert renew, using local config: %s", err.Error())
}
hostName, err := k.execHostname(k.getMaster0IPAndPort())
if err != nil {
return fmt.Errorf("get hostname failed %v", err)
}
if err := cert.RenewLeafCertsForKubeVersion(
k.pathResolver.PkiPath(),
k.pathResolver.PkiEtcdPath(),
k.getCertSANs(),
k.getMaster0IP(),
hostName,
k.getServiceCIDR(),
k.getDNSDomain(),
k.localKubeVersion(),
); err != nil {
return fmt.Errorf("failed to renew local pki files: %w", err)
}
localTargets := defaultLocalKubeConfigFiles(k.localKubeVersion())
if err := renewLocalKubeConfigFiles(k, hostName, localTargets, adminOrganizations); err != nil {
return err
}
if shouldEnsureAdminClusterRoleBinding(k.localKubeVersion(), adminOrganizations, true, localTargets) {
if err := k.ensureAdminClusterRoleBinding(); err != nil {
return err
}
}
if err := k.syncLocalAdminKubeConfigCopies(); err != nil {
return err
}
k.cli = nil
return nil
}
func renewLocalKubeConfigFiles(k *KubeadmRuntime, hostName string, files []string, adminOrganizations []string) error {
return renewLocalKubeConfigFilesForVersion(k, hostName, k.localKubeVersion(), files, adminOrganizations)
}
func renewLocalKubeConfigFilesForVersion(k *KubeadmRuntime, hostName, kubeVersion string, files []string, adminOrganizations []string) error {
certConfig := cert.Config{
Path: k.pathResolver.PkiPath(),
BaseName: "ca",
}
if err := cert.RenewKubeConfigFilesForKubeVersion(
k.pathResolver.EtcPath(),
certConfig,
hostName,
k.getClusterAPIServer(),
"kubernetes",
adminOrganizations,
kubeVersion,
files...,
); err != nil {
return fmt.Errorf("failed to renew local kubeconfig files %v: %w", files, err)
}
return nil
}
func defaultLocalKubeConfigFiles(kubeVersion string) []string {
files := []string{AdminConf, ControllerConf, SchedulerConf, KubeletConf}
if certUsesClusterAdminsIdentityModel(kubeVersion) {
files = append(files, SuperAdminConf)
}
return files
}
func remoteControlPlaneKubeConfigFiles(includeKubelet bool) []string {
// super-admin.conf is intentionally kept local to the sealos host.
files := []string{AdminConf, ControllerConf, SchedulerConf}
if includeKubelet {
files = append(files, KubeletConf)
}
return files
}
func certUsesClusterAdminsIdentityModel(kubeVersion string) bool {
return cert.UsesClusterAdminsIdentityModel(kubeVersion)
}
func shouldEnsureAdminClusterRoleBinding(kubeVersion string, adminOrganizations []string, renewAll bool, targets []string) bool {
if !certUsesClusterAdminsIdentityModel(kubeVersion) || adminOrganizations != nil {
return false
}
if renewAll {
return true
}
for _, target := range targets {
if target == AdminConf {
return true
}
}
return false
}
func (k *KubeadmRuntime) fetchMasterKubeConfigFile(name string) (string, error) {
localPath := filepath.Join(k.pathResolver.EtcPath(), name)
if err := os.MkdirAll(k.pathResolver.EtcPath(), 0o755); err != nil {
return "", fmt.Errorf("create local etc dir for %s: %w", name, err)
}
remotePath := path.Join(kubernetesEtc, name)
if err := k.sshFetch(k.getMaster0IPAndPort(), remotePath, localPath); err != nil {
return "", fmt.Errorf("fetch %s from master0: %w", remotePath, err)
}
return localPath, nil
}
// resolvePrivilegedKubeConfig returns a kubeconfig that still has cluster-admin
// privileges on the live cluster. Prefer master0 copies over sealos-managed local
// files, which may be signed by stale PKI from a previous partial upgrade.
func (k *KubeadmRuntime) resolvePrivilegedKubeConfig() (string, error) {
if kubeconfig, err := k.fetchMasterKubeConfigFile(SuperAdminConf); err == nil {
return kubeconfig, nil
}
if kubeconfig, err := k.fetchMasterKubeConfigFile(AdminConf); err == nil {
return kubeconfig, nil
}
localSuperAdmin := filepath.Join(k.pathResolver.EtcPath(), SuperAdminConf)
if file.IsExist(localSuperAdmin) {
return localSuperAdmin, nil
}
return k.pathResolver.AdminFile(), nil
}
func (k *KubeadmRuntime) resolveSuperAdminKubeConfig() (string, error) {
return k.resolvePrivilegedKubeConfig()
}
func isRetryableKubernetesClientError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no route to host") ||
strings.Contains(msg, "i/o timeout") ||
strings.Contains(msg, "TLS handshake timeout") ||
strings.Contains(msg, "EOF")
}
func (k *KubeadmRuntime) ensureAdminClusterRoleBinding() error {
kubeconfig, err := k.resolvePrivilegedKubeConfig()
if err != nil {
return err
}
apiserver := k.getClusterAPIServer()
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "kubeadm:cluster-admins",
},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "ClusterRole",
Name: "cluster-admin",
},
Subjects: []rbacv1.Subject{
{
Kind: rbacv1.GroupKind,
Name: "kubeadm:cluster-admins",
},
},
}
deadline := time.Now().Add(2 * time.Minute)
var lastErr error
for {
client, err := kubernetes.NewKubernetesClient(kubeconfig, apiserver)
if err != nil {
return fmt.Errorf("build kubernetes client from %s: %w", kubeconfig, err)
}
lastErr = kubernetes.NewKubeIdempotency(client.Kubernetes()).CreateOrUpdateClusterRoleBinding(clusterRoleBinding)
if lastErr == nil {
return nil
}
if !isRetryableKubernetesClientError(lastErr) || time.Now().After(deadline) {
return fmt.Errorf("ensure kubeadm:cluster-admins ClusterRoleBinding: %w", lastErr)
}
logger.Warn("api-server not ready while ensuring kubeadm:cluster-admins ClusterRoleBinding, retrying: %s", lastErr.Error())
time.Sleep(5 * time.Second)
}
}
func normalizeRenewTargets(targets []string) ([]string, bool, error) {
allowed := map[string]struct{}{
"all": {},
AdminConf: {},
SuperAdminConf: {},
ControllerConf: {},
SchedulerConf: {},
KubeletConf: {},
}
normalized := make([]string, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
for _, target := range targets {
t := strings.TrimSpace(target)
if t == "" {
continue
}
if _, ok := allowed[t]; !ok {
return nil, false, fmt.Errorf("unsupported renew target %q", t)
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
normalized = append(normalized, t)
}
if len(normalized) == 0 {
return nil, false, errors.New("at least one renew target must be specified")
}
if _, ok := seen["all"]; ok {
return normalized, true, nil
}
return normalized, false, nil
}
func validateRenewGroups(targets []string, renewAll bool, groups []string) error {
if groups == nil || renewAll {
return nil
}
for _, target := range targets {
if target == AdminConf {
return nil
}
}
return errors.New("renew groups can only be used with target admin.conf or all")
}
func (k *KubeadmRuntime) UpdateCertSANs(certSans []string) error {
@@ -133,7 +430,7 @@ func (k *KubeadmRuntime) InitCertsAndKubeConfigs() error {
if err := k.CreateKubeConfigFiles(); err != nil {
return fmt.Errorf("failed to generate kubernetes conf: %w", err)
}
return k.SendJoinMasterKubeConfigs(k.getMasterIPAndPortList()[:1], AdminConf, ControllerConf, SchedulerConf, KubeletConf)
return k.SendJoinMasterKubeConfigs(k.getMasterIPAndPortList()[:1], remoteControlPlaneKubeConfigFiles(true)...)
}
func (k *KubeadmRuntime) initCert() error {
@@ -167,38 +464,6 @@ func (k *KubeadmRuntime) showKubeadmCert() error {
}
func (k *KubeadmRuntime) deleteAPIServer() error {
podIDSh := fmt.Sprintf("crictl ps -a --name %s -o json", kubernetes.KubeAPIServer)
type crictlPS struct {
Containers []struct {
ID string `json:"id"`
PodSandboxID string `json:"podSandboxId"`
} `json:"containers"`
}
logger.Info("delete pod apiserver from crictl")
eg, _ := errgroup.WithContext(context.Background())
for _, master := range k.getMasterIPAndPortList() {
m := master
eg.Go(func() error {
podIDJson, err := k.sshCmdToString(m, podIDSh)
if err != nil {
return err
}
ps := &crictlPS{}
if err = json.Unmarshal([]byte(podIDJson), ps); err != nil {
return err
}
if len(ps.Containers) > 0 {
podID := ps.Containers[0].PodSandboxID[:13]
logger.Debug("found podID %s in %s", podID, m)
//crictl stopp
if err = k.sshCmdAsync(m, fmt.Sprintf("crictl --timeout=10s stopp %s", podID)); err != nil {
return err
}
//crictl rmp
return k.sshCmdAsync(m, fmt.Sprintf("crictl rmp %s", podID))
}
return errors.New("not found apiServer pod running")
})
}
return eg.Wait()
return k.deleteStaticPod(kubernetes.KubeAPIServer)
}
@@ -0,0 +1,169 @@
package kubernetes
import "testing"
func TestValidateRenewGroups(t *testing.T) {
tests := []struct {
name string
targets []string
renewAll bool
groups []string
expectErr bool
}{
{
name: "allow nil groups",
targets: []string{ControllerConf},
renewAll: false,
groups: nil,
},
{
name: "allow all target",
targets: []string{"all"},
renewAll: true,
groups: []string{"custom:group"},
},
{
name: "allow admin target",
targets: []string{AdminConf},
renewAll: false,
groups: []string{"custom:group"},
},
{
name: "reject non admin targets",
targets: []string{ControllerConf, SchedulerConf},
renewAll: false,
groups: []string{"custom:group"},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRenewGroups(tt.targets, tt.renewAll, tt.groups)
if (err != nil) != tt.expectErr {
t.Fatalf("validateRenewGroups() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}
func TestDefaultLocalKubeConfigFiles(t *testing.T) {
tests := []struct {
version string
want []string
}{
{
version: "v1.28.9",
want: []string{AdminConf, ControllerConf, SchedulerConf, KubeletConf},
},
{
version: "v1.29.0",
want: []string{AdminConf, ControllerConf, SchedulerConf, KubeletConf, SuperAdminConf},
},
}
for _, tt := range tests {
got := defaultLocalKubeConfigFiles(tt.version)
if len(got) != len(tt.want) {
t.Fatalf("defaultLocalKubeConfigFiles(%q) = %v, want %v", tt.version, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("defaultLocalKubeConfigFiles(%q) = %v, want %v", tt.version, got, tt.want)
}
}
}
}
func TestNormalizeRenewTargetsAllowsSuperAdmin(t *testing.T) {
targets, renewAll, err := normalizeRenewTargets([]string{SuperAdminConf})
if err != nil {
t.Fatalf("normalizeRenewTargets() error = %v", err)
}
if renewAll {
t.Fatal("expected super-admin target not to imply renewAll")
}
if len(targets) != 1 || targets[0] != SuperAdminConf {
t.Fatalf("normalizeRenewTargets() = %v, want [%s]", targets, SuperAdminConf)
}
}
func TestNormalizeRenewTargetsRejectsSystemCertificates(t *testing.T) {
if _, _, err := normalizeRenewTargets([]string{"apiserver-kubelet-client"}); err == nil {
t.Fatal("expected system certificate targets to be rejected")
}
}
func TestEffectiveLocalKubeConfigRenewTargets(t *testing.T) {
tests := []struct {
name string
targets []string
version string
want []string
}{
{
name: "pre v129 keeps admin target only",
targets: []string{AdminConf},
version: "v1.28.9",
want: []string{AdminConf},
},
{
name: "v129 adds super admin alongside admin",
targets: []string{AdminConf},
version: "v1.29.0",
want: []string{AdminConf, SuperAdminConf},
},
{
name: "v129 does not duplicate explicit super admin target",
targets: []string{AdminConf, SuperAdminConf},
version: "v1.29.0",
want: []string{AdminConf, SuperAdminConf},
},
}
for _, tt := range tests {
got := effectiveLocalKubeConfigRenewTargets(tt.targets, tt.version)
if len(got) != len(tt.want) {
t.Fatalf("effectiveLocalKubeConfigRenewTargets(%v, %q) = %v, want %v", tt.targets, tt.version, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("effectiveLocalKubeConfigRenewTargets(%v, %q) = %v, want %v", tt.targets, tt.version, got, tt.want)
}
}
}
}
func TestRemoteControlPlaneKubeConfigFilesExcludeSuperAdmin(t *testing.T) {
tests := []struct {
includeKubelet bool
want []string
}{
{
includeKubelet: false,
want: []string{AdminConf, ControllerConf, SchedulerConf},
},
{
includeKubelet: true,
want: []string{AdminConf, ControllerConf, SchedulerConf, KubeletConf},
},
}
for _, tt := range tests {
got := remoteControlPlaneKubeConfigFiles(tt.includeKubelet)
if len(got) != len(tt.want) {
t.Fatalf("remoteControlPlaneKubeConfigFiles(%v) = %v, want %v", tt.includeKubelet, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("remoteControlPlaneKubeConfigFiles(%v) = %v, want %v", tt.includeKubelet, got, tt.want)
}
}
}
}
func TestValidateRenewGroupsAllowsAll(t *testing.T) {
if err := validateRenewGroups([]string{"all"}, true, []string{"custom:group"}); err != nil {
t.Fatalf("validateRenewGroups(all) error = %v", err)
}
}
@@ -0,0 +1,91 @@
// Copyright © 2026 sealos.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package kubernetes
import (
"bytes"
"fmt"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/labring/sealos/pkg/runtime/kubernetes/types"
"github.com/labring/sealos/pkg/utils/yaml"
)
const (
defaultCertificateValidityPeriod = "876000h"
defaultCACertificateValidityPeriod = "876000h"
)
func shouldUseKubeadmV1beta4Features(version string) (bool, error) {
sver, err := semver.NewVersion(version)
if err != nil {
return false, fmt.Errorf("parse kubernetes version %q: %w", version, err)
}
return !sver.LessThan(V1310), nil
}
func marshalConfigsForVersion(version string, configs ...interface{}) ([]byte, error) {
enableV1beta4Features, err := shouldUseKubeadmV1beta4Features(version)
if err != nil {
return nil, err
}
docs := make([][]byte, 0, len(configs))
for _, cfg := range configs {
data, err := yaml.Marshal(cfg)
if err != nil {
return nil, err
}
if enableV1beta4Features {
data, err = appendKubeadmCertValidityPeriods(data)
if err != nil {
return nil, err
}
}
docs = append(docs, data)
}
return bytes.Join(docs, []byte("\n---\n")), nil
}
func appendKubeadmCertValidityPeriods(raw []byte) ([]byte, error) {
doc, err := yaml.UnmarshalToMap(raw)
if err != nil {
return nil, err
}
kind, _ := doc["kind"].(string)
apiVersion, _ := doc["apiVersion"].(string)
if kind != "ClusterConfiguration" || apiVersion != types.KubeadmV1beta4 {
return raw, nil
}
var additions []string
if _, ok := doc["certificateValidityPeriod"]; !ok {
additions = append(additions, fmt.Sprintf("certificateValidityPeriod: %s", defaultCertificateValidityPeriod))
}
if _, ok := doc["caCertificateValidityPeriod"]; !ok {
additions = append(additions, fmt.Sprintf("caCertificateValidityPeriod: %s", defaultCACertificateValidityPeriod))
}
if len(additions) == 0 {
return raw, nil
}
if !bytes.HasSuffix(raw, []byte("\n")) {
raw = append(raw, '\n')
}
return append(raw, []byte(strings.Join(additions, "\n")+"\n")...), nil
}
+4 -3
View File
@@ -57,7 +57,7 @@ func (k *KubeadmRuntime) GenerateCert() error {
hostName,
k.getServiceCIDR(),
k.getDNSDomain())
return cert.GenerateCert(
return cert.GenerateCertForKubeVersion(
k.pathResolver.PkiPath(),
k.pathResolver.PkiEtcdPath(),
k.getCertSANs(),
@@ -65,6 +65,7 @@ func (k *KubeadmRuntime) GenerateCert() error {
hostName,
k.getServiceCIDR(),
k.getDNSDomain(),
k.localKubeVersion(),
)
}
@@ -79,8 +80,8 @@ func (k *KubeadmRuntime) CreateKubeConfigFiles() error {
BaseName: "ca",
}
err = cert.CreateJoinControlPlaneKubeConfigFiles(k.pathResolver.EtcPath(),
certConfig, hostName, k.getClusterAPIServer(), "kubernetes")
err = cert.CreateJoinControlPlaneKubeConfigFilesForKubeVersion(k.pathResolver.EtcPath(),
certConfig, hostName, k.getClusterAPIServer(), "kubernetes", k.localKubeVersion())
if err != nil {
return fmt.Errorf("failed to generate kubeconfig: %v", err)
}
+4 -1
View File
@@ -47,6 +47,8 @@ var (
V1260 = semver.MustParse("v1.26.0")
V1270 = semver.MustParse("v1.27.0")
V1280 = semver.MustParse("v1.28.0")
V1290 = semver.MustParse("v1.29.0")
V1300 = semver.MustParse("v1.30.0")
V1310 = semver.MustParse("v1.31.0")
)
@@ -477,7 +479,8 @@ func (k *KubeadmRuntime) generateInitConfigs() ([]byte, error) {
if err != nil {
return nil, err
}
return yaml.MarshalConfigs(&conversion.InitConfiguration,
return marshalConfigsForVersion(k.kubeadmConfig.ClusterConfiguration.KubernetesVersion,
&conversion.InitConfiguration,
&conversion.ClusterConfiguration,
&conversion.KubeletConfiguration,
&conversion.KubeProxyConfiguration)
@@ -18,6 +18,9 @@ package kubernetes
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"golang.org/x/sync/errgroup"
@@ -45,3 +48,47 @@ func (k *KubeadmRuntime) copyKubeConfigFileToNodes(hosts ...string) error {
func (k *KubeadmRuntime) copyMasterKubeConfig(host string) error {
return k.sshCmdAsync(host, copyKubeAdminConfigCommand)
}
func (k *KubeadmRuntime) syncLocalAdminKubeConfigCopies() error {
hosts := append([]string{}, k.getMasterIPAndPortList()...)
hosts = append(hosts, k.getNodeIPAndPortList()...)
if len(hosts) == 0 {
return nil
}
return k.copyKubeConfigFileToNodes(hosts...)
}
func (k *KubeadmRuntime) deleteStaticPod(component string) error {
podIDSh := fmt.Sprintf("crictl ps -a --name %s -o json", component)
type crictlPS struct {
Containers []struct {
ID string `json:"id"`
PodSandboxID string `json:"podSandboxId"`
} `json:"containers"`
}
eg, _ := errgroup.WithContext(context.Background())
for _, master := range k.getMasterIPAndPortList() {
m := master
eg.Go(func() error {
podIDJSON, err := k.sshCmdToString(m, podIDSh)
if err != nil {
return err
}
ps := &crictlPS{}
if err = json.Unmarshal([]byte(podIDJSON), ps); err != nil {
return err
}
if len(ps.Containers) == 0 {
return errors.New("not found static pod running")
}
podID := ps.Containers[0].PodSandboxID[:13]
if err = k.sshCmdAsync(m, fmt.Sprintf("crictl --timeout=10s stopp %s", podID)); err != nil {
return err
}
return k.sshCmdAsync(m, fmt.Sprintf("crictl rmp %s", podID))
})
}
return eg.Wait()
}
@@ -0,0 +1,135 @@
package kubernetes
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
clientkubernetes "github.com/labring/sealos/pkg/client-go/kubernetes"
"github.com/labring/sealos/pkg/constants"
"github.com/labring/sealos/pkg/ssh"
v1beta1 "github.com/labring/sealos/pkg/types/v1beta1"
)
func TestSyncLocalAdminKubeConfigCopies(t *testing.T) {
rootDir := t.TempDir()
prevRuntimeRoot := constants.DefaultRuntimeRootDir
constants.DefaultRuntimeRootDir = rootDir
t.Cleanup(func() {
constants.DefaultRuntimeRootDir = prevRuntimeRoot
})
stub := &stubSSH{
cmdToStringResponses: map[string]string{
"master0|echo $HOME": "/root/master0",
"master1|echo $HOME": "/root/master1",
"node0|echo $HOME": "/home/node0",
},
}
rt := &KubeadmRuntime{
execer: stub,
cluster: testClusterWithNodes([]string{"master0", "master1"}, []string{"node0"}),
pathResolver: constants.NewPathResolver("test-cluster"),
}
if err := rt.syncLocalAdminKubeConfigCopies(); err != nil {
t.Fatalf("syncLocalAdminKubeConfigCopies() error = %v", err)
}
want := []string{
"master0|" + filepath.Join(rootDir, "test-cluster", "etc", "admin.conf") + "|/root/master0/.kube/config",
"master1|" + filepath.Join(rootDir, "test-cluster", "etc", "admin.conf") + "|/root/master1/.kube/config",
"node0|" + filepath.Join(rootDir, "test-cluster", "etc", "admin.conf") + "|/home/node0/.kube/config",
}
sort.Strings(want)
got := append([]string{}, stub.copyCalls...)
sort.Strings(got)
if len(stub.copyCalls) != len(want) {
t.Fatalf("syncLocalAdminKubeConfigCopies() copyCalls = %v, want %v", stub.copyCalls, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("syncLocalAdminKubeConfigCopies() copyCalls = %v, want %v", got, want)
}
}
}
func TestDeleteStaticPodMissingContainerError(t *testing.T) {
stub := &stubSSH{
cmdToStringResponses: map[string]string{
"master0|crictl ps -a --name kube-scheduler -o json": `{"containers":[]}`,
},
}
rt := &KubeadmRuntime{
execer: stub,
cluster: testCluster([]string{"master0"}),
}
err := rt.deleteStaticPod(clientkubernetes.KubeScheduler)
if err == nil {
t.Fatal("expected deleteStaticPod to fail when no container is returned")
}
if !strings.Contains(err.Error(), "not found static pod running") {
t.Fatalf("deleteStaticPod() error = %v, want missing static pod error", err)
}
}
type stubSSH struct {
cmdToStringResponses map[string]string
copyCalls []string
mu sync.Mutex
}
var _ ssh.Interface = (*stubSSH)(nil)
func (s *stubSSH) Copy(host, src, dst string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.copyCalls = append(s.copyCalls, host+"|"+src+"|"+dst)
return nil
}
func (s *stubSSH) Fetch(host, src, dst string) error { return nil }
func (s *stubSSH) CmdAsync(host string, cmds ...string) error { return nil }
func (s *stubSSH) CmdAsyncWithContext(ctx context.Context, host string, cmds ...string) error {
return nil
}
func (s *stubSSH) Cmd(host, cmd string) ([]byte, error) { return nil, nil }
func (s *stubSSH) CmdToString(host, cmd, spilt string) (string, error) {
if got, ok := s.cmdToStringResponses[host+"|"+cmd]; ok {
return got, nil
}
return "", nil
}
func (s *stubSSH) Ping(host string) error { return nil }
func testCluster(masters []string) *v1beta1.Cluster {
return testClusterWithNodes(masters, nil)
}
func testClusterWithNodes(masters, nodes []string) *v1beta1.Cluster {
host := v1beta1.Host{
Roles: []string{v1beta1.MASTER},
IPS: masters,
}
hosts := []v1beta1.Host{host}
if len(nodes) != 0 {
hosts = append(hosts, v1beta1.Host{
Roles: []string{v1beta1.NODE},
IPS: nodes,
})
}
return &v1beta1.Cluster{
Spec: v1beta1.ClusterSpec{
Hosts: hosts,
},
}
}
+6 -14
View File
@@ -56,6 +56,10 @@ func (k *KubeadmRuntime) imagePull(hostAndPort, version string) error {
if version == "" {
version = k.getKubeVersion()
}
registry := helpers.GetRegistryInfo(k.execer, k.pathResolver.RootFSPath(), k.cluster.GetRegistryIPAndPort())
if err := helpers.WaitRegistryReady(k.execer, hostAndPort, registry.Domain, registry.Port); err != nil {
return err
}
type Images struct {
Images []string `json:"images"`
}
@@ -69,7 +73,6 @@ func (k *KubeadmRuntime) imagePull(hostAndPort, version string) error {
return fmt.Errorf("unmarshal kubeadm images list failed, json: %s, error: %s", listJson, err.Error())
}
var newImageList []string
registry := helpers.GetRegistryInfo(k.execer, k.pathResolver.RootFSPath(), k.cluster.GetRegistryIPAndPort())
for _, image := range images.Images {
image = strings.TrimSpace(image)
if image == "" {
@@ -140,14 +143,8 @@ func (k *KubeadmRuntime) joinMasters(masters []string) error {
if err = k.copyStaticFiles(masters); err != nil {
return err
}
if err = k.SendJoinMasterKubeConfigs(masters, AdminConf, ControllerConf, SchedulerConf); err != nil {
return err
}
// TODO only needs send ca?
if err = k.sendNewCertAndKey(masters); err != nil {
return err
}
// setKubernetesToken uploads control-plane shared certs through kubeadm
// and embeds the matching certificateKey into the join config.
if err = k.setKubernetesToken(); err != nil {
return err
}
@@ -167,11 +164,6 @@ func (k *KubeadmRuntime) joinMasters(masters []string) error {
if err = k.imagePull(master, ""); err != nil {
return err
}
logger.Debug("start to generate cert for master %s", master)
err = k.execCert(master)
if err != nil {
return fmt.Errorf("failed to create cert for master %s: %v", master, err)
}
err = k.sshCmdAsync(master, joinCmd)
if err != nil {
+52 -11
View File
@@ -15,11 +15,14 @@
package kubernetes
import (
"context"
"errors"
"fmt"
"sync"
"github.com/Masterminds/semver/v3"
coreV1 "k8s.io/api/core/v1"
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/labring/sealos/pkg/client-go/kubernetes"
"github.com/labring/sealos/pkg/constants"
@@ -28,7 +31,6 @@ import (
"github.com/labring/sealos/pkg/ssh"
v2 "github.com/labring/sealos/pkg/types/v1beta1"
"github.com/labring/sealos/pkg/utils/logger"
"github.com/labring/sealos/pkg/utils/yaml"
)
type KubeadmRuntime struct {
@@ -79,7 +81,7 @@ func (k *KubeadmRuntime) GetRawConfig() ([]byte, error) {
conversion.KubeProxyConfiguration,
conversion.KubeletConfiguration,
}
data, err := yaml.MarshalConfigs(objects...)
data, err := marshalConfigsForVersion(k.kubeadmConfig.ClusterConfiguration.KubernetesVersion, objects...)
if err != nil {
return nil, err
}
@@ -181,16 +183,55 @@ func (k *KubeadmRuntime) Upgrade(version string) error {
return err
}
if v0.Equal(v1) {
logger.Info("skip upgrade because of same version")
return nil
}
if v0.GreaterThan(v1) {
return fmt.Errorf("cannot apply an older version %s than %s", version, currVersion)
}
if v0.Minor()+1 < v1.Minor() {
return fmt.Errorf("cannot be upgraded across more than one major releases, %s -> %s", currVersion, version)
needUpgrade, err := k.nodeVersionsNeedUpgrade(v1)
if err != nil {
logger.Warn("failed to check node kubelet versions: %s", err.Error())
logger.Info("skip upgrade because of same version")
return nil
}
if !needUpgrade {
logger.Info("skip upgrade because of same version")
return nil
}
logger.Info("continue upgrade because some node kubelet versions or readiness states are not aligned with %s", version)
} else {
if v0.GreaterThan(v1) {
return fmt.Errorf("cannot apply an older version %s than %s", version, currVersion)
}
if v0.Minor()+1 < v1.Minor() {
return fmt.Errorf("cannot be upgraded across more than one major releases, %s -> %s", currVersion, version)
}
}
return k.upgradeCluster(version)
}
func (k *KubeadmRuntime) nodeVersionsNeedUpgrade(targetVersion *semver.Version) (bool, error) {
client, err := k.getKubeInterface()
if err != nil {
return false, err
}
nodes, err := client.Kubernetes().CoreV1().Nodes().List(context.TODO(), metaV1.ListOptions{})
if err != nil {
return false, err
}
for _, node := range nodes.Items {
kubeletVersion := node.Status.NodeInfo.KubeletVersion
nodeVersion, err := semver.NewVersion(kubeletVersion)
if err != nil {
logger.Warn("failed to parse kubelet version %q of node %s: %s", kubeletVersion, node.Name, err.Error())
return true, nil
}
if !nodeVersion.Equal(targetVersion) {
logger.Info("node %s kubelet version %s does not match target %s", node.Name, kubeletVersion, targetVersion.Original())
return true, nil
}
for _, condition := range node.Status.Conditions {
if condition.Type == coreV1.NodeReady && condition.Status == coreV1.ConditionUnknown {
logger.Info("node %s ready condition is unknown, continue upgrade recovery", node.Name)
return true, nil
}
}
}
return false, nil
}
@@ -148,11 +148,19 @@ func (k *KubeadmRuntime) sshCopy(host, srcFilePath, dstFilePath string) error {
return k.execer.Copy(host, srcFilePath, dstFilePath)
}
func (k *KubeadmRuntime) sshFetch(host, srcFilePath, dstFilePath string) error {
return k.execer.Fetch(host, srcFilePath, dstFilePath)
}
func (k *KubeadmRuntime) clearKubeClient() {
k.cli = nil
}
func (k *KubeadmRuntime) getKubeInterface() (kubernetes.Client, error) {
if k.cli != nil {
return k.cli, nil
}
cli, err := kubernetes.NewKubernetesClient(k.pathResolver.AdminFile(), k.getMaster0IPAPIServer())
cli, err := kubernetes.NewKubernetesClient(k.pathResolver.AdminFile(), k.getClusterAPIServer())
if err != nil {
return nil, err
}
+609 -41
View File
@@ -17,7 +17,9 @@ package kubernetes
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -26,6 +28,7 @@ import (
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm"
"github.com/labring/sealos/pkg/cert"
"github.com/labring/sealos/pkg/runtime/decode"
"github.com/labring/sealos/pkg/runtime/kubernetes/types"
"github.com/labring/sealos/pkg/utils/logger"
@@ -33,8 +36,10 @@ import (
)
const (
upgradeApplyCmd = "kubeadm upgrade apply --certificate-renewal=false --config %s --yes"
upradeNodeCmd = "kubeadm upgrade node --certificate-renewal=false --skip-phases preflight"
upgradeApplyCmd = "kubeadm upgrade apply %s --yes --ignore-preflight-errors=SystemVerification,ControlPlaneNodesReady,CreateJob"
upgradeApplyCmdNoCertRenewal = "kubeadm upgrade apply %s --certificate-renewal=false --yes --ignore-preflight-errors=SystemVerification,ControlPlaneNodesReady,CreateJob"
upgradeNodeCmd = "kubeadm upgrade node --skip-phases preflight"
upgradeNodeCmdNoCertRenewal = "kubeadm upgrade node --certificate-renewal=false --skip-phases preflight"
//drainNodeCmd = "kubectl drain %s --ignore-daemonsets"
cordonNodeCmd = "kubectl cordon %s"
uncordonNodeCmd = "kubectl uncordon %s"
@@ -44,18 +49,34 @@ const (
installKubeadmCmd = "cp -rf %s/kubeadm /usr/bin"
installKubeletCmd = "cp -rf %s/kubelet /usr/bin"
installKubectlCmd = "cp -rf %s/kubectl /usr/bin"
renewCertCmd = "kubeadm certs renew %s"
writeKubeadmConfig = `cat > %s << EOF
%s
EOF`
kubeletConfigPath = "/var/lib/kubelet/config.yaml"
apiserverKubeletClientCertName = "apiserver-kubelet-client"
apiserverEtcdClientCertName = "apiserver-etcd-client"
etcdHealthcheckClientCertName = "etcd-healthcheck-client"
adminKubeConfigName = "admin.conf"
)
type remoteCertMigration struct {
name string
phaseName string
files []string
}
func (k *KubeadmRuntime) upgradeCluster(version string) error {
logger.Info("Change ClusterConfiguration up to newVersion if need.")
conversion, err := k.autoUpdateConfig(version)
conversion, hasLocalEtcd, err := k.autoUpdateConfig(version)
if err != nil {
return err
}
if err := k.runUpgradeMigrations(conversion, version, hasLocalEtcd); err != nil {
return err
}
//upgrade master0
logger.Info("start to upgrade master0")
err = k.upgradeMaster0(conversion, version)
@@ -71,12 +92,455 @@ func (k *KubeadmRuntime) upgradeCluster(version string) error {
upgradeNodes = append(upgradeNodes, node)
}
logger.Info("start to upgrade other control-planes and worker nodes")
return k.upgradeOtherNodes(upgradeNodes, version)
if err := k.upgradeOtherNodes(conversion, upgradeNodes, version); err != nil {
return err
}
return k.syncLocalCertificateIdentity(version)
}
func (k *KubeadmRuntime) runUpgradeMigrations(conversion *types.ConvertedKubeadmConfig, version string, hasLocalEtcd bool) error {
currentVersion := k.getKubeVersionFromImage()
migrations, err := getRemoteCertMigrations(currentVersion, version, hasLocalEtcd)
if err != nil {
return err
}
if len(migrations) == 0 {
return nil
}
configPath, err := k.stageUpgradeMigrationConfig(version, conversion)
if err != nil {
return err
}
for _, migration := range migrations {
if err := k.migrateRemoteCert(migration, configPath, version); err != nil {
return err
}
}
return nil
}
func getRemoteCertMigrations(currentVersion, targetVersion string, hasLocalEtcd bool) ([]remoteCertMigration, error) {
current, err := semver.NewVersion(currentVersion)
if err != nil {
return nil, fmt.Errorf("parse current kubernetes version %q: %w", currentVersion, err)
}
target, err := semver.NewVersion(targetVersion)
if err != nil {
return nil, fmt.Errorf("parse target kubernetes version %q: %w", targetVersion, err)
}
if !current.LessThan(V1290) || target.LessThan(V1290) {
return nil, nil
}
migrations := []remoteCertMigration{
{
name: apiserverKubeletClientCertName,
phaseName: apiserverKubeletClientCertName,
files: []string{
apiserverKubeletClientCertName + ".crt",
apiserverKubeletClientCertName + ".key",
},
},
}
if hasLocalEtcd {
migrations = append(migrations,
remoteCertMigration{
name: apiserverEtcdClientCertName,
phaseName: apiserverEtcdClientCertName,
files: []string{
apiserverEtcdClientCertName + ".crt",
apiserverEtcdClientCertName + ".key",
},
},
remoteCertMigration{
name: etcdHealthcheckClientCertName,
phaseName: etcdHealthcheckClientCertName,
files: []string{
"etcd/healthcheck-client.crt",
"etcd/healthcheck-client.key",
},
},
)
}
return migrations, nil
}
func shouldMigrateAPIServerKubeletClientCert(currentVersion, targetVersion string) (bool, error) {
migrations, err := getRemoteCertMigrations(currentVersion, targetVersion, false)
if err != nil {
return false, err
}
for _, migration := range migrations {
if migration.name == apiserverKubeletClientCertName {
return true, nil
}
}
return false, nil
}
func (k *KubeadmRuntime) migrateRemoteCert(migration remoteCertMigration, configPath, version string) error {
logger.Info("regenerate %s on master0 before upgrading to %s", migration.name, version)
var stageFile func(string) (string, error)
if shouldUseKubeadmV1beta4Features, err := shouldUseKubeadmV1beta4Features(version); err != nil {
return err
} else if shouldUseKubeadmV1beta4Features {
master0 := k.getMaster0IPAndPort()
kubeBinaryPath := k.pathResolver.RootFSBinPath()
if err := k.sshCmdAsyncSeq(
master0,
fmt.Sprintf(installKubeadmCmd, kubeBinaryPath),
buildRegenerateRemoteCertCmd(migration.phaseName, configPath, migration.files),
); err != nil {
return fmt.Errorf("regenerate %s on master0: %w", migration.name, err)
}
stageFile = k.stagePKIFileFromMaster0
} else {
stagingDir, err := k.regenerateRemoteCertWithLocalIdentityModel(migration, version)
if err != nil {
return err
}
stageFile = func(fileName string) (string, error) {
return stagedLocalPKIFile(stagingDir, fileName)
}
}
for _, fileName := range migration.files {
stagedFile, err := stageFile(fileName)
if err != nil {
return err
}
if err := k.sendFileToHosts(k.getMasterIPAndPortList(), stagedFile, path.Join(kubernetesEtcPKI, fileName)); err != nil {
return fmt.Errorf("sync %s to control-plane nodes: %w", migration.name, err)
}
}
return nil
}
func stagedLocalPKIFile(stagingDir, fileName string) (string, error) {
stagedFile := filepath.Join(stagingDir, filepath.FromSlash(fileName))
if _, err := os.Stat(stagedFile); err != nil {
return "", fmt.Errorf("local regenerated PKI file %s: %w", stagedFile, err)
}
return stagedFile, nil
}
func (k *KubeadmRuntime) regenerateRemoteCertWithLocalIdentityModel(migration remoteCertMigration, version string) (string, error) {
if err := k.mergeWithBuiltinKubeadmConfig(); err != nil {
return "", fmt.Errorf("load cluster networking and certSANs for %s migration: %w", migration.name, err)
}
stagingDir := filepath.Join(k.pathResolver.TmpPath(), "upgrade-migrations", "remote-cert-regeneration")
if err := os.RemoveAll(stagingDir); err != nil {
return "", fmt.Errorf("cleanup local cert staging dir %s: %w", stagingDir, err)
}
if err := os.MkdirAll(filepath.Join(stagingDir, "etcd"), 0o755); err != nil {
return "", fmt.Errorf("create local cert staging dir %s: %w", stagingDir, err)
}
for _, caFile := range []string{
"ca.crt", "ca.key",
"front-proxy-ca.crt", "front-proxy-ca.key",
"etcd/ca.crt", "etcd/ca.key",
"sa.key", "sa.pub",
} {
src := path.Join(kubernetesEtcPKI, caFile)
dst := filepath.Join(stagingDir, filepath.FromSlash(caFile))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", fmt.Errorf("create local dir for %s: %w", caFile, err)
}
if err := k.sshFetch(k.getMaster0IPAndPort(), src, dst); err != nil {
return "", fmt.Errorf("fetch %s from master0: %w", caFile, err)
}
}
hostName, err := k.execHostname(k.getMaster0IPAndPort())
if err != nil {
return "", fmt.Errorf("get master0 hostname for %s migration: %w", migration.name, err)
}
if err := cert.RenewLeafCertsForKubeVersion(
stagingDir,
filepath.Join(stagingDir, "etcd"),
k.getCertSANs(),
k.getMaster0IP(),
hostName,
k.getServiceCIDR(),
k.getDNSDomain(),
version,
); err != nil {
return "", fmt.Errorf("regenerate %s with local identity model: %w", migration.name, err)
}
return stagingDir, nil
}
func (k *KubeadmRuntime) stageUpgradeMigrationConfig(version string, conversion *types.ConvertedKubeadmConfig) (string, error) {
data, err := marshalConfigsForVersion(version, &conversion.InitConfiguration, &conversion.ClusterConfiguration)
if err != nil {
return "", fmt.Errorf("marshal kubeadm migration config for %s: %w", version, err)
}
stagingDir := path.Join(k.pathResolver.TmpPath(), "upgrade-migrations")
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return "", fmt.Errorf("create migration staging dir %s: %w", stagingDir, err)
}
localConfigPath := k.upgradeMigrationConfigLocalPath()
if err := os.WriteFile(localConfigPath, data, 0o600); err != nil {
return "", fmt.Errorf("write local migration config %s: %w", localConfigPath, err)
}
master0 := k.getMaster0IPAndPort()
remoteConfigPath := k.remoteUpgradeMigrationConfigPath()
if err := k.sshCmdAsync(master0, fmt.Sprintf("mkdir -p %s", remoteConfigDir)); err != nil {
return "", fmt.Errorf("create remote migration dir %s on master0: %w", remoteConfigDir, err)
}
if err := k.sshCopy(master0, localConfigPath, remoteConfigPath); err != nil {
return "", fmt.Errorf("copy migration config to master0: %w", err)
}
return remoteConfigPath, nil
}
const remoteConfigDir = "/tmp/sealos-upgrade-migrations"
func (k *KubeadmRuntime) upgradeMigrationConfigLocalPath() string {
return path.Join(k.pathResolver.TmpPath(), "upgrade-migrations", "kubeadm-migration.yaml")
}
func (k *KubeadmRuntime) remoteUpgradeMigrationConfigPath() string {
return path.Join(remoteConfigDir, "kubeadm-migration.yaml")
}
func (k *KubeadmRuntime) syncUpgradeMigrationConfig(hosts []string) error {
localConfigPath := k.upgradeMigrationConfigLocalPath()
remoteConfigPath := k.remoteUpgradeMigrationConfigPath()
for _, host := range hosts {
if err := k.sshCmdAsync(host, fmt.Sprintf("mkdir -p %s", remoteConfigDir)); err != nil {
return fmt.Errorf("create remote migration dir %s on %s: %w", remoteConfigDir, host, err)
}
if err := k.sshCopy(host, localConfigPath, remoteConfigPath); err != nil {
return fmt.Errorf("copy migration config to %s: %w", host, err)
}
}
return nil
}
func (k *KubeadmRuntime) stagePKIFileFromMaster0(fileName string) (string, error) {
stagingDir := path.Join(k.pathResolver.TmpPath(), "upgrade-migrations")
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return "", fmt.Errorf("create migration staging dir %s: %w", stagingDir, err)
}
stagedFile := path.Join(stagingDir, fileName)
if err := os.MkdirAll(path.Dir(stagedFile), 0o755); err != nil {
return "", fmt.Errorf("create local staging dir for %s: %w", fileName, err)
}
if err := k.sshFetch(k.getMaster0IPAndPort(), path.Join(kubernetesEtcPKI, fileName), stagedFile); err != nil {
return "", fmt.Errorf("fetch %s from master0: %w", fileName, err)
}
return stagedFile, nil
}
func buildRegenerateRemoteCertCmd(phaseName, configPath string, files []string) string {
var script strings.Builder
script.WriteString("set -e; ")
script.WriteString("backup_dir=$(mktemp -d /tmp/sealos-cert-migration-XXXXXX); ")
for _, file := range files {
remoteFile := path.Join(kubernetesEtcPKI, file)
backupDir := path.Dir(file)
if backupDir != "." {
script.WriteString(fmt.Sprintf("mkdir -p \"$backup_dir/%s\"; ", backupDir))
}
script.WriteString(fmt.Sprintf("if [ -f %s ]; then cp -f %s \"$backup_dir/%s\"; fi; ", remoteFile, remoteFile, file))
script.WriteString(fmt.Sprintf("rm -f %s; ", remoteFile))
}
script.WriteString(fmt.Sprintf("if ! kubeadm init phase certs %s --config %s; then ", phaseName, configPath))
for _, file := range files {
remoteFile := path.Join(kubernetesEtcPKI, file)
script.WriteString(fmt.Sprintf("if [ -f \"$backup_dir/%s\" ]; then mkdir -p %s; mv -f \"$backup_dir/%s\" %s; fi; ", file, path.Dir(remoteFile), file, remoteFile))
}
script.WriteString("exit 1; fi; ")
script.WriteString("rm -rf \"$backup_dir\"")
return fmt.Sprintf("bash -c '%s'", script.String())
}
func buildRegenerateRemoteAdminKubeConfigCmd(configPath string) string {
var script strings.Builder
script.WriteString("set -e; ")
script.WriteString("backup_dir=$(mktemp -d /tmp/sealos-kubeconfig-migration-XXXXXX); ")
script.WriteString(fmt.Sprintf("if [ -f /etc/kubernetes/%s ]; then cp -f /etc/kubernetes/%s \"$backup_dir/%s\"; fi; ", adminKubeConfigName, adminKubeConfigName, adminKubeConfigName))
script.WriteString(fmt.Sprintf("rm -f /etc/kubernetes/%s; ", adminKubeConfigName))
script.WriteString(fmt.Sprintf("if ! kubeadm init phase kubeconfig admin --config %s; then ", configPath))
script.WriteString(fmt.Sprintf("if [ -f \"$backup_dir/%s\" ]; then mv -f \"$backup_dir/%s\" /etc/kubernetes/%s; fi; ", adminKubeConfigName, adminKubeConfigName, adminKubeConfigName))
script.WriteString("exit 1; fi; ")
script.WriteString("rm -rf \"$backup_dir\"")
return fmt.Sprintf("bash -c '%s'", script.String())
}
func (k *KubeadmRuntime) syncLocalCertificateIdentity(version string) error {
if err := k.mergeWithBuiltinKubeadmConfig(); err != nil {
return fmt.Errorf("load cluster networking and certSANs for local identity sync: %w", err)
}
localKubeConfigFiles := defaultLocalKubeConfigFiles(version)
// Ensure the kubeadm:cluster-admins binding before local kubeconfig renewal.
// After renewal, local super-admin.conf may be signed by sealos-managed PKI and
// apiserver may still be settling after control-plane static pod upgrades.
if shouldEnsureAdminClusterRoleBinding(version, nil, true, localKubeConfigFiles) {
if err := k.ensureAdminClusterRoleBinding(); err != nil {
return err
}
k.clearKubeClient()
}
hostName, err := k.execHostname(k.getMaster0IPAndPort())
if err != nil {
return fmt.Errorf("get hostname failed while syncing local certificate identity: %w", err)
}
if err := cert.RenewLeafCertsForKubeVersion(
k.pathResolver.PkiPath(),
k.pathResolver.PkiEtcdPath(),
k.getCertSANs(),
k.getMaster0IP(),
hostName,
k.getServiceCIDR(),
k.getDNSDomain(),
version,
); err != nil {
return fmt.Errorf("refresh local pki identity model for %s: %w", version, err)
}
if err := renewLocalKubeConfigFilesForVersion(k, hostName, version, localKubeConfigFiles, nil); err != nil {
return err
}
k.clearKubeClient()
if shouldRegenerateRemoteAdminKubeConfig(version) {
if err := k.syncRemoteAdminKubeConfigIdentity(version); err != nil {
return err
}
}
k.cli = nil
return nil
}
func shouldRegenerateRemoteAdminKubeConfig(version string) bool {
return certUsesClusterAdminsIdentityModel(version)
}
func (k *KubeadmRuntime) syncRemoteAdminKubeConfigIdentity(version string) error {
useV1beta4Features, err := shouldUseKubeadmV1beta4Features(version)
if err != nil {
return err
}
if useV1beta4Features {
if k.kubeadmConfig == nil {
return fmt.Errorf("kubeadm config is not initialized for remote admin.conf sync")
}
kubeadmConfig := *k.kubeadmConfig
kubeadmConfig.SetAPIVersion(getterKubeadmAPIVersion(version))
conversion, err := kubeadmConfig.ToConvertedKubeadmConfig()
if err != nil {
return fmt.Errorf("convert kubeadm config for remote admin.conf sync: %w", err)
}
configPath, err := k.stageUpgradeMigrationConfig(version, conversion)
if err != nil {
return err
}
if err := k.syncUpgradeMigrationConfig(k.getMasterIPAndPortList()[1:]); err != nil {
return err
}
kubeBinaryPath := k.pathResolver.RootFSBinPath()
for _, master := range k.getMasterIPAndPortList() {
if err := k.sshCmdAsyncSeq(
master,
fmt.Sprintf(installKubeadmCmd, kubeBinaryPath),
buildRegenerateRemoteAdminKubeConfigCmd(configPath),
); err != nil {
return fmt.Errorf("regenerate admin.conf on %s: %w", master, err)
}
if err := k.copyMasterKubeConfig(master); err != nil {
return fmt.Errorf("refresh $HOME/.kube/config on %s: %w", master, err)
}
}
return nil
}
return k.syncRemoteAdminKubeConfigWithLocalIdentityModel(version)
}
func (k *KubeadmRuntime) syncRemoteAdminKubeConfigWithLocalIdentityModel(version string) error {
// certSANs and networking were loaded earlier in syncLocalCertificateIdentity.
stagingDir := filepath.Join(k.pathResolver.TmpPath(), "upgrade-migrations", "remote-admin-kubeconfig")
if err := os.RemoveAll(stagingDir); err != nil {
return fmt.Errorf("cleanup local kubeconfig staging dir %s: %w", stagingDir, err)
}
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return fmt.Errorf("create local kubeconfig staging dir %s: %w", stagingDir, err)
}
for _, caFile := range []string{"ca.crt", "ca.key"} {
src := path.Join(kubernetesEtcPKI, caFile)
dst := filepath.Join(stagingDir, caFile)
if err := k.sshFetch(k.getMaster0IPAndPort(), src, dst); err != nil {
return fmt.Errorf("fetch %s from master0: %w", caFile, err)
}
}
for _, master := range k.getMasterIPAndPortList() {
hostName, err := k.execHostname(master)
if err != nil {
return fmt.Errorf("get hostname for %s admin.conf migration: %w", master, err)
}
if err := cert.RenewAdminKubeConfigFileForKubeVersion(
stagingDir,
cert.Config{Path: stagingDir, BaseName: "ca"},
k.getClusterAPIServer(),
"kubernetes",
nil,
version,
); err != nil {
return fmt.Errorf("regenerate local admin.conf staging for %s (%s): %w", master, hostName, err)
}
if err := k.sshCopy(master, filepath.Join(stagingDir, adminKubeConfigName), path.Join("/etc/kubernetes", adminKubeConfigName)); err != nil {
return fmt.Errorf("copy admin.conf to %s: %w", master, err)
}
if err := k.copyMasterKubeConfig(master); err != nil {
return fmt.Errorf("refresh $HOME/.kube/config on %s: %w", master, err)
}
}
return nil
}
func getUpgradeApplyCmd(version string) (string, error) {
useV1beta4Features, err := shouldUseKubeadmV1beta4Features(version)
if err != nil {
return "", err
}
if useV1beta4Features {
return fmt.Sprintf(upgradeApplyCmd, version), nil
}
return fmt.Sprintf(upgradeApplyCmdNoCertRenewal, version), nil
}
func getUpgradeNodeCmd(version string) (string, error) {
useV1beta4Features, err := shouldUseKubeadmV1beta4Features(version)
if err != nil {
return "", err
}
if useV1beta4Features {
return upgradeNodeCmd, nil
}
return upgradeNodeCmdNoCertRenewal, nil
}
func (k *KubeadmRuntime) upgradeMaster0(conversion *types.ConvertedKubeadmConfig, version string) error {
master0ip := k.getMaster0IP()
sver := semver.MustParse(version)
if err := k.syncKubeletConfig(master0ip, conversion, version); err != nil {
return err
}
if gte(sver, V1260) {
if err := k.changeCRIVersion(master0ip); err != nil {
return err
@@ -101,28 +565,31 @@ func (k *KubeadmRuntime) upgradeMaster0(conversion *types.ConvertedKubeadmConfig
return err
}
// install kubeadm:{version} at master0 before listing/pulling upgrade images.
// kubeadm owns the component image matrix, so using the target kubeadm avoids
// pre-pulling images for the previous Kubernetes minor version.
if err = k.sshCmdAsyncSeq(master0ip, fmt.Sprintf(installKubeadmCmd, kubeBinaryPath)); err != nil {
return err
}
// force cri to pull the image
err = k.imagePull(master0ip, version)
if err != nil {
logger.Warn("image pull pre-upgrade failed: %s", err.Error())
}
config, err := yaml.MarshalConfigs(&conversion.InitConfiguration, &conversion.ClusterConfiguration)
upgradeApplyCmd, err := getUpgradeApplyCmd(version)
if err != nil {
logger.Error("kubeadm config marshal failed: %s", err.Error())
return err
}
upgradeConfigName := "kubeadm-upgrade.yaml"
upgradeConfigPath := path.Join(k.pathResolver.EtcPath(), upgradeConfigName)
err = k.sshCmdAsync(master0ip,
//install kubeadm:{version} at master0
fmt.Sprintf(installKubeadmCmd, kubeBinaryPath),
// write kubeadm config to file
fmt.Sprintf(writeKubeadmConfig, upgradeConfigPath, string(config)),
//execute kubeadm upgrade apply {version} at master0
fmt.Sprintf(upgradeApplyCmd, upgradeConfigPath),
err = k.sshCmdAsyncSeq(master0ip,
// execute kubeadm upgrade apply {version} at master0.
// The desired kubeadm configuration has already been persisted to the
// cluster ConfigMap by autoUpdateConfig. Passing InitConfiguration and
// ClusterConfiguration through --config is rejected by newer kubeadm,
// which expects an UpgradeConfiguration for that flag.
upgradeApplyCmd,
//kubectl cordon <node-to-cordon>
fmt.Sprintf(cordonNodeCmd, master0Name),
//install kubelet:{version},kubectl{version} at master0
@@ -138,9 +605,17 @@ func (k *KubeadmRuntime) upgradeMaster0(conversion *types.ConvertedKubeadmConfig
return k.tryUncordonNode(master0ip, master0Name)
}
func (k *KubeadmRuntime) upgradeOtherNodes(ips []string, version string) error {
func (k *KubeadmRuntime) upgradeOtherNodes(conversion *types.ConvertedKubeadmConfig, ips []string, version string) error {
sver := semver.MustParse(version)
upgradeNodeCmd, err := getUpgradeNodeCmd(version)
if err != nil {
return err
}
for _, ip := range ips {
if err := k.syncKubeletConfig(ip, conversion, version); err != nil {
return err
}
if gte(sver, V1260) {
if err := k.changeCRIVersion(ip); err != nil {
return err
@@ -165,6 +640,12 @@ func (k *KubeadmRuntime) upgradeOtherNodes(ips []string, version string) error {
return err
}
// install kubeadm:{version} at the node before listing/pulling upgrade images.
err = k.sshCmdAsyncSeq(ip, fmt.Sprintf(installKubeadmCmd, kubeBinaryPath))
if err != nil {
return err
}
// force cri to pull the image
err = k.imagePull(ip, version)
if err != nil {
@@ -172,17 +653,18 @@ func (k *KubeadmRuntime) upgradeOtherNodes(ips []string, version string) error {
}
logger.Info("upgrade node %s", nodename)
err = k.sshCmdAsync(ip,
//install kubeadm:{version} at the node
fmt.Sprintf(installKubeadmCmd, kubeBinaryPath),
//upgrade other control-plane and nodes
upradeNodeCmd,
//kubectl cordon <node-to-cordon>
// upgrade other control-plane and nodes
err = k.tryUpgradeNode(ip, upgradeNodeCmd)
if err != nil {
return err
}
err = k.sshCmdAsyncSeq(ip,
// kubectl cordon <node-to-cordon>
fmt.Sprintf(cordonNodeCmd, nodename),
//install kubelet:{version},kubectl{version} at the node
// install kubelet:{version},kubectl{version} at the node
fmt.Sprintf(installKubectlCmd, kubeBinaryPath),
fmt.Sprintf(installKubeletCmd, kubeBinaryPath),
//reload kubelet daemon
// reload kubelet daemon
daemonReload,
restartKubelet,
)
@@ -196,19 +678,29 @@ func (k *KubeadmRuntime) upgradeOtherNodes(ips []string, version string) error {
return nil
}
func (k *KubeadmRuntime) autoUpdateConfig(version string) (*types.ConvertedKubeadmConfig, error) {
func (k *KubeadmRuntime) syncKubeletConfig(ip string, conversion *types.ConvertedKubeadmConfig, version string) error {
kubeletConfig, err := marshalKubeletConfigForVersion(conversion.KubeletConfiguration, version)
if err != nil {
logger.Error("failed to encode KubeletConfiguration: %s", err)
return err
}
logger.Info("sync kubelet config to node %s", ip)
return k.sshCmdAsync(ip, fmt.Sprintf(writeKubeadmConfig, kubeletConfigPath, string(kubeletConfig)))
}
func (k *KubeadmRuntime) autoUpdateConfig(version string) (*types.ConvertedKubeadmConfig, bool, error) {
exp, err := k.getKubeExpansion()
if err != nil {
return nil, err
return nil, false, err
}
ctx := context.Background()
clusterCfg, err := exp.FetchKubeadmConfig(ctx)
if err != nil {
return nil, err
return nil, false, err
}
kubeletCfg, err := exp.FetchKubeletConfig(ctx)
if err != nil {
return nil, err
return nil, false, err
}
logger.Debug("get cluster configmap data:\n%s", clusterCfg)
logger.Debug("get kubelet configmap data:\n%s", kubeletCfg)
@@ -216,8 +708,9 @@ func (k *KubeadmRuntime) autoUpdateConfig(version string) (*types.ConvertedKubea
defaultKubeadmConfig, err := types.LoadKubeadmConfigs(allConfig, false, decode.CRDFromString)
if err != nil {
logger.Error("failed to decode cluster kubeadm config: %s", err)
return nil, err
return nil, false, err
}
hasLocalEtcd := usesLocalEtcd(defaultKubeadmConfig.ClusterConfiguration.Etcd)
defaultKubeadmConfig.InitConfiguration = kubeadm.InitConfiguration{
TypeMeta: metaV1.TypeMeta{
APIVersion: defaultKubeadmConfig.ClusterConfiguration.APIVersion,
@@ -228,38 +721,94 @@ func (k *KubeadmRuntime) autoUpdateConfig(version string) (*types.ConvertedKubea
kubeadmConfig: defaultKubeadmConfig,
}
kk.setKubeVersion(version)
kk.setAPIVersion(getterKubeadmAPIVersion(version))
kk.setFeatureGatesConfiguration()
kk.setInitConfigurationPullPolicy(v1.PullNever)
conversion, err := kk.kubeadmConfig.ToConvertedKubeadmConfig()
if err != nil {
return nil, err
return nil, false, err
}
newClusterData, err := yaml.MarshalConfigs(&conversion.ClusterConfiguration)
newClusterData, err := marshalConfigsForVersion(version, &conversion.ClusterConfiguration)
if err != nil {
logger.Error("failed to encode ClusterConfiguration: %s", err)
return nil, err
return nil, false, err
}
logger.Debug("update cluster config:\n%s", string(newClusterData))
err = exp.UpdateKubeadmConfig(ctx, string(newClusterData))
if err != nil {
logger.Error("failed to update kubeadm-config with k8s-client: %s", err)
return nil, err
return nil, false, err
}
newKubeletData, err := yaml.MarshalConfigs(&conversion.KubeletConfiguration)
newKubeletData, err := marshalKubeletConfigForVersion(conversion.KubeletConfiguration, version)
if err != nil {
logger.Error("failed to encode KubeletConfiguration: %s", err)
return nil, err
return nil, false, err
}
logger.Debug("update kubelet config:\n%s", string(newKubeletData))
err = exp.UpdateKubeletConfig(ctx, string(newKubeletData))
if err != nil {
logger.Error("failed to update kubelet-config with k8s-client: %s", err)
return nil, err
return nil, false, err
}
return conversion, nil
return conversion, hasLocalEtcd, nil
}
func usesLocalEtcd(etcd kubeadm.Etcd) bool {
return etcd.External == nil || len(etcd.External.Endpoints) == 0
}
func marshalKubeletConfigForVersion(config interface{}, version string) ([]byte, error) {
kubeletConfig, err := yaml.MarshalConfigs(config)
if err != nil {
return nil, err
}
return sanitizeKubeletConfigForVersion(kubeletConfig, version)
}
func sanitizeKubeletConfigForVersion(kubeletConfig []byte, version string) ([]byte, error) {
sver, err := semver.NewVersion(version)
if err != nil {
return nil, err
}
if gte(sver, V1300) {
return kubeletConfig, nil
}
config, err := yaml.UnmarshalToMap(kubeletConfig)
if err != nil {
return nil, err
}
delete(config, "containerRuntimeEndpoint")
delete(config, "imageMaximumGCAge")
deleteNestedMapKey(config, "logging", "options", "text")
return yaml.Marshal(config)
}
func deleteNestedMapKey(data map[string]interface{}, keys ...string) {
if len(keys) == 0 {
return
}
current := data
for _, key := range keys[:len(keys)-1] {
next, ok := current[key].(map[string]interface{})
if !ok {
return
}
current = next
}
delete(current, keys[len(keys)-1])
}
func (k *KubeadmRuntime) sshCmdAsyncSeq(host string, cmds ...string) error {
for _, cmd := range cmds {
if err := k.sshCmdAsync(host, cmd); err != nil {
return err
}
}
return nil
}
func (k *KubeadmRuntime) pingAPIServer() error {
@@ -297,8 +846,27 @@ func (k *KubeadmRuntime) tryUncordonNode(ip, nodename string) error {
return nil
}
func (k *KubeadmRuntime) tryUpgradeNode(ip, upgradeCmd string) error {
err := k.sshCmdAsync(ip, upgradeCmd)
timeout := time.Now().Add(1 * time.Minute)
for err != nil {
time.Sleep(5 * time.Second)
if pingErr := k.pingAPIServer(); pingErr != nil {
logger.Warn("api-server is not ready before retrying node upgrade: %s", pingErr.Error())
}
err = k.sshCmdAsync(ip, upgradeCmd)
if err == nil {
break
}
if time.Now().After(timeout) {
return fmt.Errorf("try upgrade node %s timeout one minute: %w", ip, err)
}
}
return nil
}
func (k *KubeadmRuntime) changeCRIVersion(ip string) error {
return k.sshCmdAsync(ip,
return k.sshCmdAsyncSeq(ip,
"sed -i \"s/v1alpha2/v1/\" /etc/image-cri-shim.yaml",
"systemctl restart image-cri-shim",
"systemctl restart kubelet",
@@ -306,8 +874,8 @@ func (k *KubeadmRuntime) changeCRIVersion(ip string) error {
}
func (k *KubeadmRuntime) changeKubeletExtraArgs(ip string) error {
return k.sshCmdAsync(ip,
`FILE="/etc/systemd/system/kubelet.service.d/10-kubeadm.conf" && [ -f "$FILE" ] && sed -i 's/\(--container-runtime=\|--pod-infra-container-image=\)\([^ ]*\)\?//g' "$FILE"`,
return k.sshCmdAsyncSeq(ip,
`for FILE in /etc/systemd/system/kubelet.service.d/10-kubeadm.conf /var/lib/kubelet/kubelet-flags.env /var/lib/kubelet/kubeadm-flags.env; do if [ -f "$FILE" ]; then sed -i -E 's#(^|[[:space:]"])-endpoint=#\1--container-runtime-endpoint=#g; s#(^|[[:space:]"])--container-runtime(=[^[:space:]"]*)?([[:space:]"]|$)#\1\3#g; s#(^|[[:space:]"])--pod-infra-container-image(=[^[:space:]"]*)?([[:space:]"]|$)#\1\3#g' "$FILE"; fi; done`,
"systemctl daemon-reload",
"systemctl restart kubelet",
)
@@ -0,0 +1,675 @@
// Copyright © 2022 sealos.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package kubernetes
import (
"bytes"
"os"
"path/filepath"
"sort"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
kubernetesclient "k8s.io/client-go/kubernetes"
clientset "k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/rest"
"k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm"
ckubeadm "k8s.io/kubernetes/cmd/kubeadm/app/constants"
clientkubernetes "github.com/labring/sealos/pkg/client-go/kubernetes"
"github.com/labring/sealos/pkg/constants"
"github.com/labring/sealos/pkg/runtime/kubernetes/types"
v1beta1 "github.com/labring/sealos/pkg/types/v1beta1"
)
func TestSanitizeKubeletConfigForPre130(t *testing.T) {
kubeletConfig := []byte(`apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerRuntimeEndpoint: unix:///run/containerd/containerd.sock
imageMaximumGCAge: 0s
logging:
format: text
options:
json:
infoBufferSize: "0"
text:
infoBufferSize: "0"
`)
got, err := sanitizeKubeletConfigForVersion(kubeletConfig, "v1.27.1")
if err != nil {
t.Fatalf("sanitize kubelet config: %v", err)
}
for _, field := range []string{"containerRuntimeEndpoint", "imageMaximumGCAge"} {
if strings.Contains(string(got), field) {
t.Fatalf("expected %q to be removed from kubelet config:\n%s", field, got)
}
}
if strings.Contains(string(got), "\n text:") {
t.Fatalf("expected logging.options.text to be removed from kubelet config:\n%s", got)
}
if !strings.Contains(string(got), "format: text") {
t.Fatalf("expected logging.format to be preserved:\n%s", got)
}
if !strings.Contains(string(got), "json:") {
t.Fatalf("expected logging.options.json to be preserved:\n%s", got)
}
}
func TestSanitizeKubeletConfigFor130KeepsFields(t *testing.T) {
kubeletConfig := []byte(`apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerRuntimeEndpoint: unix:///run/containerd/containerd.sock
imageMaximumGCAge: 0s
logging:
options:
text:
infoBufferSize: "0"
`)
got, err := sanitizeKubeletConfigForVersion(kubeletConfig, "v1.30.0")
if err != nil {
t.Fatalf("sanitize kubelet config: %v", err)
}
if !bytes.Equal(got, kubeletConfig) {
t.Fatalf("expected kubelet config for v1.30.0 to be unchanged:\n%s", got)
}
}
func TestUpgradeApplyCommandIgnoresHealthCheckJob(t *testing.T) {
got, err := getUpgradeApplyCmd("v1.30.9")
if err != nil {
t.Fatalf("getUpgradeApplyCmd() error = %v", err)
}
for _, want := range []string{
"--ignore-preflight-errors=",
"SystemVerification",
"ControlPlaneNodesReady",
"CreateJob",
} {
if !strings.Contains(got, want) {
t.Fatalf("upgrade apply command %q does not contain %q", got, want)
}
}
}
func TestGetUpgradeCommandsFollowCertificateRenewalPolicy(t *testing.T) {
tests := []struct {
name string
version string
wantApplyDisableRenew bool
wantNodeDisableRenew bool
}{
{
name: "keep disable-renewal before v131",
version: "v1.30.9",
wantApplyDisableRenew: true,
wantNodeDisableRenew: true,
},
{
name: "enable kubeadm renewal on v131 and later",
version: "v1.31.0",
wantApplyDisableRenew: false,
wantNodeDisableRenew: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
applyCmd, err := getUpgradeApplyCmd(tt.version)
if err != nil {
t.Fatalf("getUpgradeApplyCmd() error = %v", err)
}
if got := strings.Contains(applyCmd, "--certificate-renewal=false"); got != tt.wantApplyDisableRenew {
t.Fatalf("getUpgradeApplyCmd() disable-renewal = %v, want %v, cmd = %q", got, tt.wantApplyDisableRenew, applyCmd)
}
nodeCmd, err := getUpgradeNodeCmd(tt.version)
if err != nil {
t.Fatalf("getUpgradeNodeCmd() error = %v", err)
}
if got := strings.Contains(nodeCmd, "--certificate-renewal=false"); got != tt.wantNodeDisableRenew {
t.Fatalf("getUpgradeNodeCmd() disable-renewal = %v, want %v, cmd = %q", got, tt.wantNodeDisableRenew, nodeCmd)
}
})
}
}
func TestGetterKubeadmAPIVersion(t *testing.T) {
tests := []struct {
version string
want string
}{
{version: "v1.30.9", want: types.KubeadmV1beta3},
{version: "v1.31.0", want: types.KubeadmV1beta4},
}
for _, tt := range tests {
if got := getterKubeadmAPIVersion(tt.version); got != tt.want {
t.Fatalf("getterKubeadmAPIVersion(%q) = %q, want %q", tt.version, got, tt.want)
}
}
}
func TestMarshalConfigsForVersionAddsCertValidityPeriodsForV131(t *testing.T) {
clusterConfig := map[string]interface{}{
"apiVersion": types.KubeadmV1beta4,
"kind": "ClusterConfiguration",
}
got, err := marshalConfigsForVersion("v1.31.0", clusterConfig)
if err != nil {
t.Fatalf("marshalConfigsForVersion() error = %v", err)
}
for _, want := range []string{
"certificateValidityPeriod: 876000h",
"caCertificateValidityPeriod: 876000h",
} {
if !strings.Contains(string(got), want) {
t.Fatalf("marshalConfigsForVersion() output %q does not contain %q", got, want)
}
}
}
func TestMarshalConfigsForVersionSkipsCertValidityPeriodsBeforeV131(t *testing.T) {
clusterConfig := map[string]interface{}{
"apiVersion": types.KubeadmV1beta3,
"kind": "ClusterConfiguration",
}
got, err := marshalConfigsForVersion("v1.30.9", clusterConfig)
if err != nil {
t.Fatalf("marshalConfigsForVersion() error = %v", err)
}
if strings.Contains(string(got), "certificateValidityPeriod:") {
t.Fatalf("expected pre-v1.31 config to skip certificate validity periods, got %q", got)
}
}
func TestShouldMigrateAPIServerKubeletClientCert(t *testing.T) {
tests := []struct {
name string
currentVersion string
targetVersion string
want bool
}{
{
name: "migrate across 129 boundary",
currentVersion: "v1.28.9",
targetVersion: "v1.29.0",
want: true,
},
{
name: "skip same minor after 129",
currentVersion: "v1.29.1",
targetVersion: "v1.29.2",
want: false,
},
{
name: "skip below 129",
currentVersion: "v1.28.1",
targetVersion: "v1.28.9",
want: false,
},
{
name: "migrate when upgrading past 129",
currentVersion: "v1.28.10",
targetVersion: "v1.30.1",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := shouldMigrateAPIServerKubeletClientCert(tt.currentVersion, tt.targetVersion)
if err != nil {
t.Fatalf("shouldMigrateAPIServerKubeletClientCert() error = %v", err)
}
if got != tt.want {
t.Fatalf("shouldMigrateAPIServerKubeletClientCert() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldMigrateAPIServerKubeletClientCertRejectsInvalidVersions(t *testing.T) {
if _, err := shouldMigrateAPIServerKubeletClientCert("invalid", "v1.29.0"); err == nil {
t.Fatal("expected invalid current version to return an error")
}
if _, err := shouldMigrateAPIServerKubeletClientCert("v1.28.9", "invalid"); err == nil {
t.Fatal("expected invalid target version to return an error")
}
}
func TestGetRemoteCertMigrations(t *testing.T) {
tests := []struct {
name string
current string
target string
hasLocalEtcd bool
wantNames []string
}{
{
name: "skip when target stays below 129",
current: "v1.28.9",
target: "v1.28.10",
hasLocalEtcd: true,
wantNames: nil,
},
{
name: "migrate kubelet client only for external etcd",
current: "v1.28.9",
target: "v1.29.0",
hasLocalEtcd: false,
wantNames: []string{apiserverKubeletClientCertName},
},
{
name: "migrate kubelet and etcd clients for local etcd",
current: "v1.28.9",
target: "v1.29.0",
hasLocalEtcd: true,
wantNames: []string{
apiserverKubeletClientCertName,
apiserverEtcdClientCertName,
etcdHealthcheckClientCertName,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := getRemoteCertMigrations(tt.current, tt.target, tt.hasLocalEtcd)
if err != nil {
t.Fatalf("getRemoteCertMigrations() error = %v", err)
}
if len(got) != len(tt.wantNames) {
t.Fatalf("getRemoteCertMigrations() len = %d, want %d (%v)", len(got), len(tt.wantNames), tt.wantNames)
}
for i := range got {
if got[i].name != tt.wantNames[i] {
t.Fatalf("getRemoteCertMigrations()[%d] = %q, want %q", i, got[i].name, tt.wantNames[i])
}
}
})
}
}
func TestAutoUpdateConfigTreatsDefaultEtcdAsLocal(t *testing.T) {
rootDir := t.TempDir()
prevRuntimeRoot := constants.DefaultRuntimeRootDir
constants.DefaultRuntimeRootDir = rootDir
t.Cleanup(func() {
constants.DefaultRuntimeRootDir = prevRuntimeRoot
})
clusterConfig := `apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.28.9
networking:
serviceSubnet: 10.96.0.0/12
podSubnet: 10.244.0.0/16
dnsDomain: cluster.local
apiServer:
certSANs:
- 127.0.0.1`
kubeletConfig := `apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd`
client := clientset.NewSimpleClientset(
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ckubeadm.KubeadmConfigConfigMap,
Namespace: metav1.NamespaceSystem,
},
Data: map[string]string{
ckubeadm.ClusterConfigurationConfigMapKey: clusterConfig,
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ckubeadm.KubeletBaseConfigurationConfigMap,
Namespace: metav1.NamespaceSystem,
},
Data: map[string]string{
ckubeadm.KubeletBaseConfigurationConfigMapKey: kubeletConfig,
},
},
)
cfgDir := filepath.Join(rootDir, "test-cluster", "etc")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir etc dir: %v", err)
}
adminFile := filepath.Join(cfgDir, "admin.conf")
if err := os.WriteFile(adminFile, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil {
t.Fatalf("write admin.conf: %v", err)
}
rt := &KubeadmRuntime{
cli: &stubKubeClient{
k8s: client,
cfg: &rest.Config{Host: "https://127.0.0.1:6443"},
},
cluster: &v1beta1.Cluster{},
kubeadmConfig: types.NewKubeadmConfig(),
pathResolver: constants.NewPathResolver("test-cluster"),
}
_, hasLocalEtcd, err := rt.autoUpdateConfig("v1.29.15")
if err != nil {
t.Fatalf("autoUpdateConfig() error = %v", err)
}
if !hasLocalEtcd {
t.Fatal("expected kubeadm default etcd config without external etcd to be treated as local etcd")
}
}
func TestUsesLocalEtcd(t *testing.T) {
tests := []struct {
name string
etcd kubeadm.Etcd
want bool
}{
{
name: "default kubeadm etcd",
etcd: kubeadm.Etcd{},
want: true,
},
{
name: "explicit local etcd",
etcd: kubeadm.Etcd{Local: &kubeadm.LocalEtcd{}},
want: true,
},
{
name: "external etcd with endpoints",
etcd: kubeadm.Etcd{External: &kubeadm.ExternalEtcd{Endpoints: []string{"https://127.0.0.1:2379"}}},
want: false,
},
{
name: "empty external etcd falls back to local kubeadm behavior",
etcd: kubeadm.Etcd{External: &kubeadm.ExternalEtcd{}},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := usesLocalEtcd(tt.etcd); got != tt.want {
t.Fatalf("usesLocalEtcd() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldRegenerateRemoteAdminKubeConfig(t *testing.T) {
tests := []struct {
version string
want bool
}{
{version: "v1.28.9", want: false},
{version: "v1.29.0", want: true},
{version: "v1.31.1", want: true},
}
for _, tt := range tests {
if got := shouldRegenerateRemoteAdminKubeConfig(tt.version); got != tt.want {
t.Fatalf("shouldRegenerateRemoteAdminKubeConfig(%q) = %v, want %v", tt.version, got, tt.want)
}
}
}
func TestBuildRegenerateRemoteAdminKubeConfigCmd(t *testing.T) {
cmd := buildRegenerateRemoteAdminKubeConfigCmd("/tmp/kubeadm-migration.yaml")
for _, want := range []string{
"rm -f /etc/kubernetes/admin.conf",
"kubeadm init phase kubeconfig admin --config /tmp/kubeadm-migration.yaml",
"mv -f \"$backup_dir/admin.conf\" /etc/kubernetes/admin.conf",
} {
if !strings.Contains(cmd, want) {
t.Fatalf("buildRegenerateRemoteAdminKubeConfigCmd() output %q does not contain %q", cmd, want)
}
}
if strings.Contains(cmd, "kubeadm certs renew admin.conf") {
t.Fatalf("buildRegenerateRemoteAdminKubeConfigCmd() should not use renew, got %q", cmd)
}
}
func TestSyncUpgradeMigrationConfigCopiesToAllTargets(t *testing.T) {
rootDir := t.TempDir()
prevRuntimeRoot := constants.DefaultRuntimeRootDir
constants.DefaultRuntimeRootDir = rootDir
t.Cleanup(func() {
constants.DefaultRuntimeRootDir = prevRuntimeRoot
})
rt := &KubeadmRuntime{
execer: &stubSSH{},
cluster: testCluster([]string{"master0", "master1", "master2"}),
pathResolver: constants.NewPathResolver("test-cluster"),
}
localConfigPath := rt.upgradeMigrationConfigLocalPath()
if err := os.MkdirAll(filepath.Dir(localConfigPath), 0o755); err != nil {
t.Fatalf("mkdir migration dir: %v", err)
}
if err := os.WriteFile(localConfigPath, []byte("kind: ClusterConfiguration\n"), 0o600); err != nil {
t.Fatalf("write migration config: %v", err)
}
stub := rt.execer.(*stubSSH)
if err := rt.syncUpgradeMigrationConfig([]string{"master1", "master2"}); err != nil {
t.Fatalf("syncUpgradeMigrationConfig() error = %v", err)
}
wantCopies := []string{
"master1|" + localConfigPath + "|" + rt.remoteUpgradeMigrationConfigPath(),
"master2|" + localConfigPath + "|" + rt.remoteUpgradeMigrationConfigPath(),
}
gotCopies := append([]string{}, stub.copyCalls...)
sort.Strings(gotCopies)
sort.Strings(wantCopies)
if len(gotCopies) != len(wantCopies) {
t.Fatalf("syncUpgradeMigrationConfig() copyCalls = %v, want %v", gotCopies, wantCopies)
}
for i := range wantCopies {
if gotCopies[i] != wantCopies[i] {
t.Fatalf("syncUpgradeMigrationConfig() copyCalls = %v, want %v", gotCopies, wantCopies)
}
}
}
func TestPreV131RemoteIdentityMigrationsAvoidKubeadmPhaseRegeneration(t *testing.T) {
applyCmd, err := getUpgradeApplyCmd("v1.29.15")
if err != nil {
t.Fatalf("getUpgradeApplyCmd() error = %v", err)
}
if !strings.Contains(applyCmd, "--certificate-renewal=false") {
t.Fatalf("expected pre-v1.31 upgrade apply to keep certificate renewal disabled, got %q", applyCmd)
}
if got := shouldRegenerateRemoteAdminKubeConfig("v1.29.15"); !got {
t.Fatal("expected remote admin.conf regeneration to remain enabled for v1.29.15")
}
}
func TestStagedLocalPKIFileUsesRegeneratedLocalFiles(t *testing.T) {
stagingDir := t.TempDir()
files := []string{
"apiserver-etcd-client.crt",
"etcd/healthcheck-client.crt",
}
for _, fileName := range files {
filePath := filepath.Join(stagingDir, filepath.FromSlash(fileName))
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(filePath), err)
}
if err := os.WriteFile(filePath, []byte("cert"), 0o600); err != nil {
t.Fatalf("write %s: %v", filePath, err)
}
got, err := stagedLocalPKIFile(stagingDir, fileName)
if err != nil {
t.Fatalf("stagedLocalPKIFile(%q) error = %v", fileName, err)
}
if got != filePath {
t.Fatalf("stagedLocalPKIFile(%q) = %q, want %q", fileName, got, filePath)
}
}
if _, err := stagedLocalPKIFile(stagingDir, "missing.crt"); err == nil {
t.Fatal("expected missing staged file to return an error")
}
}
func TestMergeWithBuiltinKubeadmConfigLoadsNetworkingFromClusterConfig(t *testing.T) {
rootDir := t.TempDir()
prevRuntimeRoot := constants.DefaultRuntimeRootDir
constants.DefaultRuntimeRootDir = rootDir
t.Cleanup(func() {
constants.DefaultRuntimeRootDir = prevRuntimeRoot
})
clusterConfig := `apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
networking:
serviceSubnet: 10.96.0.0/12
podSubnet: 10.244.0.0/16
dnsDomain: cluster.local
apiServer:
certSANs:
- 127.0.0.1
- apiserver.example.local`
client := clientset.NewSimpleClientset(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ckubeadm.KubeadmConfigConfigMap,
Namespace: metav1.NamespaceSystem,
},
Data: map[string]string{
ckubeadm.ClusterConfigurationConfigMapKey: clusterConfig,
},
})
cfgDir := filepath.Join(rootDir, "test-cluster", "etc")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir etc dir: %v", err)
}
adminFile := filepath.Join(cfgDir, "admin.conf")
if err := os.WriteFile(adminFile, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil {
t.Fatalf("write admin.conf: %v", err)
}
rt := &KubeadmRuntime{
cli: &stubKubeClient{
k8s: client,
cfg: &rest.Config{Host: "https://127.0.0.1:6443"},
},
cluster: &v1beta1.Cluster{},
kubeadmConfig: types.NewKubeadmConfig(),
pathResolver: constants.NewPathResolver("test-cluster"),
}
rt.kubeadmConfig.ClusterConfiguration.Networking.ServiceSubnet = ""
if err := rt.mergeWithBuiltinKubeadmConfig(); err != nil {
t.Fatalf("mergeWithBuiltinKubeadmConfig() error = %v", err)
}
if got := rt.getServiceCIDR(); got != "10.96.0.0/12" {
t.Fatalf("serviceSubnet = %q, want %q", got, "10.96.0.0/12")
}
if got := rt.getDNSDomain(); got != "cluster.local" {
t.Fatalf("dnsDomain = %q, want %q", got, "cluster.local")
}
if got := rt.getCertSANs(); len(got) != 2 || got[1] != "apiserver.example.local" {
t.Fatalf("certSANs = %v, want [127.0.0.1 apiserver.example.local]", got)
}
}
func TestPreV131RemoteMigrationLoadsNetworkingFromClusterConfig(t *testing.T) {
rootDir := t.TempDir()
prevRuntimeRoot := constants.DefaultRuntimeRootDir
constants.DefaultRuntimeRootDir = rootDir
t.Cleanup(func() {
constants.DefaultRuntimeRootDir = prevRuntimeRoot
})
clusterConfig := `apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
networking:
serviceSubnet: 10.96.0.0/12
podSubnet: 10.244.0.0/16
dnsDomain: cluster.local
apiServer:
certSANs:
- 127.0.0.1
- apiserver.example.local`
client := clientset.NewSimpleClientset(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ckubeadm.KubeadmConfigConfigMap,
Namespace: metav1.NamespaceSystem,
},
Data: map[string]string{
ckubeadm.ClusterConfigurationConfigMapKey: clusterConfig,
},
})
cfgDir := filepath.Join(rootDir, "test-cluster", "etc")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir etc dir: %v", err)
}
adminFile := filepath.Join(cfgDir, "admin.conf")
if err := os.WriteFile(adminFile, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil {
t.Fatalf("write admin.conf: %v", err)
}
rt := &KubeadmRuntime{
cli: &stubKubeClient{
k8s: client,
cfg: &rest.Config{Host: "https://127.0.0.1:6443"},
},
cluster: &v1beta1.Cluster{},
kubeadmConfig: types.NewKubeadmConfig(),
pathResolver: constants.NewPathResolver("test-cluster"),
}
rt.kubeadmConfig.ClusterConfiguration.Networking.ServiceSubnet = ""
if err := rt.mergeWithBuiltinKubeadmConfig(); err != nil {
t.Fatalf("mergeWithBuiltinKubeadmConfig() error = %v", err)
}
if got := rt.getServiceCIDR(); got != "10.96.0.0/12" {
t.Fatalf("serviceSubnet = %q, want %q", got, "10.96.0.0/12")
}
}
type stubKubeClient struct {
k8s kubernetesclient.Interface
cfg *rest.Config
}
var _ clientkubernetes.Client = (*stubKubeClient)(nil)
func (s *stubKubeClient) Kubernetes() kubernetesclient.Interface { return s.k8s }
func (s *stubKubeClient) Discovery() discovery.DiscoveryInterface { return nil }
func (s *stubKubeClient) KubernetesDynamic() dynamic.Interface { return nil }
func (s *stubKubeClient) Config() *rest.Config { return s.cfg }
+39 -5
View File
@@ -24,6 +24,11 @@ ifeq ($(DEBUG), 1)
endif
GO_BUILD_FLAGS += -tags "containers_image_openpgp netgo exclude_graphdriver_devicemapper static osusergo exclude_graphdriver_btrfs" -trimpath -ldflags "$(GO_LDFLAGS)"
USER_CC_OVERRIDE := $(if $(filter-out default undefined,$(origin CC)),$(value CC),)
define platform_cc_override
$(if $(filter-out undefined,$(origin CC_$(1))),$(value CC_$(1)),$(USER_CC_OVERRIDE))
endef
ifeq ($(ROOT_PACKAGE),)
$(error the variable ROOT_PACKAGE must be set prior to including golang.mk)
endif
@@ -62,11 +67,41 @@ go.build.%:
@if [ "$(COMMAND)" == "sealos" ] || [ "$(COMMAND)" == "sealctl" ]; then \
CGO_ENABLED=1; \
CC=x86_64-linux-gnu-gcc; \
if [ "$(ARCH)" == "arm64" ]; then \
CC=aarch64-linux-gnu-gcc; \
TARGET_CC="$(call platform_cc_override,$(PLATFORM))"; \
resolve_cc() { \
local candidate cc_bin; \
for candidate in "$$@"; do \
if [ -z "$$candidate" ]; then \
continue; \
fi; \
cc_bin="$${candidate%% *}"; \
if command -v "$$cc_bin" >/dev/null 2>&1; then \
printf '%s\n' "$$candidate"; \
return 0; \
fi; \
done; \
return 1; \
}; \
if [ -z "$$TARGET_CC" ]; then \
HOST_OS="$$($(GO) env GOOS)"; \
HOST_ARCH="$$($(GO) env GOARCH)"; \
HOST_CC="$$($(GO) env CC)"; \
if [ "$(OS)" == "$$HOST_OS" ] && [ "$(ARCH)" == "$$HOST_ARCH" ]; then \
TARGET_CC="$$(resolve_cc "$$HOST_CC" cc gcc clang)"; \
else \
case "$(ARCH)" in \
amd64) GNU_ARCH=x86_64 ;; \
arm64) GNU_ARCH=aarch64 ;; \
*) GNU_ARCH="$(ARCH)" ;; \
esac; \
TARGET_CC="$$(resolve_cc "$$GNU_ARCH-$(OS)-gnu-gcc" "$$GNU_ARCH-pc-$(OS)-gnu-gcc")"; \
fi; \
fi; \
CGO_ENABLED=$$CGO_ENABLED CC=$$CC GOOS=$(OS) GOARCH=$(ARCH) $(GO) build $(GO_BUILD_FLAGS) -o $(BIN_DIR)/$(PLATFORM)/$(COMMAND) $(ROOT_PACKAGE)/cmd/$(COMMAND); \
if [ -z "$$TARGET_CC" ]; then \
echo "No usable C compiler found for $(PLATFORM). Set CC or CC_$(PLATFORM) explicitly."; \
exit 1; \
fi; \
CGO_ENABLED=$$CGO_ENABLED CC="$$TARGET_CC" GOOS=$(OS) GOARCH=$(ARCH) $(GO) build $(GO_BUILD_FLAGS) -o $(BIN_DIR)/$(PLATFORM)/$(COMMAND) $(ROOT_PACKAGE)/cmd/$(COMMAND); \
else \
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) $(GO) build $(GO_BUILD_FLAGS) -o $(BIN_DIR)/$(PLATFORM)/$(COMMAND) $(ROOT_PACKAGE)/cmd/$(COMMAND); \
fi
@@ -102,4 +137,3 @@ go.format: tools.verify.goimports
.PHONY: go.coverage
go.coverage:
@$(GO) test -race -failfast -coverprofile=coverage.out -covermode=atomic `go list ./pkg/env ./pkg/apply | grep -v "/test\|/fork"`