Repository: gophercloud/gophercloud

Issue:
gophercloud >= 2.7 breaks on parsing security group time (with some Neutron configuration)
## Issue

We recently ran into a situation where the following error was raised by CAPO (Cluster API OpenStack provider) which uses gophercloud:

```
E0807 12:12:22.404583       1 controller.go:324] "Reconciler error" 
   err="failed to reconcile security groups: parsing time \"2024-11-26T11:03:35.521964\" 
            as \"2006-01-02T15:04:05Z07:00\": cannot parse \"\" as \"Z07:00\"" 
...
```

## Observations

This error started to appear when upgrading CAPO from 0.11.3 to 0.11.6, which among other changes upgrades gophercloud from 2.2.0 and 2.7.0.

I have observed this:
* gophercloud has code to let it accept two time formats for security groups `updated_at`/`created_at` fields [here](https://github.com/gophercloud/gophercloud/blob/5126c5bbd89e6e1a9aa9b00a6f862433c7b9db24/openstack/networking/v2/extensions/security/groups/results.go#L52): the standard ISO8601 format and the "older neutron time format" (called RFC3339NoZ in gophercloud code)
* in this OpenStack tenant the SG groups used by CAPO indeed have such timestamp that match the RFC3339NoZ format (both for the SG updated_at/created_at fields, **and for the fields of the SG rules that it includes**):
    ```
    +------------+----------------------------+
    | Field      | Value                      |
    +------------+----------------------------+
    | created_at | 2023-03-09T14:51:27.533307 |
    | updated_at | 2025-08-06T14:48:16.792728 |
    +------------+----------------------------+
    ```
* between gophercloud 2.2.0 and 2.7.0, there was one addition (in https://github.com/gophercloud/gophercloud/commit/99b0a51a254c4e3332a0117a6f2ed4461bdc99fc) that added support for `updated_at`/`created_at` for Security Group **Rules**, but without anything to accept the "older neutron time format"

## Analysis

Based on the observations above, it seems that the explanation is the following:
* gophercloud 2.2.0 parses our SGs successfully because gophercloud accepts the "older neutron time format" for Security Groups `updated_at`/`created_at`, and does not attempt to read the `updated_at`/`created_at` fields of the SG **Rules** that are embedded in Security Group resources
* gophercloud 2.7.0 breaks on parsing our SGs because it attempt to read the `updated_at`/`created_at` fields  of the SG **Rules** that are embedded in Security Group resources, but without support for the "older neutron time format" for these SG rules fields

## Context

The cluster on which this happens is using OpenStack Neutron from RedHat OSP16, with Contrail Neutron Core Plugin, which seems to be the reason for those `2023-03-09T14:51:27.533307` timestamps.  Those timestamps are not exactly like the "older neutron time format" (`RFC3339NoZ`) since they have the additional `.<microseconds>`, but they are compatible with it (Go will successfully parse `2023-03-09T14:51:27.533307`  with RFC3339NoZ format, as can be seen [in this sandbox example](https://go.dev/play/p/kkJ3PNM6KXJ))

### Version

v2 (current)

Hints from issue comments:
@mandre: Apologies, I wrongly assumed that the older neutron time format was legacy and we could not find any more OpenStack in the wild using this time format. I didn't realize RHOSP 16 (based on OpenStack Train) was still using it. The proposed patch is exactly how we should do it, thanks for the great bug report and the provided fix.@tmmorin: > Apologies, I wrongly assumed that the older neutron time format was legacy and we could not find any more OpenStack in the wild using this time format. I didn't realize RHOSP 16 (based on OpenStack Train) was still using it. The proposed patch is exactly how we should do it, thanks for the great bug report and the provided fix.

no apologies needed, this format is indeed legacy ...

(we just have this need on this very specific corner case platform)

Solution patch:
diff --git a/openstack/networking/v2/extensions/layer3/routers/results.go b/openstack/networking/v2/extensions/layer3/routers/results.go
index e9cd9783b..73f3b9313 100644
--- a/openstack/networking/v2/extensions/layer3/routers/results.go
+++ b/openstack/networking/v2/extensions/layer3/routers/results.go
@@ -82,10 +82,48 @@ type Router struct {
 	RevisionNumber int `json:"revision_number"`
 
 	// Timestamp when the router was created
-	CreatedAt time.Time `json:"created_at"`
+	CreatedAt time.Time `json:"-"`
 
 	// Timestamp when the router was last updated
-	UpdatedAt time.Time `json:"updated_at"`
+	UpdatedAt time.Time `json:"-"`
+}
+
+func (r *Router) UnmarshalJSON(b []byte) error {
+	type tmp Router
+
+	// Support for older neutron time format
+	var s1 struct {
+		tmp
+		CreatedAt gophercloud.JSONRFC3339NoZ `json:"created_at"`
+		UpdatedAt gophercloud.JSONRFC3339NoZ `json:"updated_at"`
+	}
+
+	err := json.Unmarshal(b, &s1)
+	if err == nil {
+		*r = Router(s1.tmp)
+		r.CreatedAt = time.Time(s1.CreatedAt)
+		r.UpdatedAt = time.Time(s1.UpdatedAt)
+
+		return nil
+	}
+
+	// Support for newer neutron time format
+	var s2 struct {
+		tmp
+		CreatedAt time.Time `json:"created_at"`
+		UpdatedAt time.Time `json:"updated_at"`
+	}
+
+	err = json.Unmarshal(b, &s2)
+	if err != nil {
+		return err
+	}
+
+	*r = Router(s2.tmp)
+	r.CreatedAt = time.Time(s2.CreatedAt)
+	r.UpdatedAt = time.Time(s2.UpdatedAt)
+
+	return nil
 }
 
 // RouterPage is the page returned by a pager when traversing over a
diff --git a/openstack/networking/v2/extensions/security/rules/results.go b/openstack/networking/v2/extensions/security/rules/results.go
index 9f1a92e35..ddc7c5b7c 100644
--- a/openstack/networking/v2/extensions/security/rules/results.go
+++ b/openstack/networking/v2/extensions/security/rules/results.go
@@ -1,6 +1,7 @@
 package rules
 
 import (
+	"encoding/json"
 	"time"
 
 	"github.com/gophercloud/gophercloud/v2"
@@ -67,10 +68,48 @@ type SecGroupRule struct {
 	RevisionNumber int `json:"revision_number"`
 
 	// Timestamp when the rule was created
-	CreatedAt time.Time `json:"created_at"`
+	CreatedAt time.Time `json:"-"`
 
 	// Timestamp when the rule was last updated
-	UpdatedAt time.Time `json:"updated_at"`
+	UpdatedAt time.Time `json:"-"`
+}
+
+func (r *SecGroupRule) UnmarshalJSON(b []byte) error {
+	type tmp SecGroupRule
+
+	// Support for older neutron time format
+	var s1 struct {
+		tmp
+		CreatedAt gophercloud.JSONRFC3339NoZ `json:"created_at"`
+		UpdatedAt gophercloud.JSONRFC3339NoZ `json:"updated_at"`
+	}
+
+	err := json.Unmarshal(b, &s1)
+	if err == nil {
+		*r = SecGroupRule(s1.tmp)
+		r.CreatedAt = time.Time(s1.CreatedAt)
+		r.UpdatedAt = time.Time(s1.UpdatedAt)
+
+		return nil
+	}
+
+	// Support for newer neutron time format
+	var s2 struct {
+		tmp
+		CreatedAt time.Time `json:"created_at"`
+		UpdatedAt time.Time `json:"updated_at"`
+	}
+
+	err = json.Unmarshal(b, &s2)
+	if err != nil {
+		return err
+	}
+
+	*r = SecGroupRule(s2.tmp)
+	r.CreatedAt = time.Time(s2.CreatedAt)
+	r.UpdatedAt = time.Time(s2.UpdatedAt)
+
+	return nil
 }
 
 // SecGroupRulePage is the page returned by a pager when traversing over a
diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go
index bde6823ea..cb5831e9e 100644
--- a/openstack/networking/v2/subnets/results.go
+++ b/openstack/networking/v2/subnets/results.go
@@ -1,6 +1,7 @@
 package subnets
 
 import (
+	"encoding/json"
 	"time"
 
 	"github.com/gophercloud/gophercloud/v2"
@@ -129,10 +130,48 @@ type Subnet struct {
 	SegmentID string `json:"segment_id"`
 
 	// Timestamp when the subnet was created
-	CreatedAt time.Time `json:"created_at"`
+	CreatedAt time.Time `json:"-"`
 
 	// Timestamp when the subnet was last updated
-	UpdatedAt time.Time `json:"updated_at"`
+	UpdatedAt time.Time `json:"-"`
+}
+
+func (r *Subnet) UnmarshalJSON(b []byte) error {
+	type tmp Subnet
+
+	// Support for older neutron time format
+	var s1 struct {
+		tmp
+		CreatedAt gophercloud.JSONRFC3339NoZ `json:"created_at"`
+		UpdatedAt gophercloud.JSONRFC3339NoZ `json:"updated_at"`
+	}
+
+	err := json.Unmarshal(b, &s1)
+	if err == nil {
+		*r = Subnet(s1.tmp)
+		r.CreatedAt = time.Time(s1.CreatedAt)
+		r.UpdatedAt = time.Time(s1.UpdatedAt)
+
+		return nil
+	}
+
+	// Support for newer neutron time format
+	var s2 struct {
+		tmp
+		CreatedAt time.Time `json:"created_at"`
+		UpdatedAt time.Time `json:"updated_at"`
+	}
+
+	err = json.Unmarshal(b, &s2)
+	if err != nil {
+		return err
+	}
+
+	*r = Subnet(s2.tmp)
+	r.CreatedAt = time.Time(s2.CreatedAt)
+	r.UpdatedAt = time.Time(s2.UpdatedAt)
+
+	return nil
 }
 
 // SubnetPage is the page returned by a pager when traversing over a collection



Test patch:
diff --git a/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go
index f66dda4e0..f6cd2c0bb 100644
--- a/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go
+++ b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go
@@ -34,6 +34,8 @@ func TestList(t *testing.T) {
             "admin_state_up": true,
             "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
             "distributed": false,
+            "created_at": "2017-12-28T07:21:40Z",
+            "updated_at": "2017-12-28T07:21:40Z",
             "id": "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b"
         },
         {
@@ -45,6 +47,8 @@ func TestList(t *testing.T) {
             "admin_state_up": true,
             "tenant_id": "33a40233088643acb66ff6eb0ebea679",
             "distributed": false,
+            "created_at": "2017-12-28T07:21:40",
+            "updated_at": "2017-12-28T07:21:40",
             "id": "a9254bdb-2613-4a13-ac4c-adc581fba50d"
         },
         {
@@ -86,6 +90,8 @@ func TestList(t *testing.T) {
 				Distributed:  false,
 				Name:         "second_routers",
 				ID:           "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b",
+				CreatedAt:    time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+				UpdatedAt:    time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 				TenantID:     "6b96ff0cb17a4b859e1e575d221683d3",
 			},
 			{
@@ -95,6 +101,8 @@ func TestList(t *testing.T) {
 				Distributed:  false,
 				Name:         "router1",
 				ID:           "a9254bdb-2613-4a13-ac4c-adc581fba50d",
+				CreatedAt:    time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+				UpdatedAt:    time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 				TenantID:     "33a40233088643acb66ff6eb0ebea679",
 			},
 			{
diff --git a/openstack/networking/v2/extensions/security/rules/testing/requests_test.go b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go
index 15975ed4e..4e1938984 100644
--- a/openstack/networking/v2/extensions/security/rules/testing/requests_test.go
+++ b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net/http"
 	"testing"
+	"time"
 
 	fake "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/common"
 	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/rules"
@@ -35,6 +36,8 @@ func TestList(t *testing.T) {
             "protocol": null,
             "remote_group_id": null,
             "remote_ip_prefix": null,
+            "created_at": "2017-12-28T07:21:40Z",
+            "updated_at": "2017-12-28T07:21:40Z",
             "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
             "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
         },
@@ -47,6 +50,8 @@ func TestList(t *testing.T) {
             "protocol": null,
             "remote_group_id": null,
             "remote_ip_prefix": null,
+            "created_at": "2017-12-28T07:21:40",
+            "updated_at": "2017-12-28T07:21:40",
             "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
             "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
         }
@@ -76,6 +81,8 @@ func TestList(t *testing.T) {
 				Protocol:       "",
 				RemoteGroupID:  "",
 				RemoteIPPrefix: "",
+				CreatedAt:      time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+				UpdatedAt:      time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 				SecGroupID:     "85cc3048-abc3-43cc-89b3-377341426ac5",
 				TenantID:       "e4f50856753b4dc6afee5fa6b9b6c550",
 			},
@@ -88,6 +95,8 @@ func TestList(t *testing.T) {
 				Protocol:       "",
 				RemoteGroupID:  "",
 				RemoteIPPrefix: "",
+				CreatedAt:      time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+				UpdatedAt:      time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 				SecGroupID:     "85cc3048-abc3-43cc-89b3-377341426ac5",
 				TenantID:       "e4f50856753b4dc6afee5fa6b9b6c550",
 			},
@@ -372,6 +381,8 @@ func TestGet(t *testing.T) {
         "protocol": null,
         "remote_group_id": null,
         "remote_ip_prefix": null,
+        "created_at": "2017-12-28T07:21:40Z",
+        "updated_at": "2017-12-28T07:21:40Z",
         "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
         "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
     }
@@ -390,6 +401,8 @@ func TestGet(t *testing.T) {
 	th.AssertEquals(t, "", sr.Protocol)
 	th.AssertEquals(t, "", sr.RemoteGroupID)
 	th.AssertEquals(t, "", sr.RemoteIPPrefix)
+	th.AssertEquals(t, time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC), sr.UpdatedAt)
+	th.AssertEquals(t, time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC), sr.CreatedAt)
 	th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sr.SecGroupID)
 	th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sr.TenantID)
 }
diff --git a/openstack/networking/v2/subnets/testing/fixtures_test.go b/openstack/networking/v2/subnets/testing/fixtures_test.go
index a44f1cc77..ebb005f62 100644
--- a/openstack/networking/v2/subnets/testing/fixtures_test.go
+++ b/openstack/networking/v2/subnets/testing/fixtures_test.go
@@ -1,6 +1,8 @@
 package testing
 
 import (
+	"time"
+
 	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets"
 )
 
@@ -24,6 +26,8 @@ const SubnetListResult = `
 			"ip_version": 4,
 			"gateway_ip": "10.0.0.1",
 			"cidr": "10.0.0.0/24",
+			"created_at": "2017-12-28T07:21:40Z",
+			"updated_at": "2017-12-28T07:21:40Z",
 			"id": "08eae331-0402-425a-923c-34f7cfe39c1b"
 		},
 		{
@@ -43,6 +47,8 @@ const SubnetListResult = `
 			"ip_version": 4,
 			"gateway_ip": "192.0.0.1",
 			"cidr": "192.0.0.0/8",
+			"created_at": "2017-12-28T07:21:40",
+			"updated_at": "2017-12-28T07:21:40",
 			"id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
 		},
 		{
@@ -104,6 +110,8 @@ var Subnet1 = subnets.Subnet{
 	IPVersion:  4,
 	GatewayIP:  "10.0.0.1",
 	CIDR:       "10.0.0.0/24",
+	CreatedAt:  time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+	UpdatedAt:  time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 	ID:         "08eae331-0402-425a-923c-34f7cfe39c1b",
 }
 
@@ -125,6 +133,8 @@ var Subnet2 = subnets.Subnet{
 	IPVersion:  4,
 	GatewayIP:  "192.0.0.1",
 	CIDR:       "192.0.0.0/8",
+	CreatedAt:  time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
+	UpdatedAt:  time.Date(2017, 12, 28, 07, 21, 40, 0, time.UTC),
 	ID:         "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
 }