Merge pull request #10823 from azhao155/yzhao/feature/auto-hook

Add auto-pause webhook to inject env into pods for redirecting in-cluster kubectl request to reverse proxy of api server.
pull/10952/head
Medya Ghazizadeh 2021-03-29 09:53:14 -07:00 committed by GitHub
commit 660cc42e88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 544 additions and 4 deletions

1
.gitignore vendored
View File

@ -27,6 +27,7 @@ _testmain.go
*.prof
/deploy/kicbase/auto-pause
/deploy/addons/auto-pause/auto-pause-hook
/out
/_gopath

View File

@ -100,6 +100,9 @@ SHA512SUM=$(shell command -v sha512sum || echo "shasum -a 512")
# to update minikubes default, update deploy/addons/gvisor
GVISOR_TAG ?= latest
# auto-pause-hook tag to push changes to
AUTOPAUSE_HOOK_TAG ?= 1.13
# storage provisioner tag to push changes to
STORAGE_PROVISIONER_TAG ?= v4
@ -834,6 +837,20 @@ out/mkcmp:
deploy/kicbase/auto-pause: $(SOURCE_GENERATED) $(SOURCE_FILES)
GOOS=linux GOARCH=$(GOARCH) go build -o $@ cmd/auto-pause/auto-pause.go
.PHONY: deploy/addons/auto-pause/auto-pause-hook
deploy/addons/auto-pause/auto-pause-hook: $(SOURCE_GENERATED) ## Build auto-pause hook addon
$(if $(quiet),@echo " GO $@")
$(Q)GOOS=linux CGO_ENABLED=0 go build -a --ldflags '-extldflags "-static"' -tags netgo -installsuffix netgo -o $@ cmd/auto-pause/auto-pause-hook/main.go cmd/auto-pause/auto-pause-hook/config.go cmd/auto-pause/auto-pause-hook/certs.go
.PHONY: auto-pause-hook-image
auto-pause-hook-image: deploy/addons/auto-pause/auto-pause-hook ## Build docker image for auto-pause hook
docker build -t docker.io/azhao155/auto-pause-hook:$(AUTOPAUSE_HOOK_TAG) ./deploy/addons/auto-pause
.PHONY: push-auto-pause-hook-image
push-auto-pause-hook-image: auto-pause-hook-image
docker login docker.io/azhao155
$(MAKE) push-docker IMAGE=docker.io/azhao155/auto-pause-hook:$(AUTOPAUSE_HOOK_TAG)
.PHONY: out/performance-bot
out/performance-bot:
GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $@ cmd/performance/pr-bot/bot.go

View File

