diff --git a/chart/infra-server/static/flavors.yaml b/chart/infra-server/static/flavors.yaml index 644f65f3b..3201838f7 100644 --- a/chart/infra-server/static/flavors.yaml +++ b/chart/infra-server/static/flavors.yaml @@ -333,6 +333,18 @@ Defines a list of capabilities to explicitly enable. These capabilities are enabled in addition to the capabilities specified in the baseline capability set. Example: `["DeploymentConfig", "ImageRegistry"]` + - name: vm-os + description: Guest OS list for optional VMs (empty skips virt) + value: "" + kind: optional + help: | + Comma-separated guest OS list (`rhel8`, `rhel9`, or `rhel10`). Each + entry creates one VM. Empty skips OpenShift Virtualization. + Example: `rhel9,rhel9,rhel10`. + Non-empty lists require a nested-KVM `worker-node-type` (for example + `n2-standard-8`; not `e2`). Worker count is unchanged. + Roughly two VMs fit on three worker nodes. + artifacts: - name: kubeconfig description: Kube config for connecting to this cluster @@ -363,6 +375,9 @@ - name: cluster-console-password description: The password to login at the openshift console + - name: vm-access + description: SSH credentials for optional VMs (present when vm-os is non-empty) + ###################### # openshift-4-demo # ###################### @@ -683,6 +698,18 @@ Defines a list of capabilities to explicitly enable. These capabilities are enabled in addition to the capabilities specified in the baseline capability set. Example: `["DeploymentConfig", "ImageRegistry"]` + - name: vm-os + description: Guest OS list for optional VMs (empty skips virt) + value: "" + kind: optional + help: | + Comma-separated guest OS list (`rhel8`, `rhel9`, or `rhel10`). Each + entry creates one VM. Empty skips OpenShift Virtualization. + Example: `rhel9,rhel9,rhel10`. + Non-empty lists require a nested-KVM `worker-node-type` (for example + `n2-standard-8`; not `e2`). Worker count is unchanged. + Roughly two VMs fit on three worker nodes. + artifacts: - name: kubeconfig description: Kube config for connecting to this cluster @@ -713,6 +740,9 @@ - name: cluster-console-password description: The password to login at the openshift console + - name: vm-access + description: SSH credentials for optional VMs (present when vm-os is non-empty) + ##################### # AWS EKS # ##################### diff --git a/chart/infra-server/static/workflow-openshift-4.yaml b/chart/infra-server/static/workflow-openshift-4.yaml index a0430fce5..74866f881 100644 --- a/chart/infra-server/static/workflow-openshift-4.yaml +++ b/chart/infra-server/static/workflow-openshift-4.yaml @@ -34,6 +34,8 @@ spec: value: "vCurrent" - name: additional-enabled-capabilities value: "" + - name: vm-os + value: "" volumeClaimTemplates: - metadata: name: data @@ -46,6 +48,9 @@ spec: - name: credentials secret: secretName: openshift-4-gcp-service-account + - name: registry-pull-secret + secret: + secretName: infra-image-registry-pull-secret templates: - name: start @@ -91,6 +96,11 @@ spec: path: /data archive: tar: {} + - name: vm-access + path: /data/vm-access.md + optional: true + archive: + none: {} container: image: quay.io/stackrox-io/ci:automation-flavors-openshift-4-{{ .Chart.Annotations.automationFlavorsVersion }} imagePullPolicy: Always @@ -157,9 +167,14 @@ spec: value: '{{ "{{" }}workflow.parameters.baseline-capability-set{{ "}}" }}' - name: ADDITIONAL_ENABLED_CAPABILITIES value: '{{ "{{" }}workflow.parameters.additional-enabled-capabilities{{ "}}" }}' + - name: VM_OS + value: '{{ "{{" }}workflow.parameters.vm-os{{ "}}" }}' volumeMounts: - name: data mountPath: /data + - name: registry-pull-secret + mountPath: /infra-secrets/quay + readOnly: true - name: gather script: diff --git a/chart/infra-server/templates/openshift-4/secrets.yaml b/chart/infra-server/templates/openshift-4/secrets.yaml index 3fe49ca72..1f9aa6987 100644 --- a/chart/infra-server/templates/openshift-4/secrets.yaml +++ b/chart/infra-server/templates/openshift-4/secrets.yaml @@ -43,3 +43,18 @@ metadata: data: REDHAT_PULL_SECRET: |- {{ required ".Values.openshift_4__redhat_pull_secret_json is undefined" .Values.openshift_4__redhat_pull_secret_json }} + +--- + +# Workflows run in namespace default and mount this for the VM containerDisk +# pull. The infra-namespace copy is the server's imagePullSecret. +apiVersion: v1 +kind: Secret +type: kubernetes.io/dockerconfigjson + +metadata: + name: infra-image-registry-pull-secret + namespace: default + +data: + .dockerconfigjson: {{ template "pull-secret" .Values.pullSecrets.quay }} diff --git a/cmd/infractl/cli/upgrade/fancy.go b/cmd/infractl/cli/upgrade/fancy.go index 7888d2089..0865d3318 100644 --- a/cmd/infractl/cli/upgrade/fancy.go +++ b/cmd/infractl/cli/upgrade/fancy.go @@ -7,11 +7,11 @@ import ( ) type prettyCliUpgrade struct { - updatedFilename string + UpdatedFilename string `json:"updatedFilename"` } func (p prettyCliUpgrade) PrettyPrint(cmd *cobra.Command) { - cmd.Printf("Updated %s to match the infra server version\n", p.updatedFilename) + cmd.Printf("Updated %s to match the infra server version\n", p.UpdatedFilename) } func (p prettyCliUpgrade) PrettyJSONPrint(cmd *cobra.Command) error { diff --git a/pkg/service/cluster/cluster.go b/pkg/service/cluster/cluster.go index 11bd9436c..dc6ad10bc 100644 --- a/pkg/service/cluster/cluster.go +++ b/pkg/service/cluster/cluster.go @@ -352,7 +352,7 @@ func (s *clusterImpl) create(req *v1.CreateClusterRequest, owner, eventID string // insufficient or superfluous parameters. workflowParams, err := checkAndEnrichParameters(flav.Parameters, req.Parameters) if err != nil { - return nil, err + return nil, status.Errorf(codes.InvalidArgument, "%v", err) } workflow.Spec.Arguments.Parameters = workflowParams @@ -977,5 +977,12 @@ func checkAndEnrichParameters(flavorParams map[string]*v1.Parameter, requestPara } } + if err := validateVirtWorkerNodeType( + workflowParameterValue(allParams, "vm-os"), + workflowParameterValue(allParams, "worker-node-type"), + ); err != nil { + return nil, err + } + return allParams, nil } diff --git a/pkg/service/cluster/helpers.go b/pkg/service/cluster/helpers.go index 1c19c0fc2..ddd37909a 100644 --- a/pkg/service/cluster/helpers.go +++ b/pkg/service/cluster/helpers.go @@ -17,6 +17,18 @@ import ( "k8s.io/apimachinery/pkg/selection" ) +// virtNestedKVMWorkerPrefixes are GCP series that expose nested KVM. +// E2, ARM, AMD (except n4d), and memory-optimized series do not: +// https://cloud.google.com/compute/docs/instances/nested-virtualization/overview +var virtNestedKVMWorkerPrefixes = []string{ + "a2-", "a3-", "a4-", + "c2-", "c3-", "c4-", "c4n-", + "g2-", + "h3-", + "n1-", "n2-", "n4-", "n4d-", + "z3-", +} + func getClusterIDFromWorkflow(workflow *v1alpha1.Workflow) string { clusterID := GetClusterID(workflow) if clusterID == "" { @@ -296,6 +308,61 @@ func emailToLabelValue(email string) string { return result } +// parseVMOSList splits a comma-separated guest OS list. Empty input means no VMs. +func parseVMOSList(vmOS string) ([]string, error) { + if strings.TrimSpace(vmOS) == "" { + return nil, nil + } + var oses []string + for token := range strings.SplitSeq(vmOS, ",") { + os := strings.ToLower(strings.TrimSpace(token)) + if os == "" { + return nil, fmt.Errorf("vm-os contains an empty entry") + } + switch os { + case "rhel8", "rhel9", "rhel10": + oses = append(oses, os) + default: + return nil, fmt.Errorf("unsupported vm-os %q (valid values: rhel8, rhel9, rhel10)", os) + } + } + return oses, nil +} + +// validateVirtWorkerNodeType rejects machine types that lack nested KVM when any VM is requested. +func validateVirtWorkerNodeType(vmOS, workerType string) error { + oses, err := parseVMOSList(vmOS) + if err != nil { + return err + } + if len(oses) == 0 { + return nil + } + if workerTypeHasNestedKVM(workerType) { + return nil + } + return fmt.Errorf("vm-os requires a worker-node-type with nested kvm (for example n2-standard-8), got %q", workerType) +} + +func workerTypeHasNestedKVM(workerType string) bool { + t := strings.ToLower(workerType) + for _, prefix := range virtNestedKVMWorkerPrefixes { + if strings.HasPrefix(t, prefix) { + return true + } + } + return false +} + +func workflowParameterValue(params []v1alpha1.Parameter, name string) string { + for _, p := range params { + if p.Name == name { + return p.GetValue() + } + } + return "" +} + // validateClusterID validates that a cluster ID meets Kubernetes label value requirements. // Kubernetes label values must match ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9] and be at most 63 characters. func validateClusterID(clusterID string) error { diff --git a/pkg/service/cluster/helpers_test.go b/pkg/service/cluster/helpers_test.go index f0a329f18..69bb96309 100644 --- a/pkg/service/cluster/helpers_test.go +++ b/pkg/service/cluster/helpers_test.go @@ -2,6 +2,7 @@ package cluster import ( "regexp" + "slices" "strings" "testing" @@ -308,3 +309,229 @@ func TestValidateClusterID(t *testing.T) { }) } } + +func TestParseVMOSList(t *testing.T) { + tests := []struct { + name string + vmOS string + want []string + wantErr string + }{ + {name: "empty", vmOS: ""}, + {name: "whitespace", vmOS: " "}, + {name: "single", vmOS: "rhel9", want: []string{"rhel9"}}, + {name: "rhel8", vmOS: "rhel8", want: []string{"rhel8"}}, + {name: "mixed with spaces", vmOS: "rhel8, rhel9, rhel10", want: []string{"rhel8", "rhel9", "rhel10"}}, + {name: "uppercase", vmOS: "RHEL9", want: []string{"rhel9"}}, + {name: "empty entry", vmOS: "rhel9,,rhel10", wantErr: "empty entry"}, + {name: "trailing comma", vmOS: "rhel9,", wantErr: "empty entry"}, + {name: "unsupported", vmOS: "rhel7", wantErr: "unsupported vm-os"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseVMOSList(tt.vmOS) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got none", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("parseVMOSList(%q) = %q, want %q", tt.vmOS, got, tt.want) + } + }) + } +} + +func TestValidateVirtWorkerNodeType(t *testing.T) { + tests := []struct { + name string + vmOS string + workerType string + wantErr bool + }{ + { + name: "empty vm-os default e2", + vmOS: "", + workerType: "e2-standard-8", + }, + { + name: "whitespace vm-os", + vmOS: " ", + workerType: "e2-standard-8", + }, + { + name: "rhel9 on n2-standard-8", + vmOS: "rhel9", + workerType: "n2-standard-8", + }, + { + name: "list on n2-standard-4", + vmOS: "rhel9,rhel10", + workerType: "n2-standard-4", + }, + { + name: "uppercase os on n2", + vmOS: "RHEL9", + workerType: "n2-standard-8", + }, + { + name: "rhel9 on c3", + vmOS: "rhel9", + workerType: "c3-standard-8", + }, + { + name: "rhel9 on n4d (AMD exception)", + vmOS: "rhel9", + workerType: "n4d-standard-8", + }, + { + name: "rhel9 on default e2", + vmOS: "rhel9", + workerType: "e2-standard-8", + wantErr: true, + }, + { + name: "rhel9 on n2d (AMD)", + vmOS: "rhel9", + workerType: "n2d-standard-8", + wantErr: true, + }, + { + name: "rhel9 on t2a (ARM)", + vmOS: "rhel9", + workerType: "t2a-standard-8", + wantErr: true, + }, + { + name: "rhel9 on m3 (memory-optimized)", + vmOS: "rhel9", + workerType: "m3-ultramem-32", + wantErr: true, + }, + { + name: "rhel9 on empty worker type", + vmOS: "rhel9", + workerType: "", + wantErr: true, + }, + { + name: "unsupported os", + vmOS: "debian", + workerType: "n2-standard-8", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVirtWorkerNodeType(tt.vmOS, tt.workerType) + if tt.wantErr && err == nil { + t.Errorf("validateVirtWorkerNodeType(%q, %q) expected error but got none", tt.vmOS, tt.workerType) + } + if !tt.wantErr && err != nil { + t.Errorf("validateVirtWorkerNodeType(%q, %q) expected no error but got: %v", tt.vmOS, tt.workerType, err) + } + }) + } +} + +func TestCheckAndEnrichParameters_VirtWorkerType(t *testing.T) { + flavorParams := map[string]*v1.Parameter{ + "name": {Name: "name"}, + "vm-os": { + Name: "vm-os", + Value: "", + Optional: true, + }, + "worker-node-type": { + Name: "worker-node-type", + Value: "e2-standard-8", + Optional: true, + }, + } + + tests := []struct { + name string + req map[string]string + wantErr string + wantWorkerType string + }{ + { + name: "vm-os omitted keeps default e2", + req: map[string]string{"name": "abc"}, + wantWorkerType: "e2-standard-8", + }, + { + name: "rhel9 with n2-standard-8", + req: map[string]string{ + "name": "abc", + "vm-os": "rhel9", + "worker-node-type": "n2-standard-8", + }, + wantWorkerType: "n2-standard-8", + }, + { + name: "list with n2-standard-4", + req: map[string]string{ + "name": "abc", + "vm-os": "rhel9,rhel10", + "worker-node-type": "n2-standard-4", + }, + wantWorkerType: "n2-standard-4", + }, + { + name: "rhel9 with default e2", + req: map[string]string{ + "name": "abc", + "vm-os": "rhel9", + }, + wantErr: "vm-os requires a worker-node-type with nested kvm", + }, + { + name: "rhel9 with explicit e2", + req: map[string]string{ + "name": "abc", + "vm-os": "rhel9", + "worker-node-type": "e2-standard-8", + }, + wantErr: "vm-os requires a worker-node-type with nested kvm", + }, + { + name: "unsupported os", + req: map[string]string{ + "name": "abc", + "vm-os": "debian", + "worker-node-type": "n2-standard-8", + }, + wantErr: "unsupported vm-os", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := checkAndEnrichParameters(flavorParams, tt.req) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got none", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotType := workflowParameterValue(got, "worker-node-type"); gotType != tt.wantWorkerType { + t.Errorf("worker-node-type = %q, want %q", gotType, tt.wantWorkerType) + } + }) + } +} diff --git a/ui/src/containers/LaunchClusterPage/ClusterForm.tsx b/ui/src/containers/LaunchClusterPage/ClusterForm.tsx index cd305c691..357fe2738 100644 --- a/ui/src/containers/LaunchClusterPage/ClusterForm.tsx +++ b/ui/src/containers/LaunchClusterPage/ClusterForm.tsx @@ -13,6 +13,7 @@ import * as yup from 'yup'; import { mapValues } from 'lodash'; import { Button, Form } from '@patternfly/react-core'; import Markdown from 'react-markdown'; +import { AxiosError } from 'axios'; import { ClusterServiceApi, V1Parameter } from 'generated/client'; import configuration from 'client/configuration'; @@ -26,6 +27,17 @@ import { CloudUploadAltIcon } from '@patternfly/react-icons'; const clusterService = new ClusterServiceApi(configuration); +type GatewayErrorBody = { + message?: string; + error?: string; +}; + +// grpc-gateway JSONPb returns google.rpc.Status as { code, message }. +function serverErrorMessage(err: AxiosError): string { + const data = err.response?.data; + return data?.message || data?.error || err.message || 'Cluster creation request failed'; +} + function helpByParameterName(name?: string): string { const help: { [key: string]: string } = { name: @@ -242,14 +254,7 @@ export default function ClusterForm({ Parameters: initialParameterValues, }; - const [error, setError] = useState<{ - message?: string; - response?: { - data?: { - error?: string; - }; - }; - }>(); + const [error, setError] = useState>(); const onSubmit = async ( values: FormikValues, @@ -264,9 +269,8 @@ export default function ClusterForm({ const { id } = response.data; if (!id) throw new Error('Server returned empty cluster ID'); onClusterCreated(id); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - setError(e); + } catch (e) { + setError(e as AxiosError); } finally { actions.setSubmitting(false); } @@ -283,8 +287,7 @@ export default function ClusterForm({
{error && (
- {`[Server Error] ${error.message || 'Cluster creation request failed'}`} - {error.response?.data?.error && ` (${error.response.data.error})`} + {`[Server Error] ${serverErrorMessage(error)}`}
)}