diff --git a/api/v1/prefectserver_types.go b/api/v1/prefectserver_types.go index eeafa97..a33a1b0 100644 --- a/api/v1/prefectserver_types.go +++ b/api/v1/prefectserver_types.go @@ -36,6 +36,13 @@ type PrefectServerSpec struct { // Image defines the exact image to deploy for the Prefect Server, overriding Version Image *string `json:"image,omitempty"` + // Host defines the host address to bind the Prefect Server to. + // Defaults to "0.0.0.0" for IPv4 compatibility. + // Use "" (empty string) to bind to all interfaces for IPv6-only or dual-stack environments. + // Note: Prefect does not accept "::" as a valid host value. + // +kubebuilder:validation:Optional + Host *string `json:"host,omitempty"` + // Resources defines the CPU and memory resources for each replica of the Prefect Server Resources corev1.ResourceRequirements `json:"resources,omitempty"` @@ -394,8 +401,13 @@ func (s *PrefectServer) Image() string { return DEFAULT_PREFECT_IMAGE } -func (s *PrefectServer) EntrypointArugments() []string { - command := []string{"prefect", "server", "start", "--host", "0.0.0.0"} +func (s *PrefectServer) EntrypointArguments() []string { + host := "0.0.0.0" // Default to IPv4 for backward compatibility + if s.Spec.Host != nil { + host = *s.Spec.Host + } + + command := []string{"prefect", "server", "start", "--host", host} command = append(command, s.Spec.ExtraArgs...) return command diff --git a/api/v1/prefectserver_types_test.go b/api/v1/prefectserver_types_test.go index d2804df..3c9d2e2 100644 --- a/api/v1/prefectserver_types_test.go +++ b/api/v1/prefectserver_types_test.go @@ -620,5 +620,50 @@ var _ = Describe("PrefectServer type", func() { Expect(envVars).To(ConsistOf(expectedEnvVars)) }) + + Context("Host binding configuration", func() { + It("should use default host 0.0.0.0 when Host is nil", func() { + server := &PrefectServer{ + Spec: PrefectServerSpec{}, + } + + args := server.EntrypointArguments() + Expect(args).To(Equal([]string{"prefect", "server", "start", "--host", "0.0.0.0"})) + }) + + It("should use empty string for IPv6/dual-stack when specified", func() { + server := &PrefectServer{ + Spec: PrefectServerSpec{ + Host: ptr.To(""), + }, + } + + args := server.EntrypointArguments() + Expect(args).To(Equal([]string{"prefect", "server", "start", "--host", ""})) + }) + + It("should use custom host with ExtraArgs", func() { + server := &PrefectServer{ + Spec: PrefectServerSpec{ + Host: ptr.To(""), + ExtraArgs: []string{"--some-arg", "some-value"}, + }, + } + + args := server.EntrypointArguments() + Expect(args).To(Equal([]string{"prefect", "server", "start", "--host", "", "--some-arg", "some-value"})) + }) + + It("should use specific IPv4 address when specified", func() { + server := &PrefectServer{ + Spec: PrefectServerSpec{ + Host: ptr.To("127.0.0.1"), + }, + } + + args := server.EntrypointArguments() + Expect(args).To(Equal([]string{"prefect", "server", "start", "--host", "127.0.0.1"})) + }) + }) }) }) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 5cb5895..6e6b793 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1,7 +1,5 @@ //go:build !ignore_autogenerated -//go:coverage ignore - /* Copyright 2024. @@ -554,6 +552,11 @@ func (in *PrefectServerSpec) DeepCopyInto(out *PrefectServerSpec) { *out = new(string) **out = **in } + if in.Host != nil { + in, out := &in.Host, &out.Host + *out = new(string) + **out = **in + } in.Resources.DeepCopyInto(&out.Resources) if in.ExtraContainers != nil { in, out := &in.ExtraContainers, &out.ExtraContainers diff --git a/deploy/charts/prefect-operator/crds/prefect.io_prefectservers.yaml b/deploy/charts/prefect-operator/crds/prefect.io_prefectservers.yaml index 4a96882..5c59885 100644 --- a/deploy/charts/prefect-operator/crds/prefect.io_prefectservers.yaml +++ b/deploy/charts/prefect-operator/crds/prefect.io_prefectservers.yaml @@ -1661,6 +1661,13 @@ spec: - port type: object type: array + host: + description: |- + Host defines the host address to bind the Prefect Server to. + Defaults to "0.0.0.0" for IPv4 compatibility. + Use "" (empty string) to bind to all interfaces for IPv6-only or dual-stack environments. + Note: Prefect does not accept "::" as a valid host value. + type: string image: description: Image defines the exact image to deploy for the Prefect Server, overriding Version diff --git a/deploy/samples/v1_prefectserver_dualstack.yaml b/deploy/samples/v1_prefectserver_dualstack.yaml new file mode 100644 index 0000000..802a504 --- /dev/null +++ b/deploy/samples/v1_prefectserver_dualstack.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: prefect.io/v1 +kind: PrefectServer +metadata: + name: prefect-dualstack + labels: + app.kubernetes.io/name: prefect-operator + app.kubernetes.io/managed-by: kustomize +spec: + host: "" # Empty string for dual-stack (uvicorn binds to all interfaces) + sqlite: + storageClassName: local-path + size: 1Gi diff --git a/deploy/samples/v1_prefectserver_ipv6.yaml b/deploy/samples/v1_prefectserver_ipv6.yaml new file mode 100644 index 0000000..3356c4a --- /dev/null +++ b/deploy/samples/v1_prefectserver_ipv6.yaml @@ -0,0 +1,10 @@ +apiVersion: prefect.io/v1 +kind: PrefectServer +metadata: + name: prefect-ipv6 + labels: + app.kubernetes.io/name: prefect-operator + app.kubernetes.io/managed-by: kustomize +spec: + host: "" # Empty string binds to all interfaces (works for IPv6-only and dual-stack) + ephemeral: {} diff --git a/internal/controller/prefectserver_controller.go b/internal/controller/prefectserver_controller.go index 43da0aa..1057a7b 100644 --- a/internal/controller/prefectserver_controller.go +++ b/internal/controller/prefectserver_controller.go @@ -374,7 +374,7 @@ func (r *PrefectServerReconciler) ephemeralDeploymentSpec(server *prefectiov1.Pr Image: server.Image(), ImagePullPolicy: corev1.PullIfNotPresent, - Args: server.EntrypointArugments(), + Args: server.EntrypointArguments(), VolumeMounts: []corev1.VolumeMount{ { Name: "prefect-data", @@ -458,7 +458,7 @@ func (r *PrefectServerReconciler) sqliteDeploymentSpec(server *prefectiov1.Prefe Image: server.Image(), ImagePullPolicy: corev1.PullIfNotPresent, - Args: server.EntrypointArugments(), + Args: server.EntrypointArguments(), VolumeMounts: []corev1.VolumeMount{ { Name: "prefect-data", @@ -514,7 +514,7 @@ func (r *PrefectServerReconciler) postgresDeploymentSpec(server *prefectiov1.Pre Image: server.Image(), ImagePullPolicy: corev1.PullIfNotPresent, - Args: server.EntrypointArugments(), + Args: server.EntrypointArguments(), Env: server.ToEnvVars(), Ports: []corev1.ContainerPort{ { diff --git a/internal/controller/prefectserver_controller_test.go b/internal/controller/prefectserver_controller_test.go index 566e1ad..191720c 100644 --- a/internal/controller/prefectserver_controller_test.go +++ b/internal/controller/prefectserver_controller_test.go @@ -644,6 +644,41 @@ var _ = Describe("PrefectServer controller", func() { Expect(container.Command).To(BeNil()) Expect(container.Args).To(Equal([]string{"prefect", "server", "start", "--host", "0.0.0.0", "--some-arg", "some-value"})) }) + + It("should create a Deployment with IPv6/dual-stack host (empty string)", func() { + name := types.NamespacedName{ + Namespace: namespaceName, + Name: "prefect-ipv6-server", + } + + prefectserver := &prefectiov1.PrefectServer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespaceName, + Name: "prefect-ipv6-server", + }, + Spec: prefectiov1.PrefectServerSpec{ + Host: ptr.To(""), + }, + } + Expect(k8sClient.Create(ctx, prefectserver)).To(Succeed()) + + controllerReconciler := &PrefectServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: name, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, name, deployment) + }).Should(Succeed()) + + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.Args).To(Equal([]string{"prefect", "server", "start", "--host", ""})) + }) }) Context("When evaluating changes with any server", func() {