@ -0,0 +1,112 @@
/*
Copyright 2021 The Kubernetes Authors All rights reserved.
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 main
import (
"bytes"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"time"
)
// generate https certs for the webhook server
func gencerts() (caCert []byte, serverCert []byte, serverKey []byte) {
var caPEM, serverCertPEM, serverPrivKeyPEM *bytes.Buffer
// CA config
ca := &x509.Certificate{
SerialNumber: big.NewInt(2020),
Subject: pkix.Name{
Organization: []string{"velotio.com"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
// CA private key
caPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
if err != nil {
fmt.Println(err)
}
// Self signed CA certificate
caBytes, err := x509.CreateCertificate(cryptorand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
if err != nil {
fmt.Println(err)
}
// PEM encode CA cert
caPEM = new(bytes.Buffer)
_ = pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
dnsNames := []string{"webhook",
"webhook.auto-pause", "webhook.auto-pause.svc"}
commonName := "webhook.auto-pause.svc"
// server cert config
cert := &x509.Certificate{
DNSNames: dnsNames,
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"velotio.com"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
// server private key
serverPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
if err != nil {
fmt.Println(err)
}
// sign the server cert
serverCertBytes, err := x509.CreateCertificate(cryptorand.Reader, cert, ca, &serverPrivKey.PublicKey, caPrivKey)
if err != nil {
fmt.Println(err)
}
// PEM encode the server cert and key
serverCertPEM = new(bytes.Buffer)
_ = pem.Encode(serverCertPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: serverCertBytes,
})
serverPrivKeyPEM = new(bytes.Buffer)
_ = pem.Encode(serverPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(serverPrivKey),
})
return caPEM.Bytes(), serverCertPEM.Bytes(), serverPrivKeyPEM.Bytes()
}

View File

@ -0,0 +1,147 @@
/*
Copyright 2021 The Kubernetes Authors All rights reserved.
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 main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
v1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"github.com/golang/glog"
)
var (
webhookName = "env-inject-webhook"
webhookConfigName = "env-inject.zyanshu.io"
skipLabel = "auto-pause-skip"
)
// Create a clientset with in-cluster config.
func client() *kubernetes.Clientset {
config, err := rest.InClusterConfig()
if err != nil {
glog.Fatal(err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
glog.Fatal(err)
}
return clientset
}
// Retrieve the CA cert that will signed the cert used by the
// "GenericAdmissionWebhook" plugin admission controller.
func apiServerCert(clientset *kubernetes.Clientset) []byte {
c, err := clientset.CoreV1().ConfigMaps("kube-system").Get("extension-apiserver-authentication", metav1.GetOptions{})
if err != nil {
glog.Fatal(err)
}
pem, ok := c.Data["requestheader-client-ca-file"]
if !ok {
glog.Fatalf(fmt.Sprintf("cannot find the ca.crt in the configmap, configMap.Data is %#v", c.Data))
}
glog.Info("client-ca-file=", pem)
return []byte(pem)
}
func configTLS(clientset *kubernetes.Clientset, serverCert []byte, serverKey []byte) *tls.Config {
cert := apiServerCert(clientset)
apiserverCA := x509.NewCertPool()
apiserverCA.AppendCertsFromPEM(cert)
sCert, err := tls.X509KeyPair(serverCert, serverKey)
if err != nil {
glog.Fatal(err)
}
return &tls.Config{
Certificates: []tls.Certificate{sCert},
ClientCAs: apiserverCA,
ClientAuth: tls.VerifyClientCertIfGiven, // TODO: actually require client cert
}
}
// register this example webhook admission controller with the kube-apiserver
// by creating externalAdmissionHookConfigurations.
func selfRegistration(clientset *kubernetes.Clientset, caCert []byte) {
client := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations()
_, err := client.Get(webhookName, metav1.GetOptions{})
if err == nil {
if err2 := client.Delete(webhookName, &metav1.DeleteOptions{}); err2 != nil {
glog.Fatal(err2)
}
}
var failurePolicy v1.FailurePolicyType = v1.Fail
var sideEffects v1.SideEffectClass = v1.SideEffectClassNone
webhookConfig := &v1.MutatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: webhookName,
},
Webhooks: []v1.MutatingWebhook{
{
Name: webhookConfigName,
Rules: []v1.RuleWithOperations{
{
Operations: []v1.OperationType{v1.Create, v1.Update},
Rule: v1.Rule{
APIGroups: []string{""},
APIVersions: []string{"v1"},
Resources: []string{"pods"},
},
},
{
Operations: []v1.OperationType{v1.Create, v1.Update},
Rule: v1.Rule{
APIGroups: []string{"extensions"},
APIVersions: []string{"v1"},
Resources: []string{"deployments"},
},
},
},
FailurePolicy: &failurePolicy,
ObjectSelector: &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: skipLabel,
Operator: metav1.LabelSelectorOpDoesNotExist,
},
},
},
ClientConfig: v1.WebhookClientConfig{
Service: &v1.ServiceReference{
Namespace: "auto-pause",
Name: "webhook",
},
CABundle: caCert,
},
AdmissionReviewVersions: []string{"v1"},
SideEffects: &sideEffects,
},
},
}
if _, err := client.Create(webhookConfig); err != nil {
glog.Fatalf("Client creation failed with %s", err)
}
log.Println("CLIENT CREATED")
}

View File

@ -0,0 +1,176 @@
/*
Copyright 2021 The Kubernetes Authors All rights reserved.
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 main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"strconv"
"github.com/golang/glog"
"github.com/mattbaird/jsonpatch"
v1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/minikube/pkg/minikube/constants"
)
var (
runtimeScheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(runtimeScheme)
deserializer = codecs.UniversalDeserializer()
)
var targetIP *string
func handler(w http.ResponseWriter, r *http.Request) {
log.Println("Handling a request")
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("error: %v", err)
return
}
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
log.Printf("Wrong content type. Got: %s", contentType)
return
}
admReq := v1.AdmissionReview{}
admResp := v1.AdmissionReview{}
if _, _, err := deserializer.Decode(body, nil, &admReq); err != nil {
log.Printf("Could not decode body: %v", err)
admResp.Response = admissionError(err)
} else {
admResp.Response = AdmissionDecision(&admReq)
}
admResp.APIVersion = "admission.k8s.io/v1"
admResp.Kind = "AdmissionReview"
resp, err := json.Marshal(admResp)
if err != nil {
log.Printf("error marshalling decision: %v", err)
}
log.Printf("%s", string(resp))
if _, err := w.Write(resp); err != nil {
log.Printf("error writing response %v", err)
}
}
func admissionError(err error) *v1.AdmissionResponse {
return &v1.AdmissionResponse{
Result: &metav1.Status{Message: err.Error()},
}
}
// Create the admission decision for the request
func AdmissionDecision(admReq *v1.AdmissionReview) *v1.AdmissionResponse {
req := admReq.Request
var pod corev1.Pod
err := json.Unmarshal(req.Object.Raw, &pod)
if err != nil {
log.Printf("Could not unmarshal raw object: %v", err)
return admissionError(err)
}
log.Printf("AdmissionReview for Kind=%v Namespace=%v Name=%v UID=%v Operation=%v UserInfo=%v",
req.Kind, req.Namespace, req.Name, req.UID, req.Operation, req.UserInfo)
patch, err := patchConfig(&pod)
if err != nil {
log.Printf("Error creating conduit patch: %v", err)
return admissionError(err)
}
jsonPatchType := v1.PatchTypeJSONPatch
return &v1.AdmissionResponse{
Allowed: true,
Patch: patch,
PatchType: &jsonPatchType,
UID: req.UID,
}
}
func patchConfig(pod *corev1.Pod) ([]byte, error) {
var patch []jsonpatch.JsonPatchOperation
configEnv := []corev1.EnvVar{
{Name: "KUBERNETES_SERVICE_HOST", Value: *targetIP},
{Name: "KUBERNETES_SERVICE_PORT", Value: strconv.Itoa(constants.AutoPauseProxyPort)}}
for idx, container := range pod.Spec.Containers {
patch = append(patch, addEnv(container.Env, configEnv, fmt.Sprintf("/spec/containers/%d/env", idx))...)
}
return json.Marshal(patch)
}
// addEnv performs the mutation(s) needed to add the extra environment variables to the target
// resource
func addEnv(target, envVars []corev1.EnvVar, basePath string) (patch []jsonpatch.JsonPatchOperation) {
first := len(target) == 0
var value interface{}
for _, envVar := range envVars {
value = envVar
path := basePath
if first {
first = false
value = []corev1.EnvVar{envVar}
} else {
path += "/-"
}
patch = append(patch, jsonpatch.JsonPatchOperation{
Operation: "add",
Path: path,
Value: value,
})
}
return patch
}
func main() {
addr := flag.String("addr", ":8080", "address to serve on")
targetIP = flag.String("targetIP", "192.168.49.2", "The reverse proxy IP")
http.HandleFunc("/", handler)
flag.Parse()
log.Printf("Starting HTTPS webhook server on %+v and target ip is %v", *addr, *targetIP)
cacert, serverCert, serverKey := gencerts()
clientset := client()
server := &http.Server{
Addr: *addr,
TLSConfig: configTLS(clientset, serverCert, serverKey),
}
go selfRegistration(clientset, cacert)
err := server.ListenAndServeTLS("", "")
if err != nil {
glog.Fatalf("Start https server failed with %s", err)
}
}

View File

@ -0,0 +1,2 @@
FROM golang:1.8
ADD auto-pause-hook /auto-pause-hook

View File

@ -0,0 +1,57 @@
apiVersion: v1
kind: Namespace
metadata:
name: auto-pause
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: env-inject
name: env-inject
namespace: auto-pause
spec:
selector:
matchLabels:
app: env-inject
replicas: 1
template:
metadata:
labels:
app: env-inject
name: env-inject
spec:
containers:
- name: webhook
image: {{.CustomRegistries.AutoPauseHook | default .ImageRepository | default .Registries.AutoPauseHook }}{{.Images.AutoPauseHook}}
ports:
- containerPort: 8080
command: ["/auto-pause-hook"]
args: ["-targetIP={{.NetworkInfo.ControlPlaneNodeIP}}"]
---
apiVersion: v1
kind: Service
metadata:
labels:
role: webhook
name: webhook
namespace: auto-pause
spec:
ports:
- port: 443
targetPort: 8080
selector:
app: env-inject
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: webhook
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: default
namespace: auto-pause

View File

@ -11,6 +11,7 @@ metadata:
namespace: auto-pause
labels:
app: auto-pause-proxy
auto-pause-skip: "true"
spec:
replicas: 1
selector:

2
go.mod
View File

@ -27,6 +27,7 @@ require (
github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f
github.com/elazarl/goproxy/ext v0.0.0-20190421051319-9d40249d3c2f // indirect
github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/google/go-cmp v0.5.5
github.com/google/go-containerregistry v0.4.1
github.com/google/go-github v17.0.0+incompatible
@ -52,6 +53,7 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/libvirt/libvirt-go v3.9.0+incompatible
github.com/machine-drivers/docker-machine-driver-vmware v0.1.1
github.com/mattbaird/jsonpatch v0.0.0-20200820163806-098863c1fc24 // indirect
github.com/mattn/go-isatty v0.0.12
github.com/mitchellh/go-ps v1.0.0
github.com/moby/hyperkit v0.0.0-20210108224842-2f061e447e14

3
go.sum
View File

@ -416,6 +416,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 h1:zN2lZNZRflqFyxVaTIU61KNKQ9C0055u9CAfpmqUvo4=
github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3/go.mod h1:nPpo7qLxd6XL3hWJG/O60sR8ZKfMCiIoNap5GvD12KU=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -680,6 +681,8 @@ github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7
github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
github.com/maruel/panicparse v1.5.0/go.mod h1:aOutY/MUjdj80R0AEVI9qE2zHqig+67t2ffUDDiLzAM=
github.com/mattbaird/jsonpatch v0.0.0-20200820163806-098863c1fc24 h1:uYuGXJBAi1umT+ZS4oQJUgKtfXCAYTR+n9zw1ViT0vA=
github.com/mattbaird/jsonpatch v0.0.0-20200820163806-098863c1fc24/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=

View File

@ -216,7 +216,14 @@ https://github.com/kubernetes/minikube/issues/7332`, out.V{"driver_name": cc.Dri
}
}
data := assets.GenerateTemplateData(addon, cc.KubernetesConfig)
var networkInfo assets.NetworkInfo
if len(cc.Nodes) >= 1 {
networkInfo.ControlPlaneNodeIP = cc.Nodes[0].IP
} else {
out.WarningT("At least needs control plane nodes to enable addon")
}
data := assets.GenerateTemplateData(addon, cc.KubernetesConfig, networkInfo)
return enableOrDisableAddonInternal(cc, addon, runner, data, enable)
}

View File

@ -40,6 +40,11 @@ type Addon struct {
Registries map[string]string
}
// NetworkInfo contains control plane node IP address used for add on template
type NetworkInfo struct {
ControlPlaneNodeIP string
}
// NewAddon creates a new Addon
func NewAddon(assets []*BinAsset, enabled bool, addonName string, images map[string]string, registries map[string]string) *Addon {
a := &Addon{
@ -77,6 +82,11 @@ var Addons = map[string]*Addon{
vmpath.GuestAddonsDir,
"auto-pause.yaml",
"0640"),
MustBinAsset(
"deploy/addons/auto-pause/auto-pause-hook.yaml.tmpl",
vmpath.GuestAddonsDir,
"auto-pause-hook.yaml",
"0640"),
MustBinAsset(
"deploy/addons/auto-pause/haproxy.cfg",
"/var/lib/minikube/",
@ -95,9 +105,9 @@ var Addons = map[string]*Addon{
//GuestPersistentDir
}, false, "auto-pause", map[string]string{
"haproxy": "haproxy:2.3.5",
"AutoPauseHook": "azhao155/auto-pause-hook:1.13",
}, map[string]string{
"haproxy": "gcr.io",
"AutoPauseHook": "docker.io",
}),
"dashboard": NewAddon([]*BinAsset{
// We want to create the kubernetes-dashboard ns first so that every subsequent object can be created
@ -650,7 +660,7 @@ var Addons = map[string]*Addon{
}
// GenerateTemplateData generates template data for template assets
func GenerateTemplateData(addon *Addon, cfg config.KubernetesConfig) interface{} {
func GenerateTemplateData(addon *Addon, cfg config.KubernetesConfig, networkInfo NetworkInfo) interface{} {
a := runtime.GOARCH
// Some legacy docker images still need the -arch suffix
@ -669,6 +679,7 @@ func GenerateTemplateData(addon *Addon, cfg config.KubernetesConfig) interface{}
Images map[string]string
Registries map[string]string
CustomRegistries map[string]string
NetworkInfo map[string]string
}{
Arch: a,
ExoticArch: ea,
@ -679,11 +690,15 @@ func GenerateTemplateData(addon *Addon, cfg config.KubernetesConfig) interface{}
Images: addon.Images,
Registries: addon.Registries,
CustomRegistries: make(map[string]string),
NetworkInfo: make(map[string]string),
}
if opts.ImageRepository != "" && !strings.HasSuffix(opts.ImageRepository, "/") {
opts.ImageRepository += "/"
}
// Network info for generating template
opts.NetworkInfo["ControlPlaneNodeIP"] = networkInfo.ControlPlaneNodeIP
if opts.Images == nil {
opts.Images = make(map[string]string) // Avoid nil access when rendering
}