diff --git a/api/v1alpha/location_types.go b/api/v1alpha/location_types.go index d4da0a14..1d3917b7 100644 --- a/api/v1alpha/location_types.go +++ b/api/v1alpha/location_types.go @@ -35,6 +35,33 @@ type LocationSpec struct { // // +kubebuilder:validation:Required Provider LocationProvider `json:"provider"` + + // The geographic coordinates of the location, used by consumers that need + // to plot the location on a map. + // + // +kubebuilder:validation:Optional + Coordinates *Coordinates `json:"coordinates,omitempty"` +} + +// Coordinates describes a geographic point in decimal degrees (WGS 84). +// +// Latitude and longitude are serialized as strings rather than floats, per +// Kubernetes API convention (float precision/serialization varies across +// client languages). +type Coordinates struct { + // Latitude in decimal degrees, in the range [-90, 90]. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^-?\d{1,2}(\.\d+)?$` + // +kubebuilder:validation:XValidation:message="latitude must be between -90 and 90",rule="double(self) >= -90.0 && double(self) <= 90.0" + Latitude string `json:"latitude"` + + // Longitude in decimal degrees, in the range [-180, 180]. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^-?\d{1,3}(\.\d+)?$` + // +kubebuilder:validation:XValidation:message="longitude must be between -180 and 180",rule="double(self) >= -180.0 && double(self) <= 180.0" + Longitude string `json:"longitude"` } type LocationProvider struct { diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index e82f9295..aceb7595 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -90,6 +90,21 @@ func (in *ContactSet) DeepCopy() *ContactSet { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Coordinates) DeepCopyInto(out *Coordinates) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Coordinates. +func (in *Coordinates) DeepCopy() *Coordinates { + if in == nil { + return nil + } + out := new(Coordinates) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSSECInfo) DeepCopyInto(out *DNSSECInfo) { *out = *in @@ -831,6 +846,11 @@ func (in *LocationSpec) DeepCopyInto(out *LocationSpec) { } } in.Provider.DeepCopyInto(&out.Provider) + if in.Coordinates != nil { + in, out := &in.Coordinates, &out.Coordinates + *out = new(Coordinates) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocationSpec. diff --git a/config/crd/bases/networking.datumapis.com_locations.yaml b/config/crd/bases/networking.datumapis.com_locations.yaml index a107c328..a4555080 100644 --- a/config/crd/bases/networking.datumapis.com_locations.yaml +++ b/config/crd/bases/networking.datumapis.com_locations.yaml @@ -55,6 +55,30 @@ spec: spec: description: LocationSpec defines the desired state of Location. properties: + coordinates: + description: |- + The geographic coordinates of the location, used by consumers that need + to plot the location on a map. + properties: + latitude: + description: Latitude in decimal degrees, in the range [-90, 90]. + pattern: ^-?\d{1,2}(\.\d+)?$ + type: string + x-kubernetes-validations: + - message: latitude must be between -90 and 90 + rule: double(self) >= -90.0 && double(self) <= 90.0 + longitude: + description: Longitude in decimal degrees, in the range [-180, + 180]. + pattern: ^-?\d{1,3}(\.\d+)?$ + type: string + x-kubernetes-validations: + - message: longitude must be between -180 and 180 + rule: double(self) >= -180.0 && double(self) <= 180.0 + required: + - latitude + - longitude + type: object locationClassName: description: "The location class that indicates control plane behavior of entities\nassociated with the location.\n\nValid values are:\n\t- diff --git a/docs/api/locations.md b/docs/api/locations.md index 2f1aaf1f..217048c9 100644 --- a/docs/api/locations.md +++ b/docs/api/locations.md @@ -113,6 +113,14 @@ as: - topology.datum.net/city-code
true + + coordinates + object + + The geographic coordinates of the location, used by consumers that need +to plot the location on a map.
+ + false @@ -189,6 +197,45 @@ namespace.
+### Location.spec.coordinates +[↩ Parent](#locationspec) + + + +The geographic coordinates of the location, used by consumers that need +to plot the location on a map. + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
latitudestring + Latitude in decimal degrees, in the range [-90, 90].
+
+ Validations:
  • double(self) >= -90.0 && double(self) <= 90.0: latitude must be between -90 and 90
  • +
    true
    longitudestring + Longitude in decimal degrees, in the range [-180, 180].
    +
    + Validations:
  • double(self) >= -180.0 && double(self) <= 180.0: longitude must be between -180 and 180
  • +
    true
    + + ### Location.status [↩ Parent](#location) diff --git a/test/crd/location_coordinates_test.go b/test/crd/location_coordinates_test.go new file mode 100644 index 00000000..78d0678f --- /dev/null +++ b/test/crd/location_coordinates_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package crd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func baseLocationSpec() networkingv1alpha.LocationSpec { + return networkingv1alpha.LocationSpec{ + LocationClassName: "datum-managed", + Topology: map[string]string{ + "topology.datum.net/city-code": "DFW", + }, + Provider: networkingv1alpha.LocationProvider{ + GCP: &networkingv1alpha.GCPLocationProvider{ + ProjectID: "datum-cloud-poc-1", + Region: "us-south1", + Zone: "us-south1-a", + }, + }, + } +} + +// TestLocationCoordinatesOptional asserts a Location can still be created +// without coordinates, so the new field doesn't break existing callers. +func TestLocationCoordinatesOptional(t *testing.T) { + cl := requireEnv(t) + ctx := context.Background() + + loc := &networkingv1alpha.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "no-coordinates"}, + Spec: baseLocationSpec(), + } + require.NoError(t, cl.Create(ctx, loc)) + t.Cleanup(func() { _ = cl.Delete(ctx, loc) }) + + var got networkingv1alpha.Location + require.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(loc), &got)) + assert.Nil(t, got.Spec.Coordinates) +} + +// TestLocationCoordinatesValid asserts a Location with well-formed +// coordinates is accepted and round-trips. +func TestLocationCoordinatesValid(t *testing.T) { + cl := requireEnv(t) + ctx := context.Background() + + spec := baseLocationSpec() + spec.Coordinates = &networkingv1alpha.Coordinates{ + Latitude: "32.8968", + Longitude: "-97.0380", + } + loc := &networkingv1alpha.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "valid-coordinates"}, + Spec: spec, + } + require.NoError(t, cl.Create(ctx, loc)) + t.Cleanup(func() { _ = cl.Delete(ctx, loc) }) + + var got networkingv1alpha.Location + require.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(loc), &got)) + require.NotNil(t, got.Spec.Coordinates) + assert.Equal(t, "32.8968", got.Spec.Coordinates.Latitude) + assert.Equal(t, "-97.0380", got.Spec.Coordinates.Longitude) +} + +// TestLocationRejectsOutOfRangeCoordinates asserts the CEL range rules reject +// a latitude/longitude outside valid Earth coordinates, even though the +// pattern alone would accept the string shape. +func TestLocationRejectsOutOfRangeCoordinates(t *testing.T) { + cl := requireEnv(t) + ctx := context.Background() + + spec := baseLocationSpec() + spec.Coordinates = &networkingv1alpha.Coordinates{ + Latitude: "95.0", + Longitude: "-97.0380", + } + loc := &networkingv1alpha.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "out-of-range-latitude"}, + Spec: spec, + } + err := cl.Create(ctx, loc) + require.Error(t, err, "latitude outside [-90, 90] must be rejected") + assert.Truef(t, apierrors.IsInvalid(err), "expected an Invalid error, got %v", err) +} + +// TestLocationRejectsMalformedCoordinate asserts the pattern rule rejects a +// non-numeric coordinate string. +func TestLocationRejectsMalformedCoordinate(t *testing.T) { + cl := requireEnv(t) + ctx := context.Background() + + spec := baseLocationSpec() + spec.Coordinates = &networkingv1alpha.Coordinates{ + Latitude: "not-a-number", + Longitude: "-97.0380", + } + loc := &networkingv1alpha.Location{ + ObjectMeta: metav1.ObjectMeta{Name: "malformed-latitude"}, + Spec: spec, + } + err := cl.Create(ctx, loc) + require.Error(t, err, "non-numeric latitude must be rejected") + assert.Truef(t, apierrors.IsInvalid(err), "expected an Invalid error, got %v", err) +}