diff --git a/controllers/funcs.go b/controllers/funcs.go index 62392341..9f9286ac 100644 --- a/controllers/funcs.go +++ b/controllers/funcs.go @@ -86,6 +86,11 @@ func getCommonRbacRules() []rbacv1.PolicyRule { Resources: []string{"pods"}, Verbs: []string{"create", "get", "list", "watch", "update", "patch", "delete"}, }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"create", "get", "list", "delete"}, + }, } } diff --git a/controllers/ironic_controller.go b/controllers/ironic_controller.go index 6198099f..0ef03347 100644 --- a/controllers/ironic_controller.go +++ b/controllers/ironic_controller.go @@ -794,6 +794,9 @@ func (r *IronicReconciler) conductorDeploymentCreateOrUpdate( TransportURLSecret: instance.Status.TransportURLSecret, KeystoneEndpoints: *keystoneEndpoints, TLS: instance.Spec.IronicAPI.TLS.Ca, + GraphicalConsoles: instance.Spec.GraphicalConsoles, + ConsoleImage: instance.Spec.Images.GraphicalConsole, + NoVNCProxyImage: instance.Spec.Images.NoVNCProxy, } if IronicConductorSpec.NodeSelector == nil { diff --git a/controllers/ironicconductor_controller.go b/controllers/ironicconductor_controller.go index aec30cda..2b89ce03 100644 --- a/controllers/ironicconductor_controller.go +++ b/controllers/ironicconductor_controller.go @@ -463,6 +463,48 @@ func (r *IronicConductorReconciler) reconcileServices( } } } + if instance.Spec.GraphicalConsoles == "Enabled" { + // + // Create the conductor pod route to enable traffic to the + // novnc service, which graphical consoles are enabled + // + conductorRouteLabels := map[string]string{ + common.AppSelector: ironic.ServiceName, + common.ComponentSelector: ironic.NoVNCComponent, + ironic.ConductorGroupSelector: ironicv1.ConductorGroupNull, + } + if instance.Spec.ConductorGroup != "" { + conductorRouteLabels[ironic.ConductorGroupSelector] = strings.ToLower(instance.Spec.ConductorGroup) + } + + novncRoute := ironicconductor.RouteNoVNC(conductorPod.Name, instance, conductorRouteLabels) + err = controllerutil.SetOwnerReference(&conductorPod, novncRoute, helper.GetScheme()) + if err != nil { + return ctrl.Result{}, err + } + err = r.Get( + ctx, + types.NamespacedName{ + Name: novncRoute.Name, + Namespace: novncRoute.Namespace, + }, + novncRoute, + ) + if err != nil && k8s_errors.IsNotFound(err) { + Log.Info(fmt.Sprintf("Route %s does not exist, creating it", novncRoute.Name)) + err = r.Create(ctx, novncRoute) + if err != nil { + return ctrl.Result{}, err + } + } else { + Log.Info(fmt.Sprintf("Route %s exists, updating it", novncRoute.Name)) + err = r.Update(ctx, novncRoute) + if err != nil { + return ctrl.Result{}, err + } + } + + } } Log.Info("Reconciled Conductor Services successfully") @@ -922,6 +964,11 @@ func (r *IronicConductorReconciler) generateServiceConfigMaps( templateParameters["Standalone"] = instance.Spec.Standalone templateParameters["ConductorGroup"] = instance.Spec.ConductorGroup templateParameters["LogPath"] = ironicconductor.LogPath + graphicalConsolesEnabled := instance.Spec.GraphicalConsoles == "Enabled" + templateParameters["GraphicalConsolesEnabled"] = graphicalConsolesEnabled + if graphicalConsolesEnabled { + templateParameters["ConsoleImage"] = instance.Spec.ConsoleImage + } databaseAccount := db.GetAccount() dbSecret := db.GetSecret() @@ -960,6 +1007,7 @@ func (r *IronicConductorReconciler) generateServiceConfigMaps( AdditionalTemplate: map[string]string{ "ironic.conf": "/common/config/ironic.conf", "01-conductor.conf": "/ironicconductor/config/01-conductor.conf", + "01-novnc.conf": "/ironicconductor/config/01-novnc.conf", "03-init-container-conductor.conf": "/ironicconductor/config/03-init-container-conductor.conf", "dnsmasq.conf": "/common/config/dnsmasq.conf", }, diff --git a/pkg/ironic/const.go b/pkg/ironic/const.go index a420c57b..8acbf50a 100644 --- a/pkg/ironic/const.go +++ b/pkg/ironic/const.go @@ -41,6 +41,8 @@ const ( APIComponent = "api" // InspectorComponent - InspectorComponent = "inspector" + // NoVNCComponent - + NoVNCComponent = "novnc" // ConductorGroupSelector - ConductorGroupSelector = "conductorGroup" // ImageDirectory - diff --git a/pkg/ironic/initcontainer.go b/pkg/ironic/initcontainer.go index 0c8c90b2..ddbfe6f8 100644 --- a/pkg/ironic/initcontainer.go +++ b/pkg/ironic/initcontainer.go @@ -36,6 +36,7 @@ type APIDetails struct { PxeInit bool ConductorInit bool DeployHTTPURL string + NoVNCProxyURL string IngressDomain string ProvisionNetwork string ImageDirectory string @@ -57,6 +58,7 @@ func InitContainer(init APIDetails) []corev1.Container { envVars["DatabaseHost"] = env.SetValue(init.DatabaseHost) envVars["DatabaseName"] = env.SetValue(init.DatabaseName) envVars["DeployHTTPURL"] = env.SetValue(init.DeployHTTPURL) + envVars["NoVNCProxyURL"] = env.SetValue(init.NoVNCProxyURL) envVars["IngressDomain"] = env.SetValue(init.IngressDomain) envs := []corev1.EnvVar{ diff --git a/pkg/ironicconductor/service.go b/pkg/ironicconductor/service.go index c1e1f33c..6c2a2edd 100644 --- a/pkg/ironicconductor/service.go +++ b/pkg/ironicconductor/service.go @@ -39,6 +39,17 @@ func Service( ports = append(ports, httpbootPort) } + // Expose the ironic-novncproxy HTTP port if graphical consoles is enabled + if instance.Spec.GraphicalConsoles == "Enabled" { + novncPort := corev1.ServicePort{ + Name: ironic.NoVNCComponent, + Port: 6090, + Protocol: corev1.ProtocolTCP, + } + ports = append(ports, novncPort) + + } + if len(ports) == 0 { return nil } @@ -80,3 +91,29 @@ func Route( }, } } + +// RouteNoVNC - Route for novnc service when graphical consoles are enabled +func RouteNoVNC( + serviceName string, + instance *ironicv1.IronicConductor, + routeLabels map[string]string, +) *routev1.Route { + serviceRef := routev1.RouteTargetReference{ + Kind: "Service", + Name: serviceName, + } + routePort := &routev1.RoutePort{ + TargetPort: intstr.FromString(ironic.NoVNCComponent), + } + return &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName + "-novnc", + Namespace: instance.Namespace, + Labels: routeLabels, + }, + Spec: routev1.RouteSpec{ + To: serviceRef, + Port: routePort, + }, + } +} diff --git a/pkg/ironicconductor/statefulset.go b/pkg/ironicconductor/statefulset.go index 55ba8ee0..6df7c88d 100644 --- a/pkg/ironicconductor/statefulset.go +++ b/pkg/ironicconductor/statefulset.go @@ -82,6 +82,16 @@ func StatefulSet( PeriodSeconds: 30, InitialDelaySeconds: 5, } + novncLivenessProbe := &corev1.Probe{ + TimeoutSeconds: 10, + PeriodSeconds: 30, + InitialDelaySeconds: 5, + } + novncReadinessProbe := &corev1.Probe{ + TimeoutSeconds: 10, + PeriodSeconds: 30, + InitialDelaySeconds: 5, + } args := []string{"-c", ServiceCommand} @@ -115,6 +125,12 @@ func StatefulSet( httpbootReadinessProbe.TCPSocket = &corev1.TCPSocketAction{ Port: intstr.IntOrString{Type: intstr.Int, IntVal: int32(8088)}, } + novncLivenessProbe.TCPSocket = &corev1.TCPSocketAction{ + Port: intstr.IntOrString{Type: intstr.Int, IntVal: int32(6090)}, + } + novncReadinessProbe.TCPSocket = &corev1.TCPSocketAction{ + Port: intstr.IntOrString{Type: intstr.Int, IntVal: int32(6090)}, + } // Parse the storageRequest defined in the CR storageRequest, err := resource.ParseQuantity(instance.Spec.StorageRequest) @@ -156,6 +172,10 @@ func StatefulSet( httpbootEnvVars["KOLLA_CONFIG_STRATEGY"] = env.SetValue("COPY_ALWAYS") httpbootEnvVars["CONFIG_HASH"] = env.SetValue(configHash) + novncEnvVars := map[string]env.Setter{} + novncEnvVars["KOLLA_CONFIG_STRATEGY"] = env.SetValue("COPY_ALWAYS") + novncEnvVars["CONFIG_HASH"] = env.SetValue(configHash) + ramdiskLogsEnvVars := map[string]env.Setter{} ramdiskLogsEnvVars["KOLLA_CONFIG_STRATEGY"] = env.SetValue("COPY_ALWAYS") ramdiskLogsEnvVars["CONFIG_HASH"] = env.SetValue(configHash) @@ -163,6 +183,7 @@ func StatefulSet( volumes := GetVolumes(ctx, instance) conductorVolumeMounts := GetVolumeMounts("ironic-conductor") httpbootVolumeMounts := GetVolumeMounts("httpboot") + novncVolumeMounts := GetVolumeMounts("novnc") dnsmasqVolumeMounts := GetVolumeMounts("dnsmasq") ramdiskLogsVolumeMounts := GetVolumeMounts("ramdisk-logs") initVolumeMounts := GetInitVolumeMounts(instance) @@ -175,6 +196,7 @@ func StatefulSet( dnsmasqVolumeMounts = append(dnsmasqVolumeMounts, instance.Spec.TLS.CreateVolumeMounts(nil)...) ramdiskLogsVolumeMounts = append(ramdiskLogsVolumeMounts, instance.Spec.TLS.CreateVolumeMounts(nil)...) initVolumeMounts = append(initVolumeMounts, instance.Spec.TLS.CreateVolumeMounts(nil)...) + novncVolumeMounts = append(novncVolumeMounts, instance.Spec.TLS.CreateVolumeMounts(nil)...) } resourceName := fmt.Sprintf("%s-%s", ironic.ServiceName, ironic.ConductorComponent) @@ -269,11 +291,29 @@ func StatefulSet( LivenessProbe: dnsmasqLivenessProbe, // StartupProbe: startupProbe, } - containers = []corev1.Container{ - conductorContainer, - httpbootContainer, - dnsmasqContainer, + containers = append(containers, dnsmasqContainer) + } + + if instance.Spec.GraphicalConsoles == "Enabled" { + // Only include the novnc container if graphical consoles are enabled + novncContainer := corev1.Container{ + Name: "novnc", + Command: []string{ + "/bin/bash", + }, + Args: args, + Image: instance.Spec.NoVNCProxyImage, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsUser, + }, + Env: env.MergeEnvs([]corev1.EnvVar{}, novncEnvVars), + VolumeMounts: novncVolumeMounts, + Resources: instance.Spec.Resources, + ReadinessProbe: novncReadinessProbe, + LivenessProbe: novncLivenessProbe, + // StartupProbe: startupProbe, } + containers = append(containers, novncContainer) } // Default oslo.service graceful_shutdown_timeout is 60, so align with that @@ -346,6 +386,14 @@ func StatefulSet( // Build what the fully qualified Route hostname will be when the Route exists deployHTTPURL = "http://%(PodName)s-%(PodNamespace)s.%(IngressDomain)s/" } + novncProxyURL := "" + if instance.Spec.GraphicalConsoles == "Enabled" { + + novncProtocol := "http" + // TODO(stevebaker) detect if https should be used, and also for deployHTTPURL above + novncDomain := "%(PodName)s-novnc-%(PodNamespace)s.%(IngressDomain)s" + novncProxyURL = fmt.Sprintf("%s://%s/vnc_auto.html", novncProtocol, novncDomain) + } initContainerDetails := ironic.APIDetails{ ContainerImage: instance.Spec.ContainerImage, @@ -362,6 +410,7 @@ func StatefulSet( ConductorInit: true, Privileged: true, DeployHTTPURL: deployHTTPURL, + NoVNCProxyURL: novncProxyURL, IngressDomain: ingressDomain, ProvisionNetwork: instance.Spec.ProvisionNetwork, } diff --git a/templates/common/config/ironic.conf b/templates/common/config/ironic.conf index e4f36f9e..50172efd 100644 --- a/templates/common/config/ironic.conf +++ b/templates/common/config/ironic.conf @@ -2,7 +2,6 @@ enabled_hardware_types=ipmi,idrac,irmc,fake-hardware,redfish,manual-management,ilo,ilo5 enabled_bios_interfaces=no-bios,redfish,idrac-redfish,irmc,ilo enabled_boot_interfaces=ipxe,ilo-ipxe,pxe,ilo-pxe,fake,redfish-virtual-media,idrac-redfish-virtual-media,ilo-virtual-media -enabled_console_interfaces=ipmitool-socat,ilo,no-console,fake enabled_deploy_interfaces=direct,fake,ramdisk,custom-agent default_deploy_interface=direct enabled_inspect_interfaces=inspector,no-inspect,irmc,fake,redfish,ilo @@ -36,7 +35,6 @@ auth_strategy={{if .Standalone}}noauth{{else}}keystone{{end}} grub_config_path=EFI/BOOT/grub.cfg isolinux_bin=/usr/share/syslinux/isolinux.bin - [agent] deploy_logs_local_path=/var/lib/ironic/ramdisk-logs diff --git a/templates/ironicconductor/bin/init.sh b/templates/ironicconductor/bin/init.sh index 7ba097ed..95485233 100755 --- a/templates/ironicconductor/bin/init.sh +++ b/templates/ironicconductor/bin/init.sh @@ -85,4 +85,13 @@ if [ ! -d "/var/lib/ironic/ramdisk-logs" ]; then mkdir /var/lib/ironic/ramdisk-logs fi +NOVNC_PROXY_URL=$(python3 -c ' +import os + +url_template = os.environ.get("NoVNCProxyURL", "") +if url_template: + print(url_template % os.environ) +') +crudini --set ${INIT_CONFIG} vnc public_url ${NOVNC_PROXY_URL} + echo "Conductor init successfully completed" diff --git a/templates/ironicconductor/config/01-conductor.conf b/templates/ironicconductor/config/01-conductor.conf index 9190a1da..6ab7fba6 100644 --- a/templates/ironicconductor/config/01-conductor.conf +++ b/templates/ironicconductor/config/01-conductor.conf @@ -1,8 +1,16 @@ [DEFAULT] -# Default conductor configuration +enabled_console_interfaces={{if .GraphicalConsolesEnabled}}redfish-graphical,fake-graphical,{{end}}ipmitool-socat,ilo,no-console,fake [conductor] heartbeat_interval=20 heartbeat_timeout=120 allow_provisioning_in_maintenance=false {{ if .ConductorGroup }}conductor_group={{ .ConductorGroup }}{{ end }} + +{{if .GraphicalConsolesEnabled}} +[vnc] +enabled=True +container_provider=kubernetes +console_image={{ .ConsoleImage }} +read_only=false +{{end}} diff --git a/templates/ironicconductor/config/01-novnc.conf b/templates/ironicconductor/config/01-novnc.conf new file mode 100644 index 00000000..23730222 --- /dev/null +++ b/templates/ironicconductor/config/01-novnc.conf @@ -0,0 +1,2 @@ +[vnc] +enabled=True diff --git a/templates/ironicconductor/config/novnc-config.json b/templates/ironicconductor/config/novnc-config.json new file mode 100644 index 00000000..46b4e3f1 --- /dev/null +++ b/templates/ironicconductor/config/novnc-config.json @@ -0,0 +1,36 @@ +{ + "command": "/usr/bin/ironic-novncproxy --config-file /etc/ironic/ironic.conf --config-dir /etc/ironic/ironic.conf.d", + "config_files": [ + { + "source": "/var/lib/config-data/default/ironic.conf", + "dest": "/etc/ironic/ironic.conf", + "owner": "ironic", + "perm": "0600" + }, + { + "source": "/var/lib/config-data/default/01-novnc.conf", + "dest": "/etc/ironic/ironic.conf.d/01-novnc.conf", + "owner": "ironic", + "perm": "0600" + }, + { + "source": "/var/lib/config-data/custom/02-ironic-custom.conf", + "dest": "/etc/ironic/ironic.conf.d/02-ironic-custom.conf", + "owner": "ironic", + "perm": "0600" + }, + { + "source": "/var/lib/config-data/default/my.cnf", + "dest": "/etc/my.cnf", + "owner": "ironic", + "perm": "0644" + } + ], + "permissions": [ + { + "path": "/var/lib/ironic", + "owner": "ironic:ironic", + "recurse": true + } + ] +} diff --git a/tests/functional/ironicconductor_controller_test.go b/tests/functional/ironicconductor_controller_test.go index b78ad60b..ea7a2850 100644 --- a/tests/functional/ironicconductor_controller_test.go +++ b/tests/functional/ironicconductor_controller_test.go @@ -114,9 +114,10 @@ var _ = Describe("IronicConductor controller", func() { corev1.ConditionTrue, ) role := th.GetRole(ironicNames.ConductorRole) - Expect(role.Rules).To(HaveLen(2)) + Expect(role.Rules).To(HaveLen(3)) Expect(role.Rules[0].Resources).To(Equal([]string{"securitycontextconstraints"})) Expect(role.Rules[1].Resources).To(Equal([]string{"pods"})) + Expect(role.Rules[2].Resources).To(Equal([]string{"secrets"})) th.ExpectCondition( ironicNames.ConductorName, ConditionGetterFunc(IronicConductorConditionGetter),