Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,11 +517,15 @@ func main() { // nolint: gocyclo
}

ctx := ctrl.SetupSignalHandler()
if err := controller.RegisterIndexFields(ctx, mgr.GetFieldIndexer()); err != nil {
setupLog.Error(err, "unable to register field indexers")
os.Exit(1)
}

// Run registry server as a runnable to ensure it stops when the manager stops
if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {
setupLog.Info("starting registry server", "RegistryURL", registryURL)
registryServer := registry.NewServer(setupLog, fmt.Sprintf(":%d", registryPort))
registryServer := registry.NewServer(setupLog, fmt.Sprintf(":%d", registryPort), mgr.GetClient())
if err := registryServer.Start(ctx); err != nil {
return fmt.Errorf("unable to start registry server: %w", err)
}
Expand Down
10 changes: 10 additions & 0 deletions internal/api/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@

package registry

// BootStateReceivedCondition is the condition type for indicating a server has booted.
const BootStateReceivedCondition = "BootStateReceived"

// RegistrationPayload represents the payload to send to the `/register` endpoint,
// including the systemUUID and the server details.
type RegistrationPayload struct {
SystemUUID string `json:"systemUUID"`
Data Server `json:"data"`
}

// BootstatePayload represents the payload to send to the `/bootstate` endpoint,
// including the systemUUID and the booted state.
type BootstatePayload struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: how about camel-case for BootState references instead?

SystemUUID string `json:"systemUUID"`
Booted bool `json:"booted"`
}
31 changes: 31 additions & 0 deletions internal/controller/index_fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package controller

import (
"context"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
ServerSystemUUIDIndexField = "spec.systemUUID"
)

func RegisterIndexFields(ctx context.Context, indexer client.FieldIndexer) error {
if err := indexer.IndexField(ctx, &metalv1alpha1.Server{}, ServerSystemUUIDIndexField, func(rawObj client.Object) []string {
server, ok := rawObj.(*metalv1alpha1.Server)
if !ok {
return nil
}
if server.Spec.SystemUUID == "" {
return nil
}
return []string{server.Spec.SystemUUID}
}); err != nil {
return err
}
return nil
}
63 changes: 63 additions & 0 deletions internal/controller/server_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package controller

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
Expand All @@ -13,6 +15,7 @@ import (
"gopkg.in/yaml.v3"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"github.com/ironcore-dev/metal-operator/internal/api/registry"
"github.com/ironcore-dev/metal-operator/internal/ignition"
"github.com/ironcore-dev/metal-operator/internal/probe"
. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -726,4 +729,64 @@ var _ = Describe("Server Controller", func() {
Expect(k8sClient.Delete(ctx, server)).Should(Succeed())
Expect(k8sClient.Delete(ctx, bmcSecret)).To(Succeed())
})

It("Should updated the BootStateReceived condition when the bootstate endpoint is called", func(ctx SpecContext) {
By("Creating a BMCSecret")
bmcSecret := &metalv1alpha1.BMCSecret{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-server-",
},
Data: map[string][]byte{
"username": []byte("foo"),
"password": []byte("bar"),
},
}
Expect(k8sClient.Create(ctx, bmcSecret)).To(Succeed())

By("Creating a Server with inline BMC configuration")
server := &metalv1alpha1.Server{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "server-",
},
Spec: metalv1alpha1.ServerSpec{
UUID: "38947555-7742-3448-3784-823347823834",
SystemUUID: "38947555-7742-3448-3784-823347823834",
BMC: &metalv1alpha1.BMCAccess{
Protocol: metalv1alpha1.Protocol{
Name: metalv1alpha1.ProtocolRedfishLocal,
Port: 8000,
},
Address: "127.0.0.1",
BMCSecretRef: v1.LocalObjectReference{
Name: bmcSecret.Name,
},
},
},
}
Expect(k8sClient.Create(ctx, server)).To(Succeed())
Eventually(Object(server)).Should(HaveField("Status.State", metalv1alpha1.ServerStateDiscovery))

var bootstateRequest registry.BootstatePayload
bootstateRequest.SystemUUID = server.Spec.SystemUUID
bootstateRequest.Booted = true
marshaled, err := json.Marshal(bootstateRequest)
Expect(err).NotTo(HaveOccurred())
response, err := http.Post(registryURL+"/bootstate", "application/json", bytes.NewBuffer(marshaled))
Expect(err).NotTo(HaveOccurred())
Expect(response.Body.Close()).To(Succeed())
Expect(response.StatusCode).To(Equal(http.StatusOK))

bootConfig := metalv1alpha1.ServerBootConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: server.Spec.BootConfigurationRef.Name,
Namespace: server.Spec.BootConfigurationRef.Namespace,
},
}
Eventually(Object(&bootConfig)).Should(HaveField("Status.Conditions", ContainElement(HaveField("Type", registry.BootStateReceivedCondition))))
Expect(k8sClient.Delete(ctx, server)).To(Succeed())
Eventually(Get(server)).Should(Satisfy(apierrors.IsNotFound))
Expect(k8sClient.Delete(ctx, bmcSecret)).To(Succeed())
Eventually(Get(bmcSecret)).Should(Satisfy(apierrors.IsNotFound))
Eventually(Get(&bootConfig)).Should(Satisfy(apierrors.IsNotFound))
})
})
3 changes: 2 additions & 1 deletion internal/controller/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func SetupTest() *corev1.Namespace {
},
})
Expect(err).ToNot(HaveOccurred())
Expect(RegisterIndexFields(mgrCtx, k8sManager.GetFieldIndexer())).To(Succeed())

