From 019768e9847e65a0d5de3cfb49e33fb5a5654101 Mon Sep 17 00:00:00 2001 From: Hardik Dodiya Date: Wed, 10 Jul 2024 14:29:42 +0000 Subject: [PATCH] Support IPXE Boot --- Dockerfile | 2 + api/v1alpha1/httpbootconfig_types.go | 6 - api/v1alpha1/ipxebootconfig_types.go | 77 +++++++ api/v1alpha1/zz_generated.deepcopy.go | 104 +++++++++ cmd/main.go | 95 +++++++- .../boot.ironcore.dev_ipxebootconfigs.yaml | 118 ++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/ipxebootconfig_editor_role.yaml | 31 +++ config/rbac/ipxebootconfig_viewer_role.yaml | 27 +++ config/rbac/role.yaml | 43 ++++ config/samples/httpbootconfig.yaml | 4 +- config/samples/ipxebootconfig.yaml | 19 ++ config/samples/kustomization.yaml | 1 + go.mod | 13 +- go.sum | 25 ++ internal/controller/helper.go | 23 ++ .../controller/ipxebootconfig_controller.go | 149 ++++++++++++ .../ipxebootconfig_controller_test.go | 69 ++++++ .../serverbootconfiguration_pxe_controller.go | 199 ++++++++++++++++ ...erbootconfiguration_pxe_controller_test.go | 98 ++++++++ internal/controller/suite_test.go | 6 + server/bootserver.go | 172 +++++++++++++- server/imageproxyserver.go | 216 ++++++++++++++++++ templates/ipxe-script.tpl | 13 ++ 24 files changed, 1497 insertions(+), 14 deletions(-) create mode 100644 api/v1alpha1/ipxebootconfig_types.go create mode 100644 config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml create mode 100644 config/rbac/ipxebootconfig_editor_role.yaml create mode 100644 config/rbac/ipxebootconfig_viewer_role.yaml create mode 100644 config/samples/ipxebootconfig.yaml create mode 100644 internal/controller/helper.go create mode 100644 internal/controller/ipxebootconfig_controller.go create mode 100644 internal/controller/ipxebootconfig_controller_test.go create mode 100644 internal/controller/serverbootconfiguration_pxe_controller.go create mode 100644 internal/controller/serverbootconfiguration_pxe_controller_test.go create mode 100644 server/imageproxyserver.go create mode 100644 templates/ipxe-script.tpl diff --git a/Dockerfile b/Dockerfile index 2443ff4d..df006c17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/ COPY server/ server/ +COPY templates/ templates/ # Build # the GOARCH has not a default value to allow the binary be built according to the host where the command @@ -29,6 +30,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o ma FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=builder /workspace/manager . +COPY --from=builder /workspace/templates/ templates/ USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/api/v1alpha1/httpbootconfig_types.go b/api/v1alpha1/httpbootconfig_types.go index b87f4e2d..640f2745 100644 --- a/api/v1alpha1/httpbootconfig_types.go +++ b/api/v1alpha1/httpbootconfig_types.go @@ -53,12 +53,6 @@ type HTTPBootConfigList struct { Items []HTTPBootConfig `json:"items"` } -const ( - DefaultIgnitionKey = "ignition" - SystemUUIDIndexKey = "spec.systemUUID" - SystemIPIndexKey = "spec.systemIPs" -) - func init() { SchemeBuilder.Register(&HTTPBootConfig{}, &HTTPBootConfigList{}) } diff --git a/api/v1alpha1/ipxebootconfig_types.go b/api/v1alpha1/ipxebootconfig_types.go new file mode 100644 index 00000000..b9d96a9b --- /dev/null +++ b/api/v1alpha1/ipxebootconfig_types.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// IPXEBootConfigSpec defines the desired state of IPXEBootConfig +type IPXEBootConfigSpec struct { + // Important: Run "make" to regenerate code after modifying this file + SystemUUID string `json:"systemUUID,omitempty"` + SystemIPs []string `json:"systemIPs,omitempty"` // TODO: Add the custom serialization. For now validate at the controller. + // TODO: remove image as this is not needed + Image string `json:"image,omitempty"` + KernelURL string `json:"kernelURL,omitempty"` + InitrdURL string `json:"initrdURL,omitempty"` + SquashfsURL string `json:"squashfsURL,omitempty"` + // TODO: remove later + IPXEServerURL string `json:"ipxeServerURL,omitempty"` + IgnitionSecretRef *corev1.LocalObjectReference `json:"ignitionSecretRef,omitempty"` + IPXEScriptSecretRef *corev1.LocalObjectReference `json:"ipxeScriptSecretRef,omitempty"` +} + +type IPXEBootConfigState string + +const ( + IPXEBootConfigStateReady IPXEBootConfigState = "Ready" + IPXEBootConfigStatePending IPXEBootConfigState = "Pending" + IPXEBootConfigStateError IPXEBootConfigState = "Error" +) + +// IPXEBootConfigStatus defines the observed state of IPXEBootConfig +type IPXEBootConfigStatus struct { + // Important: Run "make" to regenerate code after modifying this file + State IPXEBootConfigState `json:"state,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +genclient + +// IPXEBootConfig is the Schema for the ipxebootconfigs API +type IPXEBootConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec IPXEBootConfigSpec `json:"spec,omitempty"` + Status IPXEBootConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// IPXEBootConfigList contains a list of IPXEBootConfig +type IPXEBootConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IPXEBootConfig `json:"items"` +} + +const ( + DefaultIgnitionKey = "ignition" + DefaultIPXEScriptKey = "ipxe-script" + SystemUUIDIndexKey = "spec.systemUUID" + SystemIPIndexKey = "spec.systemIPs" +) + +func init() { + SchemeBuilder.Register(&IPXEBootConfig{}, &IPXEBootConfigList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8137a215..99ad984e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -110,3 +110,107 @@ func (in *HTTPBootConfigStatus) DeepCopy() *HTTPBootConfigStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPXEBootConfig) DeepCopyInto(out *IPXEBootConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPXEBootConfig. +func (in *IPXEBootConfig) DeepCopy() *IPXEBootConfig { + if in == nil { + return nil + } + out := new(IPXEBootConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPXEBootConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPXEBootConfigList) DeepCopyInto(out *IPXEBootConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IPXEBootConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPXEBootConfigList. +func (in *IPXEBootConfigList) DeepCopy() *IPXEBootConfigList { + if in == nil { + return nil + } + out := new(IPXEBootConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPXEBootConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPXEBootConfigSpec) DeepCopyInto(out *IPXEBootConfigSpec) { + *out = *in + if in.SystemIPs != nil { + in, out := &in.SystemIPs, &out.SystemIPs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.IgnitionSecretRef != nil { + in, out := &in.IgnitionSecretRef, &out.IgnitionSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.IPXEScriptSecretRef != nil { + in, out := &in.IPXEScriptSecretRef, &out.IPXEScriptSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPXEBootConfigSpec. +func (in *IPXEBootConfigSpec) DeepCopy() *IPXEBootConfigSpec { + if in == nil { + return nil + } + out := new(IPXEBootConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPXEBootConfigStatus) DeepCopyInto(out *IPXEBootConfigStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPXEBootConfigStatus. +func (in *IPXEBootConfigStatus) DeepCopy() *IPXEBootConfigStatus { + if in == nil { + return nil + } + out := new(IPXEBootConfigStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index fe2badd0..f05d750d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -42,9 +42,11 @@ var ( const ( // core controllers + machineBootConfigControllerHttp = "machinebootconfighttp" + ipxeBootConfigController = "ipxebootconfig" + serverBootConfigControllerPxe = "serverbootconfigpxe" httpBootConfigController = "httpbootconfig" serverBootConfigControllerHttp = "serverbootconfighttp" - machineBootConfigControllerHttp = "machinebootconfighttp" ) func init() { @@ -58,6 +60,7 @@ func init() { func main() { ctx := ctrl.LoggerInto(ctrl.SetupSignalHandler(), setupLog) + defaultIpxeTemplateData := NewDefaultIPXETemplateData() defaultHttpUKIURL := NewDefaultHTTPBootData() var metricsAddr string @@ -66,14 +69,22 @@ func main() { var secureMetrics bool var enableHTTP2 bool var bootserverAddr string + var imageProxyServerAddr string + var ipxeServiceURL string + var ipxeServiceProtocol string + var ipxeServicePort int var imageServerURL string var bootconfigNamespace string + flag.IntVar(&ipxeServicePort, "ipxe-service-port", 5000, "IPXE Service port to listen on.") + flag.StringVar(&ipxeServiceProtocol, "ipxe-service-protocol", "http", "IPXE Service Protocol.") + flag.StringVar(&ipxeServiceURL, "ipxe-service-url", "", "IPXE Service URL.") flag.StringVar(&imageServerURL, "image-server-url", "", "OS Image Server URL.") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.StringVar(&bootserverAddr, "boot-server-address", ":8082", "The address the boot-server binds to.") flag.StringVar(&bootconfigNamespace, "machinebootconfig-namespace", "default", "The namespace in which HTTPBootConfigs should be created for MachineBootConfiguration Controller.") + flag.StringVar(&imageProxyServerAddr, "image-proxy-server-address", ":8083", "The address the image-proxy-server binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -84,6 +95,8 @@ func main() { controllers := switches.New( // core controllers + ipxeBootConfigController, + serverBootConfigControllerPxe, serverBootConfigControllerHttp, machineBootConfigControllerHttp, httpBootConfigController, @@ -104,6 +117,17 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + // set the correct ipxe service URL by getting the address from the environment + var ipxeServiceAddr string + if ipxeServiceURL == "" { + ipxeServiceAddr = os.Getenv("IPXE_SERVER_ADDRESS") + if ipxeServiceAddr == "" { + setupLog.Error(nil, "failed to set the ipxe service URL as no address is provided") + os.Exit(1) + } + ipxeServiceURL = fmt.Sprintf("%s://%s:%d", ipxeServiceProtocol, ipxeServiceAddr, ipxeServicePort) + } + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancelation and @@ -152,6 +176,27 @@ func main() { os.Exit(1) } + if controllers.Enabled(ipxeBootConfigController) { + if err = (&controller.IPXEBootConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IPXEBootConfig") + os.Exit(1) + } + } + + if controllers.Enabled(serverBootConfigControllerPxe) { + if err = (&controller.ServerBootConfigurationPXEReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IPXEServiceURL: ipxeServiceURL, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigPxe") + os.Exit(1) + } + } + if controllers.Enabled(serverBootConfigControllerHttp) { if err = (&controller.ServerBootConfigurationHTTPReconciler{ Client: mgr.GetClient(), @@ -196,18 +241,31 @@ func main() { os.Exit(1) } + if err := IndexIPXEBootConfigBySystemUUID(ctx, mgr); err != nil { + setupLog.Error(err, "unable to set up indexer for IPXEBootConfig SystemUUID") + os.Exit(1) + } + if err := IndexHTTPBootConfigBySystemUUID(ctx, mgr); err != nil { setupLog.Error(err, "unable to set up indexer for HTTPBootConfig SystemUUID") os.Exit(1) } + if err := IndexIPXEBootConfigBySystemIPs(ctx, mgr); err != nil { + setupLog.Error(err, "unable to set up indexer for IPXEBootConfig SystemIPs") + os.Exit(1) + } + if err := IndexHTTPBootConfigBySystemIPs(ctx, mgr); err != nil { setupLog.Error(err, "unable to set up indexer for HTTPBootConfig SystemIP") os.Exit(1) } setupLog.Info("starting boot-server") - go bootserver.RunBootServer(mgr.GetClient(), serverLog.WithName("bootserver"), bootserverAddr, *defaultHttpUKIURL) + go bootserver.RunBootServer(bootserverAddr, ipxeServiceURL, mgr.GetClient(), serverLog.WithName("bootserver"), *defaultIpxeTemplateData, *defaultHttpUKIURL) + + setupLog.Info("starting image-proxy-server") + go bootserver.RunImageProxyServer(imageProxyServerAddr, mgr.GetClient(), serverLog.WithName("imageproxyserver")) setupLog.Info("starting manager") if err := mgr.Start(ctx); err != nil { @@ -216,6 +274,29 @@ func main() { } } +func IndexIPXEBootConfigBySystemUUID(ctx context.Context, mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField( + ctx, + &bootv1alpha1.IPXEBootConfig{}, + bootv1alpha1.SystemUUIDIndexKey, + func(Obj client.Object) []string { + ipxeBootConfig := Obj.(*bootv1alpha1.IPXEBootConfig) + return []string{ipxeBootConfig.Spec.SystemUUID} + }, + ) +} + +func IndexIPXEBootConfigBySystemIPs(ctx context.Context, mgr ctrl.Manager) error { + return mgr.GetFieldIndexer().IndexField( + ctx, &bootv1alpha1.IPXEBootConfig{}, + bootv1alpha1.SystemIPIndexKey, + func(Obj client.Object) []string { + ipxeBootConfig := Obj.(*bootv1alpha1.IPXEBootConfig) + return ipxeBootConfig.Spec.SystemIPs + }, + ) +} + func IndexHTTPBootConfigBySystemUUID(ctx context.Context, mgr ctrl.Manager) error { return mgr.GetFieldIndexer().IndexField( ctx, @@ -240,6 +321,16 @@ func IndexHTTPBootConfigBySystemIPs(ctx context.Context, mgr ctrl.Manager) error ) } +func NewDefaultIPXETemplateData() *bootserver.IPXETemplateData { + var cfg bootserver.IPXETemplateData + flag.StringVar(&cfg.KernelURL, "default-kernel-url", "", "Default URL for the kernel") + flag.StringVar(&cfg.InitrdURL, "default-initrd-url", "", "Default URL for the initrd") + flag.StringVar(&cfg.SquashfsURL, "default-squashfs-url", "", "Default URL for the squashfs") + flag.StringVar(&cfg.IPXEServerURL, "default-ipxe-server-url", "", "Default IPXE Server URL to while generating ipxe-script") + + return &cfg +} + func NewDefaultHTTPBootData() *string { var defaultUKIURL string flag.StringVar(&defaultUKIURL, "default-httpboot-uki-url", "", "Default UKI URL for http boot") diff --git a/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml new file mode 100644 index 00000000..85dc32e7 --- /dev/null +++ b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml @@ -0,0 +1,118 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: ipxebootconfigs.boot.ironcore.dev +spec: + group: boot.ironcore.dev + names: + kind: IPXEBootConfig + listKind: IPXEBootConfigList + plural: ipxebootconfigs + singular: ipxebootconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.state + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: IPXEBootConfig is the Schema for the ipxebootconfigs API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: IPXEBootConfigSpec defines the desired state of IPXEBootConfig + properties: + ignitionSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + image: + description: 'TODO: remove image as this is not needed' + type: string + initrdURL: + type: string + ipxeScriptSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + ipxeServerURL: + description: 'TODO: remove later' + type: string + kernelURL: + type: string + squashfsURL: + type: string + systemIPs: + items: + type: string + type: array + systemUUID: + description: 'Important: Run "make" to regenerate code after modifying + this file' + type: string + type: object + status: + description: IPXEBootConfigStatus defines the observed state of IPXEBootConfig + properties: + state: + description: 'Important: Run "make" to regenerate code after modifying + this file' + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 62eab54a..5db11630 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,6 +2,7 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: +- bases/boot.ironcore.dev_ipxebootconfigs.yaml - bases/boot.ironcore.dev_httpbootconfigs.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/ipxebootconfig_editor_role.yaml b/config/rbac/ipxebootconfig_editor_role.yaml new file mode 100644 index 00000000..5c5ead0b --- /dev/null +++ b/config/rbac/ipxebootconfig_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit ipxebootconfigs. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: ipxebootconfig-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: ipxe-operator + app.kubernetes.io/part-of: ipxe-operator + app.kubernetes.io/managed-by: kustomize + name: ipxebootconfig-editor-role +rules: +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs/status + verbs: + - get diff --git a/config/rbac/ipxebootconfig_viewer_role.yaml b/config/rbac/ipxebootconfig_viewer_role.yaml new file mode 100644 index 00000000..7eff48ad --- /dev/null +++ b/config/rbac/ipxebootconfig_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view ipxebootconfigs. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: ipxebootconfig-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: ipxe-operator + app.kubernetes.io/part-of: ipxe-operator + app.kubernetes.io/managed-by: kustomize + name: ipxebootconfig-viewer-role +rules: +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs + verbs: + - get + - list + - watch +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 65dbcaa2..d407ab0c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -55,6 +55,49 @@ rules: - get - patch - update +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfig + verbs: + - create + - delete + - get + - list + - patch + - watch +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfig/status + verbs: + - get +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs/finalizers + verbs: + - update +- apiGroups: + - boot.ironcore.dev + resources: + - ipxebootconfigs/status + verbs: + - get + - patch + - update - apiGroups: - metal.ironcore.dev resources: diff --git a/config/samples/httpbootconfig.yaml b/config/samples/httpbootconfig.yaml index d19701a6..b51ebb63 100644 --- a/config/samples/httpbootconfig.yaml +++ b/config/samples/httpbootconfig.yaml @@ -14,5 +14,7 @@ spec: - 1.2.3.4 ignitionSecretRef: name: ignition-sample - ukiURL: http://1.2.3.4/uki.efi + kernelURL: "10.0.0.1/ipxe/rootfs.vmlinuz" + initrdURL: "10.0.0.1/ipxe/rootfs.initrd" + squashfsURL: "10.0.0.1/ipxe/root.squashfs" diff --git a/config/samples/ipxebootconfig.yaml b/config/samples/ipxebootconfig.yaml new file mode 100644 index 00000000..65b6ac32 --- /dev/null +++ b/config/samples/ipxebootconfig.yaml @@ -0,0 +1,19 @@ +apiVersion: boot.ironcore.dev/v1alpha1 +kind: IPXEBootConfig +metadata: + labels: + app.kubernetes.io/name: ipxebootconfig + app.kubernetes.io/instance: ipxebootconfig-sample + app.kubernetes.io/part-of: ipxe-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: ipxe-operator + name: ipxebootconfig-sample +spec: + systemUUID: "1234" + systemIP: "10.0.0.2" + ignitionSecretRef: + name: ignition-sample + image: gardenlinux:latest + kernelURL: "10.0.0.1/ipxe/rootfs.vmlinuz" + initrdURL: "10.0.0.1/ipxe/rootfs.initrd" + squashfsURL: "10.0.0.1/ipxe/root.squashfs" \ No newline at end of file diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index f6f68cf4..79cf5cc8 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: +- boot_v1alpha1_ipxebootconfig.yaml - boot_v1alpha1_httpbootconfig.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/go.mod b/go.mod index 57699669..768d67af 100644 --- a/go.mod +++ b/go.mod @@ -6,21 +6,27 @@ require ( github.com/coreos/butane v0.21.0 github.com/go-logr/logr v1.4.2 github.com/ironcore-dev/controller-utils v0.9.3 + github.com/ironcore-dev/ironcore-image v0.2.1 github.com/ironcore-dev/metal v0.0.0-20240624131301-18385f342755 github.com/ironcore-dev/metal-operator v0.0.0-20240701152532-491d3d6f6ef8 github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 + github.com/opencontainers/image-spec v1.1.0 k8s.io/api v0.30.2 k8s.io/apimachinery v0.30.2 k8s.io/client-go v0.30.2 + k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 sigs.k8s.io/controller-runtime v0.18.4 ) require ( + github.com/Microsoft/hcsshim v0.11.4 // indirect github.com/aws/aws-sdk-go v1.50.25 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clarketm/json v1.17.1 // indirect + github.com/containerd/containerd v1.7.14 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/coreos/go-json v0.0.0-20230131223807-18775e0fb4fb // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -46,16 +52,19 @@ require ( github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.16.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.53.0 // indirect github.com/prometheus/procfs v0.15.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/vincent-petithory/dataurl v1.0.0 // indirect @@ -64,12 +73,15 @@ require ( golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/term v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/grpc v1.61.1 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -77,7 +89,6 @@ require ( k8s.io/apiextensions-apiserver v0.30.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f // indirect - k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/go.sum b/go.sum index 5732a379..7c35bc47 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= github.com/aws/aws-sdk-go v1.50.25 h1:vhiHtLYybv1Nhx3Kv18BBC6L0aPJHaG9aeEsr92W99c= github.com/aws/aws-sdk-go v1.50.25/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -6,6 +8,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clarketm/json v1.17.1 h1:U1IxjqJkJ7bRK4L6dyphmoO840P6bdhPdbbLySourqI= github.com/clarketm/json v1.17.1/go.mod h1:ynr2LRfb0fQU34l07csRNBTcivjySLLiY1YzQqKVfdo= +github.com/containerd/containerd v1.7.14 h1:H/XLzbnGuenZEGK+v0RkwTdv2u1QFAruMe5N0GNPJwA= +github.com/containerd/containerd v1.7.14/go.mod h1:YMC9Qt5yzNqXx/fO4j/5yYVIHXSRrlB3H7sxkUTvspg= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/butane v0.21.0 h1:GDi6XBheEfvxaq7Ez3wxdN+0IraAz3U7QvpVGcbHd84= github.com/coreos/butane v0.21.0/go.mod h1:3OKS5qaH58O2yLAKgAtOgBpUQSm7aIOU09IpG+IvmF4= github.com/coreos/go-json v0.0.0-20230131223807-18775e0fb4fb h1:rmqyI19j3Z/74bIRhuC59RB442rXUazKNueVpfJPxg4= @@ -65,6 +71,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/ironcore-dev/controller-utils v0.9.3 h1:sTrnxSzX5RrLf4B8KrAH2axSC+gxfJXphkV6df2GSsw= github.com/ironcore-dev/controller-utils v0.9.3/go.mod h1:djKnxDs0Hwxhhc0VmVY8tZnrOrElvrRV2jov/LiCZ2Y= +github.com/ironcore-dev/ironcore-image v0.2.1 h1:7LsIftIRX5btnSieo1J+xUSnW1tSIsGzYgD6NnpObyY= +github.com/ironcore-dev/ironcore-image v0.2.1/go.mod h1:BNaacvN5++9zGiTDJea4vvGDwHvPJE6S9Xb3G7hsFQU= github.com/ironcore-dev/metal v0.0.0-20240624131301-18385f342755 h1:EmR3Ngg2wmOXJkxgsdYVuPXLRfwWmO2Fi+htjih6QGY= github.com/ironcore-dev/metal v0.0.0-20240624131301-18385f342755/go.mod h1:+/bmkghOE7acqXDT/LDH57RemaUzlVwnQjttsOjdoyg= github.com/ironcore-dev/metal-operator v0.0.0-20240701152532-491d3d6f6ef8 h1:cPAp9TQ8UH/YSQycwodtpUeQauui+pSdwEnqzNl/Mdk= @@ -75,6 +83,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -92,6 +102,10 @@ github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -107,10 +121,13 @@ github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace h1:9PNP1jnUjRhfmGMlkXHjYPishpcw4jpSt/V/xYY3FMA= github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= @@ -141,9 +158,12 @@ golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= @@ -166,6 +186,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 h1:hZB7eLIaYlW9qXRfCq/qDaPdbeY3757uARz5Vvfv+cY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:YUWgXUFRPfoYK1IHMuxH5K6nPEXSCzIMljnQ59lLRCk= +google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY= +google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -176,6 +200,7 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.30.2 h1:+ZhRj+28QT4UOH+BKznu4CBgPWgkXO7XAvMcMl0qKvI= diff --git a/internal/controller/helper.go b/internal/controller/helper.go new file mode 100644 index 00000000..6137b1f2 --- /dev/null +++ b/internal/controller/helper.go @@ -0,0 +1,23 @@ +/* +Copyright 2024. + +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 controller + +import "sigs.k8s.io/controller-runtime/pkg/client" + +const ( + fieldOwner = client.FieldOwner("boot.ironcore.dev/controller-manager") +) diff --git a/internal/controller/ipxebootconfig_controller.go b/internal/controller/ipxebootconfig_controller.go new file mode 100644 index 00000000..d75d9e76 --- /dev/null +++ b/internal/controller/ipxebootconfig_controller.go @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/go-logr/logr" + bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" +) + +// IPXEBootConfigReconciler reconciles a IPXEBootConfig object +type IPXEBootConfigReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=boot.ironcore.dev,resources=ipxebootconfigs,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=boot.ironcore.dev,resources=ipxebootconfigs/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=boot.ironcore.dev,resources=ipxebootconfigs/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch + +func (r *IPXEBootConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + IPXEBootConfig := &bootv1alpha1.IPXEBootConfig{} + if err := r.Get(ctx, req.NamespacedName, IPXEBootConfig); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + return r.reconcileExists(ctx, log, IPXEBootConfig) +} + +func (r *IPXEBootConfigReconciler) reconcileExists(ctx context.Context, log logr.Logger, IPXEBootConfig *bootv1alpha1.IPXEBootConfig) (ctrl.Result, error) { + if !IPXEBootConfig.DeletionTimestamp.IsZero() { + return r.delete(ctx, log, IPXEBootConfig) + } + + return r.reconcile(ctx, log, IPXEBootConfig) +} + +func (r *IPXEBootConfigReconciler) reconcile(ctx context.Context, log logr.Logger, ipxeBootConfig *bootv1alpha1.IPXEBootConfig) (ctrl.Result, error) { + log.V(1).Info("Ensuring Ignition") + state, ignitionErr := r.ensureIgnition(ctx, log, ipxeBootConfig) + if ignitionErr != nil { + patchError := r.patchStatus(ctx, ipxeBootConfig, state) + if patchError != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch status %w %w", ignitionErr, patchError) + } + + log.V(1).Info("Failed to Ensure Ignition", "Error", ignitionErr) + return ctrl.Result{}, nil + } + + patchErr := r.patchStatus(ctx, ipxeBootConfig, state) + if patchErr != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch status %w", patchErr) + } + + return ctrl.Result{}, nil +} + +func (r *IPXEBootConfigReconciler) ensureIgnition(ctx context.Context, _ logr.Logger, ipxeBootConfig *bootv1alpha1.IPXEBootConfig) (bootv1alpha1.IPXEBootConfigState, error) { + // Verify if the IgnitionRef is set, and it has the intended data key. + if ipxeBootConfig.Spec.IgnitionSecretRef != nil { + IgnitionSecret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Name: ipxeBootConfig.Spec.IgnitionSecretRef.Name, Namespace: ipxeBootConfig.Namespace}, IgnitionSecret); err != nil { + return bootv1alpha1.IPXEBootConfigStateError, err + // TODO: Add some validation steps to ensure that the IgntionData is compliant, if necessary. + // Assume for now, that it's going to json format. + } + if IgnitionSecret.Data[bootv1alpha1.DefaultIgnitionKey] == nil { + return bootv1alpha1.IPXEBootConfigStateError, fmt.Errorf("ignition data is missing") + } + } + + return bootv1alpha1.IPXEBootConfigStateReady, nil +} + +func (r *IPXEBootConfigReconciler) delete(_ context.Context, log logr.Logger, _ *bootv1alpha1.IPXEBootConfig) (ctrl.Result, error) { + log.V(1).Info("Deleting ipxeBootConfig") + + // TODO + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IPXEBootConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bootv1alpha1.IPXEBootConfig{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.enqueueIPXEBootConfigReferencingIgnitionSecret), + ). + Complete(r) +} + +func (r *IPXEBootConfigReconciler) patchStatus( + ctx context.Context, + ipxeBootConfig *bootv1alpha1.IPXEBootConfig, + state bootv1alpha1.IPXEBootConfigState, +) error { + base := ipxeBootConfig.DeepCopy() + ipxeBootConfig.Status.State = state + + if err := r.Status().Patch(ctx, ipxeBootConfig, client.MergeFrom(base)); err != nil { + return fmt.Errorf("error patching ipxeBootConfig: %w", err) + } + return nil +} + +func (r *IPXEBootConfigReconciler) enqueueIPXEBootConfigReferencingIgnitionSecret(ctx context.Context, secret client.Object) []reconcile.Request { + log := log.Log.WithValues("secret", secret.GetName()) + secretObj, ok := secret.(*corev1.Secret) + if !ok { + log.Error(nil, "cant decode object into Secret", secret) + return nil + } + + list := &bootv1alpha1.IPXEBootConfigList{} + if err := r.Client.List(ctx, list, client.InNamespace(secretObj.Namespace)); err != nil { + log.Error(err, "failed to list IPXEBootConfig for secret", secret) + return nil + } + + var requests []reconcile.Request + for _, ipxeBootConfig := range list.Items { + if ipxeBootConfig.Spec.IgnitionSecretRef != nil && ipxeBootConfig.Spec.IgnitionSecretRef.Name == secretObj.Name { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: ipxeBootConfig.Name, + Namespace: ipxeBootConfig.Namespace, + }, + }) + } + } + return requests +} diff --git a/internal/controller/ipxebootconfig_controller_test.go b/internal/controller/ipxebootconfig_controller_test.go new file mode 100644 index 00000000..88cd4006 --- /dev/null +++ b/internal/controller/ipxebootconfig_controller_test.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" +) + +var _ = Describe("IPXEBootConfig Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + ipxebootconfig := &bootv1alpha1.IPXEBootConfig{} + + BeforeEach(func() { + By("creating the custom resource for the Kind IPXEBootConfig") + err := k8sClient.Get(ctx, typeNamespacedName, ipxebootconfig) + if err != nil && errors.IsNotFound(err) { + resource := &bootv1alpha1.IPXEBootConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &bootv1alpha1.IPXEBootConfig{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance IPXEBootConfig") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &IPXEBootConfigReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/internal/controller/serverbootconfiguration_pxe_controller.go b/internal/controller/serverbootconfiguration_pxe_controller.go new file mode 100644 index 00000000..05d18c5a --- /dev/null +++ b/internal/controller/serverbootconfiguration_pxe_controller.go @@ -0,0 +1,199 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "fmt" + "strings" + + "github.com/ironcore-dev/boot-operator/api/v1alpha1" + ironcoreimage "github.com/ironcore-dev/ironcore-image" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/go-logr/logr" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ServerBootConfigurationPXEReconciler struct { + client.Client + Scheme *runtime.Scheme + IPXEServiceURL string +} + +//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations,verbs=get;list;watch +//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations/finalizers,verbs=update +//+kubebuilder:rbac:groups=boot.ironcore.dev,resources=ipxebootconfig,verbs=get;list;watch;create;delete;patch +//+kubebuilder:rbac:groups=boot.ironcore.dev,resources=ipxebootconfig/status,verbs=get +//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=servers,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *ServerBootConfigurationPXEReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + bootConfig := &metalv1alpha1.ServerBootConfiguration{} + if err := r.Get(ctx, req.NamespacedName, bootConfig); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + return r.reconcileExists(ctx, log, bootConfig) +} + +func (r *ServerBootConfigurationPXEReconciler) reconcileExists(ctx context.Context, log logr.Logger, config *metalv1alpha1.ServerBootConfiguration) (ctrl.Result, error) { + if !config.DeletionTimestamp.IsZero() { + return r.delete(ctx, log, config) + } + return r.reconcile(ctx, log, config) +} + +func (r *ServerBootConfigurationPXEReconciler) delete(ctx context.Context, log logr.Logger, config *metalv1alpha1.ServerBootConfiguration) (ctrl.Result, error) { + return ctrl.Result{}, nil +} + +func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, log logr.Logger, config *metalv1alpha1.ServerBootConfiguration) (ctrl.Result, error) { + log.V(1).Info("Reconciling ServerBootConfiguration") + + systemUUID, err := r.getSystemUUIDFromBootConfig(ctx, config) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get system UUID from BootConfig: %w", err) + } + log.V(1).Info("Got system UUID from BootConfig", "systemUUID", systemUUID) + + systemIPs, err := r.getSystemIPFromBootConfig(ctx, config) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get system IP from BootConfig: %w", err) + } + log.V(1).Info("Got system IP from BootConfig", "systemIPs", systemIPs) + + kernelURL, initrdURL, squashFSURL, err := r.getImageDetailsFromConfig(ctx, config) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get image details from BootConfig: %w", err) + } + log.V(1).Info("Extracted OS image layer details") + + ipxeConfig := &v1alpha1.IPXEBootConfig{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "boot.ironcore.dev/v1alpha1", + Kind: "IPXEBootConfig", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: config.Namespace, + Name: config.Name, + }, + Spec: v1alpha1.IPXEBootConfigSpec{ + SystemUUID: systemUUID, + SystemIPs: systemIPs, + KernelURL: kernelURL, + InitrdURL: initrdURL, + SquashfsURL: squashFSURL, + IgnitionSecretRef: &v1.LocalObjectReference{Name: config.Spec.IgnitionSecretRef.Name}, + }, + } + + if err := controllerutil.SetControllerReference(config, ipxeConfig, r.Scheme); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to set controller reference: %w", err) + } + log.V(1).Info("Set controller reference") + + if err := r.Patch(ctx, ipxeConfig, client.Apply, fieldOwner, client.ForceOwnership); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to apply IPXE config: %w", err) + } + log.V(1).Info("Applied IPXE config for server boot config") + + if err := r.Get(ctx, client.ObjectKey{Namespace: config.Namespace, Name: config.Name}, ipxeConfig); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get IPXE config: %w", err) + } + + if err := r.patchConfigStateFromIPXEState(ctx, ipxeConfig, config); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state to %s: %w", ipxeConfig.Status.State, err) + } + log.V(1).Info("Patched server boot config state") + + log.V(1).Info("Reconciled ServerBootConfiguration") + return ctrl.Result{}, nil +} + +func (r *ServerBootConfigurationPXEReconciler) patchConfigStateFromIPXEState(ctx context.Context, ipxeConfig *v1alpha1.IPXEBootConfig, config *metalv1alpha1.ServerBootConfiguration) error { + if ipxeConfig.Status.State == v1alpha1.IPXEBootConfigStateReady { + return r.patchState(ctx, config, metalv1alpha1.ServerBootConfigurationStateReady) + } + + if ipxeConfig.Status.State == v1alpha1.IPXEBootConfigStateError { + return r.patchState(ctx, config, metalv1alpha1.ServerBootConfigurationStateError) + } + return nil +} + +func (r *ServerBootConfigurationPXEReconciler) patchState(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration, state metalv1alpha1.ServerBootConfigurationState) error { + configBase := config.DeepCopy() + config.Status.State = state + if err := r.Status().Patch(ctx, config, client.MergeFrom(configBase)); err != nil { + return err + } + return nil +} + +func (r *ServerBootConfigurationPXEReconciler) getSystemUUIDFromBootConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) (string, error) { + server := &metalv1alpha1.Server{} + if err := r.Get(ctx, client.ObjectKey{Name: config.Spec.ServerRef.Name}, server); err != nil { + return "", fmt.Errorf("failed to get Server: %w", err) + } + + return server.Spec.UUID, nil +} + +func (r *ServerBootConfigurationPXEReconciler) getSystemIPFromBootConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) ([]string, error) { + server := &metalv1alpha1.Server{} + if err := r.Get(ctx, client.ObjectKey{Name: config.Spec.ServerRef.Name}, server); err != nil { + return nil, fmt.Errorf("failed to get Server: %w", err) + } + + systemIPs := []string{} + for _, nic := range server.Status.NetworkInterfaces { + systemIPs = append(systemIPs, nic.IP.String()) + } + + return systemIPs, nil +} + +func (r *ServerBootConfigurationPXEReconciler) getImageDetailsFromConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) (string, string, string, error) { + imageDetails := strings.Split(config.Spec.Image, ":") + if len(imageDetails) != 2 { + return "", "", "", fmt.Errorf("invalid image format") + } + kernelURL := fmt.Sprintf("%s/image?imageName=%s&version=%s&layerName=%s", r.IPXEServiceURL, imageDetails[0], imageDetails[1], ironcoreimage.KernelLayerMediaType) + initrdURL := fmt.Sprintf("%s/image?imageName=%s&version=%s&layerName=%s", r.IPXEServiceURL, imageDetails[0], imageDetails[1], ironcoreimage.InitRAMFSLayerMediaType) + // TODO: move this const to ironcore-image + const squashFSMediaTypeLayer = "application/vnd.ironcore.image.squashfs.v1alpha1.squashfs" + squashFSURL := fmt.Sprintf("%s/image?imageName=%s&version=%s&layerName=%s", r.IPXEServiceURL, imageDetails[0], imageDetails[1], squashFSMediaTypeLayer) + + return kernelURL, initrdURL, squashFSURL, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ServerBootConfigurationPXEReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&metalv1alpha1.ServerBootConfiguration{}). + Owns(&v1alpha1.IPXEBootConfig{}). + Complete(r) +} diff --git a/internal/controller/serverbootconfiguration_pxe_controller_test.go b/internal/controller/serverbootconfiguration_pxe_controller_test.go new file mode 100644 index 00000000..acf6abdb --- /dev/null +++ b/internal/controller/serverbootconfiguration_pxe_controller_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2024. + +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 controller + +import ( + "github.com/ironcore-dev/boot-operator/api/v1alpha1" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ServerBootConfiguration Controller", func() { + ns := SetupTest() + + It("should map a new ServerBootConfiguration", func(ctx SpecContext) { + By("creating a new Server object") + server := &metalv1alpha1.Server{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "server-", + }, + Spec: metalv1alpha1.ServerSpec{ + UUID: "12345", + }, + } + Expect(k8sClient.Create(ctx, server)).To(Succeed()) + DeferCleanup(k8sClient.Delete, server) + + By("patching the Server NICs in Server status") + Eventually(UpdateStatus(server, func() { + server.Status.NetworkInterfaces = []metalv1alpha1.NetworkInterface{ + { + Name: "foo", + IP: metalv1alpha1.MustParseIP("1.1.1.1"), + MACAddress: "abcd", + }, + } + })).Should(Succeed()) + + By("creating a new ServerBootConfiguration") + config := &metalv1alpha1.ServerBootConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns.Name, + GenerateName: "test-", + }, + Spec: metalv1alpha1.ServerBootConfigurationSpec{ + ServerRef: corev1.LocalObjectReference{ + Name: server.Name, + }, + Image: "foo:bar", + IgnitionSecretRef: &corev1.LocalObjectReference{Name: "foo"}, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + + By("ensuring that the ipxe boot configuration is correct") + bootConfig := &v1alpha1.IPXEBootConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns.Name, + Name: config.Name, + }, + } + Eventually(Object(bootConfig)).Should(SatisfyAll( + HaveField("OwnerReferences", ContainElement(metav1.OwnerReference{ + APIVersion: "metal.ironcore.dev/v1alpha1", + Kind: "ServerBootConfiguration", + Name: config.Name, + UID: config.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + })), + HaveField("Spec.SystemUUID", server.Spec.UUID), + HaveField("Spec.SystemIPs", ContainElement("1.1.1.1")), + HaveField("Spec.KernelURL", "http://localhost:5000/image?imageName=foo&version=bar&layerName=application/vnd.ironcore.image.vmlinuz.v1alpha1.vmlinuz"), + HaveField("Spec.InitrdURL", "http://localhost:5000/image?imageName=foo&version=bar&layerName=application/vnd.ironcore.image.initramfs.v1alpha1.initramfs"), + HaveField("Spec.SquashfsURL", "http://localhost:5000/image?imageName=foo&version=bar&layerName=application/vnd.ironcore.image.squashfs.v1alpha1.squashfs"), + HaveField("Spec.IgnitionSecretRef.Name", "foo"), + )) + }) +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 7dd3fd22..28c89b04 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -121,6 +121,12 @@ func SetupTest() *corev1.Namespace { }) Expect(err).ToNot(HaveOccurred()) + Expect((&ServerBootConfigurationPXEReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + IPXEServiceURL: "http://localhost:5000", + }).SetupWithManager(k8sManager)).To(Succeed()) + Expect((&ServerBootConfigurationHTTPReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), diff --git a/server/bootserver.go b/server/bootserver.go index f57e63e8..62760248 100644 --- a/server/bootserver.go +++ b/server/bootserver.go @@ -9,9 +9,12 @@ import ( "net" "net/http" "path" + "path/filepath" "strings" + "text/template" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" butaneconfig "github.com/coreos/butane/config" @@ -20,7 +23,19 @@ import ( bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" ) -func RunBootServer(k8sClient client.Client, log logr.Logger, bootserverAddr string, defaultUKIURL string) { +type IPXETemplateData struct { + KernelURL string + InitrdURL string + SquashfsURL string + RegistryURL string + IPXEServerURL string +} + +func RunBootServer(ipxeServerAddr string, ipxeServiceURL string, k8sClient client.Client, log logr.Logger, defaultIpxeTemplateData IPXETemplateData, defaultUKIURL string) { + http.HandleFunc("/ipxe", func(w http.ResponseWriter, r *http.Request) { + handleIPXE(w, r, k8sClient, log, ipxeServiceURL, defaultIpxeTemplateData) + }) + http.HandleFunc("/httpboot", func(w http.ResponseWriter, r *http.Request) { handleHTTPBoot(w, r, k8sClient, log, defaultUKIURL) }) @@ -31,16 +46,165 @@ func RunBootServer(k8sClient client.Client, log logr.Logger, bootserverAddr stri http.Error(w, "Bad Request: UUID is required", http.StatusBadRequest) return } - handleIgnitionHTTPBoot(w, r, k8sClient, log, uuid) + + ipxeBootConfigList := &bootv1alpha1.IPXEBootConfigList{} + err := k8sClient.List(r.Context(), ipxeBootConfigList, client.MatchingFields{bootv1alpha1.SystemUUIDIndexKey: uuid}) + if client.IgnoreNotFound(err) != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if len(ipxeBootConfigList.Items) == 0 { + log.Info("No IPXEBootConfig found with given UUID. Trying HTTPBootConfig") + handleIgnitionHTTPBoot(w, r, k8sClient, log, uuid) + } else { + handleIgnitionIPXEBoot(w, r, k8sClient, log, uuid) + } }) - log.Info("Starting boot server", "address", bootserverAddr) - if err := http.ListenAndServe(bootserverAddr, nil); err != nil { + log.Info("Starting boot server", "address", ipxeServerAddr) + if err := http.ListenAndServe(ipxeServerAddr, nil); err != nil { log.Error(err, "failed to start boot server") panic(err) } } +func handleIPXE(w http.ResponseWriter, r *http.Request, k8sClient client.Client, log logr.Logger, ipxeServiceURL string, defaultIpxeTemplateData IPXETemplateData) { + log.Info("Processing IPXE request", "method", r.Method, "path", r.URL.Path, "clientIP", r.RemoteAddr) + ctx := r.Context() + + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.Error(err, "Failed to parse client IP address", "clientIP", r.RemoteAddr) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + clientIPs := []string{clientIP} + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + for _, ip := range strings.Split(xff, ",") { + trimmedIP := strings.TrimSpace(ip) + if trimmedIP != "" { + clientIPs = append(clientIPs, trimmedIP) + } + } + } + + var ipxeConfigs bootv1alpha1.IPXEBootConfigList + for _, ip := range clientIPs { + if err := k8sClient.List(ctx, &ipxeConfigs, client.MatchingFields{bootv1alpha1.SystemIPIndexKey: ip}); err != nil { + log.Info("Failed to list IPXEBootConfig for IP", "IP", ip, "error", err) + continue + } + + if len(ipxeConfigs.Items) > 0 { + log.Info("Found IPXEBootConfig", "IP", ip) + break + } + } + + if len(ipxeConfigs.Items) == 0 { + log.Info("No IPXEBootConfig found for client IP, delivering default script", "clientIP", clientIP) + serveDefaultIPXETemplate(w, log, ipxeServiceURL, defaultIpxeTemplateData) + } else { + config := ipxeConfigs.Items[0] + if config.Spec.IPXEScriptSecretRef != nil { + secret := &corev1.Secret{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: config.Spec.IPXEScriptSecretRef.Name, Namespace: config.Namespace}, secret) + if err != nil { + log.Error(err, "Failed to fetch IPXE script from secret", "SecretName", config.Spec.IPXEScriptSecretRef.Name) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ipxeScript, exists := secret.Data[bootv1alpha1.DefaultIPXEScriptKey] + if !exists { + log.Info("IPXE script not found in the secret", "ExpectedKey", bootv1alpha1.DefaultIPXEScriptKey) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if _, err := w.Write(ipxeScript); err != nil { + log.Info("Failed to write custom IPXE script", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + serveDefaultIPXETemplate(w, log, ipxeServiceURL, IPXETemplateData{ + KernelURL: config.Spec.KernelURL, + InitrdURL: config.Spec.InitrdURL, + SquashfsURL: config.Spec.SquashfsURL, + IPXEServerURL: ipxeServiceURL, + }) + } +} + +func handleIgnitionIPXEBoot(w http.ResponseWriter, r *http.Request, k8sClient client.Client, log logr.Logger, uuid string) { + log.Info("Processing Ignition request", "method", r.Method, "path", r.URL.Path, "clientIP", r.RemoteAddr) + ctx := r.Context() + + ipxeBootConfigList := &bootv1alpha1.IPXEBootConfigList{} + if err := k8sClient.List(ctx, ipxeBootConfigList, client.MatchingFields{bootv1alpha1.SystemUUIDIndexKey: uuid}); err != nil { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("Failed to find IPXEBootConfig", "error", err.Error()) + return + } + + if len(ipxeBootConfigList.Items) == 0 { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("No IPXEBootConfig found with given UUID") + return + } + + // TODO: Assuming UUID is unique. + ipxeBootConfig := ipxeBootConfigList.Items[0] + + ignitionSecret := &corev1.Secret{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: ipxeBootConfig.Spec.IgnitionSecretRef.Name, Namespace: ipxeBootConfig.Namespace}, ignitionSecret); err != nil { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("Error: Failed to get Ignition secret", "error", err.Error()) + return + } + + ignitionData, ok := ignitionSecret.Data[bootv1alpha1.DefaultIgnitionKey] + if !ok { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("Error: Ignition data not found in secret") + return + } + + ignitionJSONData, err := renderIgnition(ignitionData) + if err != nil { + log.Info("Failed to render the ignition data to json", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, err = w.Write(ignitionJSONData) + if err != nil { + log.Info("Failed to write the ignition http response", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} + +func serveDefaultIPXETemplate(w http.ResponseWriter, log logr.Logger, ipxeServiceURL string, data IPXETemplateData) { + tmplPath := filepath.Join("templates", "ipxe-script.tpl") + tmpl, err := template.ParseFiles(tmplPath) + if err != nil { + log.Info("Failed to parse iPXE script template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, data); err != nil { + log.Info("Failed to execute template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + func handleIgnitionHTTPBoot(w http.ResponseWriter, r *http.Request, k8sClient client.Client, log logr.Logger, uuid string) { log.Info("Processing Ignition request", "method", r.Method, "path", r.URL.Path, "clientIP", r.RemoteAddr) ctx := r.Context() diff --git a/server/imageproxyserver.go b/server/imageproxyserver.go new file mode 100644 index 00000000..c1ad8b1e --- /dev/null +++ b/server/imageproxyserver.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/go-logr/logr" + ociimage "github.com/opencontainers/image-spec/specs-go/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + ghcrIOKey = "ghcr.io/" + imageKey = "imageName" + layerKey = "layerName" + versionKey = "version" +) + +type TokenResponse struct { + Token string `json:"token"` +} + +type ImageDetails struct { + OCIImageName string + RepositoryName string + LayerName string + Version string +} + +func RunImageProxyServer(imageProxyServerAddr string, k8sClient client.Client, log logr.Logger) { + http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) { + imageDetails, err := parseImageURL(r.URL.Query()) + if err != nil { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("Error: Failed to parse the image url", "URL", r.URL.Path, "Error", err) + return + } + + if strings.HasPrefix(imageDetails.OCIImageName, ghcrIOKey) { + handleGHCR(w, r, &imageDetails, log) + } else { + http.Error(w, "Bad Request", http.StatusBadRequest) + log.Info("Unsupported registry") + } + }) + + log.Info("Starting image proxy server", "address", imageProxyServerAddr) + if err := http.ListenAndServe(imageProxyServerAddr, nil); err != nil { + log.Error(err, "failed to start image proxy server") + panic(err) + } +} + +func handleGHCR(w http.ResponseWriter, r *http.Request, imageDetails *ImageDetails, log logr.Logger) { + log.Info("Processing Image Proxy request", "method", r.Method, "path", r.URL.Path, "clientIP", r.RemoteAddr) + + bearerToken, err := imageDetails.getBearerToken() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + log.Info("Error: Failed to obtain the bearer token", "error", err) + return + } + + digest, err := imageDetails.getLayerDigest(bearerToken) + if err != nil { + http.Error(w, "Resource Not Found", http.StatusNotFound) + log.Info("Error: Failed to obtain layer digest", "error", err) + return + } + + targetURL := fmt.Sprintf("https://ghcr.io/v2/%s/blobs/%s", imageDetails.RepositoryName, digest) + proxyURL, _ := url.Parse(targetURL) + + proxy := &httputil.ReverseProxy{ + Director: imageDetails.modifyDirector(proxyURL, bearerToken, digest), + ModifyResponse: modifyProxyResponse(bearerToken), + } + + r.URL.Host = proxyURL.Host + r.URL.Scheme = proxyURL.Scheme + r.Host = proxyURL.Host + + proxy.ServeHTTP(w, r) +} + +func (imageDetails ImageDetails) getBearerToken() (string, error) { + url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull", imageDetails.RepositoryName) + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var tokenResponse TokenResponse + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", err + } + + return tokenResponse.Token, nil +} + +func modifyProxyResponse(bearerToken string) func(*http.Response) error { + return func(resp *http.Response) error { + resp.Header.Set("Authorization", "Bearer "+bearerToken) + if resp.StatusCode == http.StatusTemporaryRedirect { + location, err := resp.Location() + if err != nil { + return err + } + + client := &http.Client{} + redirectReq, err := http.NewRequest("GET", location.String(), nil) + if err != nil { + return err + } + + copyHeaders(resp.Request.Header, redirectReq.Header) + + redirectResp, err := client.Do(redirectReq) + if err != nil { + return err + } + + replaceResponse(resp, redirectResp) + } + return nil + } +} + +func copyHeaders(source http.Header, destination http.Header) { + for name, values := range source { + for _, value := range values { + destination.Add(name, value) + } + } +} + +func replaceResponse(originalResp, redirectResp *http.Response) { + for name, values := range redirectResp.Header { + for _, value := range values { + originalResp.Header.Set(name, value) + } + } + originalResp.Body = redirectResp.Body + originalResp.StatusCode = redirectResp.StatusCode +} + +func parseImageURL(queries url.Values) (imageDetails ImageDetails, err error) { + ociImageName := queries.Get(imageKey) + layerName := queries.Get(layerKey) + version := queries.Get(versionKey) + repositoryName := strings.TrimPrefix(ociImageName, ghcrIOKey) + + if ociImageName == "" || layerName == "" || version == "" { + return ImageDetails{}, fmt.Errorf("missing required query parameters 'image' or 'layer' or 'version'") + } + + return ImageDetails{ + OCIImageName: ociImageName, + RepositoryName: repositoryName, + LayerName: layerName, + Version: version, + }, nil +} + +func (ImageDetails ImageDetails) modifyDirector(proxyURL *url.URL, bearerToken string, digest string) func(*http.Request) { + return func(req *http.Request) { + req.URL.Scheme = proxyURL.Scheme + req.URL.Host = proxyURL.Host + req.URL.Path = fmt.Sprintf("/v2/%s/blobs/%s", ImageDetails.RepositoryName, digest) + req.Header.Set("Authorization", "Bearer "+bearerToken) + } +} + +func (imageDetails ImageDetails) getLayerDigest(token string) (string, error) { + url := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/%s", imageDetails.RepositoryName, imageDetails.Version) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("http request to fetch manifest failed %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("http client connection failed %w", err) + } + defer resp.Body.Close() + + var manifest ociimage.Manifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return "", fmt.Errorf("unable to decode the manifest %w", err) + } + + for _, layer := range manifest.Layers { + if strings.Contains(layer.MediaType, imageDetails.LayerName) { + return string(layer.Digest), nil + } + } + + return "", fmt.Errorf("%s layer not found in the manifest", imageDetails.LayerName) +} diff --git a/templates/ipxe-script.tpl b/templates/ipxe-script.tpl new file mode 100644 index 00000000..128ff587 --- /dev/null +++ b/templates/ipxe-script.tpl @@ -0,0 +1,13 @@ +#!ipxe + +set ipxe-svc {{.IPXEServerURL}} +set kernel-url {{.KernelURL}} +set initrd-url {{.InitrdURL}} +set squashfs-url {{.SquashfsURL}} + +echo Loading kernel... +kernel ${kernel-url} initrd=initrd gl.ovl=/:tmpfs gl.url=${squashfs-url} gl.live=1 ip=dhcp ignition.firstboot=1 ignition.config.url=${ipxe-svc}/ignition/${uuid} ignition.platform.id=metal console=ttyS0,115200 console=tty0 earlyprintk=ttyS0,115200 consoleblank=0 +echo Loading initrd... +initrd ${initrd-url} +echo Booting... +boot \ No newline at end of file