github.com/verrazzano/verrazzano-monitoring-operator@v0.0.30/pkg/resources/helper.go (about) 1 // Copyright (C) 2020, 2022, Oracle and/or its affiliates. 2 // Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. 3 4 package resources 5 6 import ( 7 "crypto/rand" 8 "fmt" 9 "math/big" 10 "os" 11 "regexp" 12 "strconv" 13 "strings" 14 15 vmcontrollerv1 "github.com/verrazzano/verrazzano-monitoring-operator/pkg/apis/vmcontroller/v1" 16 "github.com/verrazzano/verrazzano-monitoring-operator/pkg/config" 17 "github.com/verrazzano/verrazzano-monitoring-operator/pkg/constants" 18 19 corev1 "k8s.io/api/core/v1" 20 netv1 "k8s.io/api/networking/v1" 21 "k8s.io/apimachinery/pkg/api/resource" 22 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 "k8s.io/apimachinery/pkg/runtime/schema" 24 "k8s.io/apimachinery/pkg/util/intstr" 25 ) 26 27 var ( 28 runes = []rune("abcdefghijklmnopqrstuvwxyz0123456789") 29 ) 30 31 const ( 32 serviceClusterLocal = ".svc.cluster.local" 33 masterHTTPEndpoint = "VMO_MASTER_HTTP_ENDPOINT" 34 dashboardsHTTPEndpoint = "VMO_DASHBOARDS_HTTP_ENDPOINT" 35 OpenSearchIngestCmdTmpl = `#!/usr/bin/env bash -e 36 set -euo pipefail 37 %s 38 /usr/local/bin/docker-entrypoint.sh` 39 OpenSearchDashboardCmdTmpl = `#!/usr/bin/env bash -e 40 %s 41 /usr/local/bin/opensearch-dashboards-docker` 42 containerCmdTmpl = `#!/usr/bin/env bash -e 43 # Updating elastic search keystore with keys 44 # required for the repository-s3 plugin 45 if [ "${OBJECT_STORE_ACCESS_KEY_ID:-}" ]; then 46 echo "Updating object store access key..." 47 echo $OBJECT_STORE_ACCESS_KEY_ID | /usr/share/opensearch/bin/opensearch-keystore add --stdin --force s3.client.default.access_key; 48 fi 49 if [ "${OBJECT_STORE_SECRET_KEY_ID:-}" ]; then 50 echo "Updating object store secret key..." 51 echo $OBJECT_STORE_SECRET_KEY_ID | /usr/share/opensearch/bin/opensearch-keystore add --stdin --force s3.client.default.secret_key; 52 fi 53 54 %s 55 56 %s 57 58 /usr/local/bin/docker-entrypoint.sh` 59 60 jvmOptsDisableCmd = ` 61 # Disable the jvm heap settings in jvm.options 62 echo "Commenting out java heap settings in jvm.options..." 63 sed -i -e '/^-Xms/s/^/#/g' -e '/^-Xmx/s/^/#/g' config/jvm.options 64 ` 65 OSPluginsInstallTmpl = ` 66 set -euo pipefail 67 # Install OS plugins that are not bundled with OS 68 %s 69 ` 70 OSPluginsInstallCmd = ` 71 /usr/share/opensearch/bin/opensearch-plugin install -b %s 72 ` 73 OSDashboardPluginsInstallCmd = ` 74 /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin install %s 75 ` 76 ) 77 78 // CopyImmutableEnvVars copies the initial master node environment variable from an existing container to an expected container 79 // cluster.initial_master_nodes shouldn't be changed after it's set. 80 func CopyImmutableEnvVars(expected, existing []corev1.Container, containerName string) { 81 getContainer := func(containers []corev1.Container) (int, *corev1.Container) { 82 for idx, c := range containers { 83 if c.Name == containerName { 84 return idx, &c 85 } 86 } 87 return -1, nil 88 } 89 90 // Initial master nodes should not change 91 idx, currentContainer := getContainer(expected) 92 _, existingContainer := getContainer(existing) 93 if currentContainer == nil || existingContainer == nil { 94 return 95 } 96 97 getAndSetVar := func(varName string) { 98 envVar := GetEnvVar(existingContainer, varName) 99 if envVar != nil { 100 SetEnvVar(currentContainer, envVar) 101 } 102 } 103 104 getAndSetVar(constants.ClusterInitialMasterNodes) 105 getAndSetVar("node.roles") 106 expected[idx] = *currentContainer 107 } 108 109 // GetEnvVar retrieves a container EnvVar if it is present 110 func GetEnvVar(container *corev1.Container, name string) *corev1.EnvVar { 111 for _, envVar := range container.Env { 112 if envVar.Name == name { 113 return &envVar 114 } 115 } 116 return nil 117 } 118 119 // SetEnvVar sets a container EnvVar, overriding if it was laready present 120 func SetEnvVar(container *corev1.Container, envVar *corev1.EnvVar) { 121 for idx, env := range container.Env { 122 if env.Name == envVar.Name { 123 container.Env[idx] = *envVar 124 return 125 } 126 } 127 container.Env = append(container.Env, *envVar) 128 } 129 130 func GetOpenSearchHTTPEndpoint(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) string { 131 // The master HTTP port may be overridden if necessary. 132 // This can be useful in situations where the VMO does not have direct access to the cluster service, 133 // such as when you are using port-forwarding. 134 masterServiceEndpoint := os.Getenv(masterHTTPEndpoint) 135 if len(masterServiceEndpoint) > 0 { 136 return masterServiceEndpoint 137 } 138 return fmt.Sprintf("http://%s-http.%s%s:%d", 139 GetMetaName(vmo.Name, config.ElasticsearchMaster.Name), 140 vmo.Namespace, 141 serviceClusterLocal, 142 constants.OSHTTPPort) 143 } 144 145 func GetOpenSearchDashboardsHTTPEndpoint(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) string { 146 dashboardsServiceEndpoint := os.Getenv(dashboardsHTTPEndpoint) 147 if len(dashboardsServiceEndpoint) > 0 { 148 return dashboardsServiceEndpoint 149 } 150 return fmt.Sprintf("http://%s.%s%s:%d", GetMetaName(vmo.Name, config.OpenSearchDashboards.Name), 151 vmo.Namespace, 152 serviceClusterLocal, 153 constants.OSDashboardsHTTPPort) 154 } 155 156 func GetOwnerLabels(owner string) map[string]string { 157 return map[string]string{ 158 "owner": owner, 159 } 160 } 161 162 // GetNewRandomID generates a random alphanumeric string of the format [a-z0-9]{size} 163 func GetNewRandomID(size int) (string, error) { 164 builder := strings.Builder{} 165 for i := 0; i < size; i++ { 166 idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(runes)))) 167 if err != nil { 168 return "", err 169 } 170 builder.WriteRune(runes[idx.Int64()]) 171 } 172 return builder.String(), nil 173 } 174 175 // GetMetaName returns name 176 func GetMetaName(vmoName string, componentName string) string { 177 return constants.VMOServiceNamePrefix + vmoName + "-" + componentName 178 } 179 180 // GetMetaLabels returns k8s-app and vmo lables 181 func GetMetaLabels(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) map[string]string { 182 return map[string]string{constants.K8SAppLabel: constants.VMOGroup, constants.VMOLabel: vmo.Name} 183 } 184 185 // GetCompLabel returns a component value for opensearch 186 func GetCompLabel(componentName string) string { 187 var componentLabelValue string 188 switch componentName { 189 case config.ElasticsearchMaster.Name, config.ElasticsearchData.Name, config.ElasticsearchIngest.Name, config.OpensearchIngest.Name: 190 componentLabelValue = constants.ComponentOpenSearchValue 191 default: 192 componentLabelValue = componentName 193 } 194 return componentLabelValue 195 } 196 197 // DeepCopyMap performs a deepcopy of a map 198 func DeepCopyMap(srcMap map[string]string) map[string]string { 199 result := make(map[string]string, len(srcMap)) 200 for k, v := range srcMap { 201 result[k] = v 202 } 203 return result 204 } 205 206 // GetSpecID returns app label 207 func GetSpecID(vmoName string, componentName string) map[string]string { 208 return map[string]string{constants.ServiceAppLabel: vmoName + "-" + componentName} 209 } 210 211 // GetServicePort returns service port 212 func GetServicePort(componentDetails config.ComponentDetails) corev1.ServicePort { 213 return corev1.ServicePort{Name: "http-" + componentDetails.Name, Port: int32(componentDetails.Port)} 214 } 215 216 // GetOwnerReferences returns owner references 217 func GetOwnerReferences(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) []metav1.OwnerReference { 218 var ownerReferences []metav1.OwnerReference 219 if vmo.Spec.CascadingDelete { 220 ownerReferences = []metav1.OwnerReference{ 221 *metav1.NewControllerRef(vmo, schema.GroupVersionKind{ 222 Group: vmcontrollerv1.SchemeGroupVersion.Group, 223 Version: vmcontrollerv1.SchemeGroupVersion.Version, 224 Kind: constants.VMOKind, 225 }), 226 } 227 } 228 return ownerReferences 229 } 230 231 // SliceContains returns whether or not the given slice contains the given string 232 func SliceContains(slice []string, value string) bool { 233 for _, a := range slice { 234 if a == value { 235 return true 236 } 237 } 238 return false 239 } 240 241 // GetStorageElementForComponent returns storage for a given component 242 func GetStorageElementForComponent(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance, component *config.ComponentDetails) (storage *vmcontrollerv1.Storage) { 243 switch component.Name { 244 case config.Grafana.Name: 245 return &vmo.Spec.Grafana.Storage 246 case config.ElasticsearchData.Name: 247 return vmo.Spec.Elasticsearch.DataNode.Storage 248 } 249 return nil 250 } 251 252 // GetReplicasForComponent returns number of replicas for a given component 253 func GetReplicasForComponent(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance, component *config.ComponentDetails) (replicas int32) { 254 switch component.Name { 255 case config.Grafana.Name: 256 return int32(1) 257 case config.ElasticsearchData.Name: 258 return vmo.Spec.Elasticsearch.DataNode.Replicas 259 } 260 return 0 261 } 262 263 // GetNextStringInSequence returns the next string in the incremental sequence given a string 264 func GetNextStringInSequence(name string) string { 265 tokens := strings.Split(name, "-") 266 if len(tokens) < 2 { 267 return name + "-1" // Starting a new sequence 268 } 269 number, err := strconv.Atoi(tokens[len(tokens)-1]) 270 if err != nil { 271 return name + "-1" // Starting a new sequence 272 } 273 tokens[len(tokens)-1] = strconv.Itoa(number + 1) 274 return strings.Join(tokens, "-") 275 } 276 277 // CreateContainerElement creates a generic container element for the given component of the given VMO object. 278 func CreateContainerElement(vmoStorage *vmcontrollerv1.Storage, 279 vmoResources *vmcontrollerv1.Resources, componentDetails config.ComponentDetails) corev1.Container { 280 281 var volumeMounts []corev1.VolumeMount 282 if vmoStorage != nil && vmoStorage.PvcNames != nil && vmoStorage.Size != "" { 283 volumeMounts = append(volumeMounts, corev1.VolumeMount{MountPath: componentDetails.DataDir, Name: constants.StorageVolumeName}) 284 } 285 286 limitResourceList := corev1.ResourceList{} 287 requestResourceList := corev1.ResourceList{} 288 if vmoResources != nil { 289 if vmoResources.LimitCPU != "" { 290 limitResourceList[corev1.ResourceCPU] = resource.MustParse(vmoResources.LimitCPU) 291 } 292 if vmoResources.LimitMemory != "" { 293 limitResourceList[corev1.ResourceMemory] = resource.MustParse(vmoResources.LimitMemory) 294 } 295 if vmoResources.RequestCPU != "" { 296 requestResourceList[corev1.ResourceCPU] = resource.MustParse(vmoResources.RequestCPU) 297 } 298 if vmoResources.RequestMemory != "" { 299 requestResourceList[corev1.ResourceMemory] = resource.MustParse(vmoResources.RequestMemory) 300 } 301 } 302 303 var livenessProbe *corev1.Probe 304 if componentDetails.LivenessHTTPPath != "" { 305 livenessProbe = &corev1.Probe{ 306 ProbeHandler: corev1.ProbeHandler{ 307 HTTPGet: &corev1.HTTPGetAction{ 308 Path: componentDetails.LivenessHTTPPath, 309 Port: intstr.IntOrString{IntVal: int32(componentDetails.Port)}, 310 Scheme: "HTTP", 311 }, 312 }, 313 } 314 } 315 316 var readinessProbe *corev1.Probe 317 if componentDetails.ReadinessHTTPPath != "" { 318 readinessProbe = &corev1.Probe{ 319 ProbeHandler: corev1.ProbeHandler{ 320 HTTPGet: &corev1.HTTPGetAction{ 321 Path: componentDetails.ReadinessHTTPPath, 322 Port: intstr.IntOrString{IntVal: int32(componentDetails.Port)}, 323 Scheme: "HTTP", 324 }, 325 }, 326 } 327 } 328 return corev1.Container{ 329 Name: componentDetails.Name, 330 Image: componentDetails.Image, 331 ImagePullPolicy: constants.DefaultImagePullPolicy, 332 SecurityContext: &corev1.SecurityContext{ 333 Privileged: &componentDetails.Privileged, 334 }, 335 Ports: []corev1.ContainerPort{{Name: componentDetails.Name, ContainerPort: int32(componentDetails.Port)}}, 336 Resources: corev1.ResourceRequirements{ 337 Requests: requestResourceList, 338 Limits: limitResourceList, 339 }, 340 VolumeMounts: volumeMounts, 341 LivenessProbe: livenessProbe, 342 ReadinessProbe: readinessProbe, 343 } 344 } 345 346 // CreateZoneAntiAffinityElement return an Affinity resource for a given VMO instance and component 347 func CreateZoneAntiAffinityElement(vmoName string, component string) *corev1.Affinity { 348 return &corev1.Affinity{ 349 PodAntiAffinity: &corev1.PodAntiAffinity{ 350 PreferredDuringSchedulingIgnoredDuringExecution: []corev1.WeightedPodAffinityTerm{ 351 { 352 Weight: 100, 353 PodAffinityTerm: corev1.PodAffinityTerm{ 354 LabelSelector: &metav1.LabelSelector{ 355 MatchLabels: GetSpecID(vmoName, component), 356 }, 357 TopologyKey: constants.K8sZoneLabel, 358 }, 359 }, 360 }, 361 }, 362 } 363 } 364 365 // GetElasticsearchMasterInitContainer return an Elasticsearch Init container for the master. This changes ownership of 366 // the ES directory permissions needed to access PV volume data. Also set the max map count. 367 func GetElasticsearchMasterInitContainer() *corev1.Container { 368 elasticsearchInitContainer := CreateContainerElement(nil, nil, config.ElasticsearchInit) 369 elasticsearchInitContainer.Command = 370 []string{"sh", "-c", "chown -R 1000:1000 /usr/share/opensearch/data; sysctl -w vm.max_map_count=262144"} 371 elasticsearchInitContainer.Ports = nil 372 return &elasticsearchInitContainer 373 } 374 375 // GetElasticsearchInitContainer returns an Elasticsearch Init container object 376 func GetElasticsearchInitContainer() *corev1.Container { 377 elasticsearchInitContainer := CreateContainerElement(nil, nil, config.ElasticsearchInit) 378 elasticsearchInitContainer.Args = []string{"sysctl", "-w", "vm.max_map_count=262144"} 379 elasticsearchInitContainer.Ports = nil 380 return &elasticsearchInitContainer 381 } 382 383 // NewVal return a pointer to an int32 given an int32 value 384 func NewVal(value int32) *int32 { 385 var val = value 386 return &val 387 } 388 389 // New64Val return a pointer to an int64 given an int64 value 390 func New64Val(value int64) *int64 { 391 var val = value 392 return &val 393 } 394 395 // oidcProxyName returns OIDC Proxy name of the component. ex. es-ingest-oidc 396 func oidcProxyName(componentName string) string { 397 return componentName + "-" + config.OidcProxy.Name 398 } 399 400 // OidcProxyMetaName returns OIDC Proxy meta name of the component. ex. vmi-system-es-ingest-oidc 401 func OidcProxyMetaName(vmoName string, component string) string { 402 return GetMetaName(vmoName, oidcProxyName(component)) 403 } 404 405 // AuthProxyMetaName returns Auth Proxy service name 406 // TESTING: should be passed in from hel chart as s 407 func AuthProxyMetaName() string { 408 return os.Getenv("AUTH_PROXY_SERVICE_NAME") 409 } 410 411 // AuthProxyMetaName returns Auth Proxy service name 412 func AuthProxyPort() string { 413 return os.Getenv("AUTH_PROXY_SERVICE_PORT") 414 } 415 416 // OidcProxyConfigName returns OIDC Proxy ConfigMap name of the component. ex. vmi-system-es-ingest-oidc-config 417 func OidcProxyConfigName(vmo string, component string) string { 418 return OidcProxyMetaName(vmo, component) + "-config" 419 } 420 421 // OidcProxyIngressHost returns OIDC Proxy ingress host. 422 func OidcProxyIngressHost(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance, component *config.ComponentDetails) string { 423 host := component.Name 424 if component.EndpointName != "" { 425 host = component.EndpointName 426 } 427 return fmt.Sprintf("%s.%s", host, vmo.Spec.URI) 428 } 429 430 // CreateOidcProxy creates OpenID Connect (OIDC) proxy container and config Volume 431 func CreateOidcProxy(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance, vmoResources *vmcontrollerv1.Resources, component *config.ComponentDetails) ([]corev1.Volume, *corev1.Container) { 432 var volumes []corev1.Volume 433 configName := OidcProxyConfigName(vmo.Name, component.Name) 434 var defaultMode int32 = 0755 435 configVolume := corev1.Volume{Name: configName, VolumeSource: corev1.VolumeSource{ 436 ConfigMap: &corev1.ConfigMapVolumeSource{ 437 LocalObjectReference: corev1.LocalObjectReference{Name: configName}, 438 DefaultMode: &defaultMode, 439 }, 440 }} 441 oidcProxContainer := CreateContainerElement(nil, vmoResources, *component.OidcProxy) 442 oidcProxContainer.Command = []string{"/bootstrap/startup.sh"} 443 oidcProxContainer.VolumeMounts = []corev1.VolumeMount{{Name: configName, MountPath: "/bootstrap"}} 444 if len(vmo.Labels[constants.ClusterNameData]) > 0 { 445 secretVolume := corev1.Volume{Name: "secret", VolumeSource: corev1.VolumeSource{ 446 Secret: &corev1.SecretVolumeSource{ 447 SecretName: constants.MCRegistrationSecret, 448 }, 449 }} 450 volumes = append(volumes, secretVolume) 451 oidcProxContainer.VolumeMounts = append(oidcProxContainer.VolumeMounts, corev1.VolumeMount{Name: "secret", MountPath: "/secret"}) 452 } 453 volumes = append(volumes, configVolume) 454 return volumes, &oidcProxContainer 455 } 456 457 // getIngressRule returns the ingressRule with the provided ingress host 458 func GetIngressRule(ingressHost string) netv1.IngressRule { 459 ingressRule := netv1.IngressRule{ 460 Host: ingressHost, 461 IngressRuleValue: netv1.IngressRuleValue{ 462 HTTP: &netv1.HTTPIngressRuleValue{ 463 Paths: []netv1.HTTPIngressPath{ 464 { 465 Path: "/()(.*)", 466 Backend: netv1.IngressBackend{ 467 Service: &netv1.IngressServiceBackend{ 468 Port: netv1.ServiceBackendPort{ 469 Number: int32(8775), 470 }, 471 }, 472 }, 473 }, 474 }, 475 }, 476 }, 477 } 478 return ingressRule 479 } 480 481 // OidcProxyService creates OidcProxy Service 482 func OidcProxyService(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance, component *config.ComponentDetails) *corev1.Service { 483 return &corev1.Service{ 484 ObjectMeta: metav1.ObjectMeta{ 485 Labels: GetMetaLabels(vmo), 486 Name: OidcProxyMetaName(vmo.Name, component.Name), 487 Namespace: vmo.Namespace, 488 OwnerReferences: GetOwnerReferences(vmo), 489 }, 490 Spec: corev1.ServiceSpec{ 491 Type: vmo.Spec.ServiceType, 492 Selector: GetSpecID(vmo.Name, component.Name), 493 Ports: []corev1.ServicePort{{Name: "oidc", Port: int32(constants.OidcProxyPort)}}, 494 }, 495 } 496 } 497 498 // convertToRegexp converts index pattern to a regular expression pattern. 499 func ConvertToRegexp(pattern string) string { 500 var result strings.Builder 501 // Add ^ at the beginning 502 result.WriteString("^") 503 for i, literal := range strings.Split(pattern, "*") { 504 505 // Replace * with .* 506 if i > 0 { 507 result.WriteString(".*") 508 } 509 510 // Quote any regular expression meta characters in the 511 // literal text. 512 result.WriteString(regexp.QuoteMeta(literal)) 513 } 514 // Add $ at the end 515 result.WriteString("$") 516 return result.String() 517 } 518 519 // CreateOpenSearchContainerCMD creates the CMD for OpenSearch containers. 520 // The resulting CMD contains 521 // command to comment java heap settings in config/jvm/options if input javaOpts is non-empty 522 // OS plugins installation commands if OpenSearch plugins are provided 523 // and contains java min/max heap settings 524 func CreateOpenSearchContainerCMD(javaOpts string, plugins []string) string { 525 pluginsInstallTmpl := GetOSPluginsInstallTmpl(plugins, OSPluginsInstallCmd) 526 if javaOpts != "" { 527 jvmOptsPair := strings.Split(javaOpts, " ") 528 minHeapMemory := "" 529 maxHeapMemory := "" 530 for _, opt := range jvmOptsPair { 531 if strings.HasPrefix(opt, "-Xms") { 532 minHeapMemory = opt 533 } 534 535 if strings.HasPrefix(opt, "-Xmx") { 536 maxHeapMemory = opt 537 } 538 } 539 540 if minHeapMemory != "" && maxHeapMemory != "" { 541 return fmt.Sprintf(containerCmdTmpl, jvmOptsDisableCmd, pluginsInstallTmpl) 542 } 543 } 544 545 return fmt.Sprintf(containerCmdTmpl, "", pluginsInstallTmpl) 546 } 547 548 // GetOpenSearchPluginList retrieves the list of plugins provided in the VMI CRD for OpenSearch. 549 // GIVEN VMI CRD 550 // RETURN the list of provided os plugins. If there is no plugins in VMI CRD, empty list is returned. 551 func GetOpenSearchPluginList(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) []string { 552 if vmo.Spec.Elasticsearch.Enabled && 553 vmo.Spec.Elasticsearch.Plugins.Enabled && 554 len(vmo.Spec.Elasticsearch.Plugins.InstallList) > 0 { 555 return vmo.Spec.Elasticsearch.Plugins.InstallList 556 } 557 return []string{} 558 } 559 560 // GetOSDashboardPluginList retrieves the list of plugins provided in the VMI CRD for OpenSearch dashboard. 561 // GIVEN VMI CRD 562 // RETURN the list of provided OSD plugins. If there is no plugin in VMI CRD, an empty list is returned. 563 func GetOSDashboardPluginList(vmo *vmcontrollerv1.VerrazzanoMonitoringInstance) []string { 564 if vmo.Spec.Kibana.Enabled && 565 vmo.Spec.Kibana.Plugins.Enabled && 566 len(vmo.Spec.Kibana.Plugins.InstallList) > 0 { 567 return vmo.Spec.Kibana.Plugins.InstallList 568 } 569 return []string{} 570 } 571 572 // GetOSPluginsInstallTmpl returns the OSPluginsInstallTmpl by updating it with the given plugins and plugins installation cmd. 573 func GetOSPluginsInstallTmpl(plugins []string, osPluginInstallCmd string) string { 574 var pluginsInstallTmpl string 575 for _, plugin := range plugins { 576 pluginsInstallTmpl += fmt.Sprintf(OSPluginsInstallTmpl, fmt.Sprintf(osPluginInstallCmd, plugin)) 577 } 578 return pluginsInstallTmpl 579 }