prefixDB := &macdb.MacPrefixes{
MacPrefixes: []macdb.MacPrefix{
Expand Down Expand Up @@ -272,7 +273,7 @@ func SetupTest() *corev1.Namespace {

By("Starting the registry server")
Expect(k8sManager.Add(manager.RunnableFunc(func(ctx context.Context) error {
registryServer := registry.NewServer(GinkgoLogr, ":30000")
registryServer := registry.NewServer(GinkgoLogr, ":30000", k8sManager.GetClient())
if err := registryServer.Start(ctx); err != nil {
return fmt.Errorf("failed to start registry server: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/probe/probe_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ var _ = BeforeSuite(func() {
DeferCleanup(cancel)

// Initialize the registry
registryServer = registry.NewServer(GinkgoLogr, registryAddr)
registryServer = registry.NewServer(GinkgoLogr, registryAddr, nil)
go func() {
defer GinkgoRecover()
Expect(registryServer.Start(ctx)).To(Succeed(), "failed to start registry agent")
Expand Down
2 changes: 1 addition & 1 deletion internal/registry/registry_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ var _ = BeforeSuite(func() {
ctx, cancel := context.WithCancel(context.Background())
DeferCleanup(cancel)

server = registry.NewServer(GinkgoLogr, testServerAddr)
server = registry.NewServer(GinkgoLogr, testServerAddr, nil)
go func() {
defer GinkgoRecover()
Expect(server.Start(ctx)).To(Succeed(), "failed to start registry server")
Expand Down
76 changes: 75 additions & 1 deletion internal/registry/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"

"github.com/go-logr/logr"

"github.com/ironcore-dev/controller-utils/conditionutils"
metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"github.com/ironcore-dev/metal-operator/internal/api/registry"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// Server holds the HTTP server's state, including the systems store.
Expand All @@ -22,16 +27,18 @@ type Server struct {
mux *http.ServeMux
systemsStore *sync.Map
log logr.Logger
k8sClient client.Client
}

// NewServer initializes and returns a new Server instance.
func NewServer(log logr.Logger, addr string) *Server {
func NewServer(log logr.Logger, addr string, k8sClient client.Client) *Server {
mux := http.NewServeMux()
server := &Server{
addr: addr,
mux: mux,
systemsStore: &sync.Map{},
log: log,
k8sClient: k8sClient,
}
server.routes()
return server
Expand All @@ -42,6 +49,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/register", s.registerHandler)
s.mux.HandleFunc("/delete/", s.deleteHandler)
s.mux.HandleFunc("/systems/", s.systemsHandler)
s.mux.HandleFunc("/bootstate", s.bootstateHandler)
}

// registerHandler handles the /register endpoint.
Expand Down Expand Up @@ -116,6 +124,72 @@ func (s *Server) deleteHandler(w http.ResponseWriter, r *http.Request) {
s.log.Info("Deleted system UUID", "uuid", uuid)
}

func (s *Server) bootstateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed)
s.log.Info("Received unsupported HTTP method", "method", r.Method)
return
}
var bootstate registry.BootstatePayload
if err := json.NewDecoder(r.Body).Decode(&bootstate); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
s.log.Error(err, "Failed to decode bootstate payload")
return
}
log.Printf("Received boot state for system UUID: %s, Booted: %t\n", bootstate.SystemUUID, bootstate.Booted)
if !bootstate.Booted {
w.WriteHeader(http.StatusOK)
return
}
var servers metalv1alpha1.ServerList
if err := s.k8sClient.List(r.Context(), &servers, client.MatchingFields{"spec.systemUUID": bootstate.SystemUUID}); err != nil {
http.Error(w, fmt.Sprintf("Failed to list servers for system UUID %s: %v", bootstate.SystemUUID, err), http.StatusInternalServerError)
s.log.Error(err, "Failed to list servers for system", "systemUUID", bootstate.SystemUUID)
return
}
if len(servers.Items) != 1 {
http.Error(w, fmt.Sprintf("No servers found for system UUID %s", bootstate.SystemUUID), http.StatusNotFound)
s.log.Info("Found unexpected number of server of system", "systemUUID", bootstate.SystemUUID, "count", len(servers.Items))
return
}
server := servers.Items[0]
bootConfigRef := server.Spec.BootConfigurationRef
if bootConfigRef == nil {
http.Error(w, fmt.Sprintf("Servers for system UUID %s does not reference a ServerBootConfiguration", bootstate.SystemUUID), http.StatusNotFound)
s.log.Info("Server does not reference a ServerBootConfiguration", "server", server.Name)
return
}
bootConfigKey := client.ObjectKey{Namespace: bootConfigRef.Namespace, Name: bootConfigRef.Name}
var bootConfig metalv1alpha1.ServerBootConfiguration
if err := s.k8sClient.Get(r.Context(), bootConfigKey, &bootConfig); err != nil {
http.Error(w, fmt.Sprintf("No ServerBootConfig found for system UUID %s", bootstate.SystemUUID), http.StatusNotFound)
s.log.Error(err, "Failed to retrieve ServerBootConfiguration", "name", bootConfigKey.Name, "namespace", bootConfig.Namespace)
return
}
acc := conditionutils.NewAccessor(conditionutils.AccessorOptions{})
original := bootConfig.DeepCopy()
err := acc.UpdateSlice(
&bootConfig.Status.Conditions,
registry.BootStateReceivedCondition,
conditionutils.UpdateStatus(metav1.ConditionTrue),
conditionutils.UpdateReason("BootStateReceived"),
conditionutils.UpdateMessage("Server successfully posted boot state"),
conditionutils.UpdateObserved(&bootConfig),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update booted condition for ServerBootConfig %s: %v", bootConfig.Name, err), http.StatusInternalServerError)
s.log.Error(err, "Failed to update booted condition for ServerBootConfig", "name", bootConfigKey.Name, "namespace", bootConfig.Namespace)
return
}
if err := s.k8sClient.Status().Patch(r.Context(), &bootConfig, client.MergeFrom(original)); err != nil {
http.Error(w, fmt.Sprintf("Failed to update boot state for ServerBootConfig %s: %v", bootConfig.Name, err), http.StatusInternalServerError)
s.log.Error(err, "Failed to update boot state for ServerBootConfig", "name", bootConfigKey.Name, "namespace", bootConfig.Namespace)
return
}
s.log.Info("Updated boot state for ServerBootConfig", "name", bootConfigKey.Name, "namespace", bootConfig.Namespace)
w.WriteHeader(http.StatusOK)
}

// Start starts the server on the specified address and adds logging for key events.
func (s *Server) Start(ctx context.Context) error {
s.log.Info("Starting registry server", "address", s.addr)
Expand Down
2 changes: 2 additions & 0 deletions internal/webhook/v1alpha1/webhook_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
admissionv1 "k8s.io/api/admission/v1"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"github.com/ironcore-dev/metal-operator/internal/controller"

// +kubebuilder:scaffold:imports
apimachineryruntime "k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -111,6 +112,7 @@ var _ = BeforeSuite(func() {
Metrics: metricsserver.Options{BindAddress: "0"},
})
Expect(err).NotTo(HaveOccurred())
Expect(controller.RegisterIndexFields(ctx, mgr.GetFieldIndexer())).To(Succeed())

err = SetupEndpointWebhookWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
Expand Down
Loading