From 65ec1512eb562f649e52590ffc4004c091854391 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Mon, 22 Jun 2026 20:11:04 +0200 Subject: [PATCH 1/9] [Feat] Add EPS v1 client and project management functionality --- acceptance/clients/clients.go | 11 ++ acceptance/openstack/eps/v1/projects_test.go | 32 ++++ openstack/client.go | 10 ++ openstack/eps/v1/projects/requests.go | 146 ++++++++++++++++++ openstack/eps/v1/projects/results.go | 65 ++++++++ .../eps/v1/projects/testing/requests_test.go | 117 ++++++++++++++ openstack/eps/v1/projects/urls.go | 11 ++ 7 files changed, 392 insertions(+) create mode 100644 acceptance/openstack/eps/v1/projects_test.go create mode 100644 openstack/eps/v1/projects/requests.go create mode 100644 openstack/eps/v1/projects/results.go create mode 100644 openstack/eps/v1/projects/testing/requests_test.go create mode 100644 openstack/eps/v1/projects/urls.go diff --git a/acceptance/clients/clients.go b/acceptance/clients/clients.go index f82cf27ee..12a18bb1c 100644 --- a/acceptance/clients/clients.go +++ b/acceptance/clients/clients.go @@ -1130,6 +1130,17 @@ func NewRMSClient() (client *golangsdk.ServiceClient, err error) { }) } +// NewEPSV1Client returns authenticated EPS v1 client +func NewEPSV1Client() (client *golangsdk.ServiceClient, err error) { + cc, err := CloudAndClient() + if err != nil { + return nil, err + } + return openstack.NewEPSV1(cc.ProviderClient, golangsdk.EndpointOpts{ + Region: cc.RegionName, + }) +} + // NewCCIClient returns authenticated CCI v2 client func NewCCIClient() (client *golangsdk.ServiceClient, err error) { cc, err := CloudAndClient() diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go new file mode 100644 index 000000000..888022b52 --- /dev/null +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -0,0 +1,32 @@ +package v1 + +import ( + "testing" + + "github.com/opentelekomcloud/gophertelekomcloud/acceptance/clients" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/projects" + th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" +) + +func TestEnterpriseProjectsListAndGet(t *testing.T) { + client, err := clients.NewEPSV1Client() + th.AssertNoErr(t, err) + + allPages, err := projects.List(client, projects.ListOpts{}).AllPages() + th.AssertNoErr(t, err) + + allProjects, err := projects.ExtractProjects(allPages) + th.AssertNoErr(t, err) + + enterpriseProjectID := clients.EnvOS.GetEnv("OS_ENTERPRISE_PROJECT_ID") + if enterpriseProjectID == "" { + if len(allProjects) == 0 { + t.Skip("no enterprise projects are available") + } + enterpriseProjectID = allProjects[0].ID + } + + p, err := projects.Get(client, enterpriseProjectID).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, enterpriseProjectID, p.ID) +} diff --git a/openstack/client.go b/openstack/client.go index d285b1516..f3b019385 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -1105,6 +1105,16 @@ func NewRmsServiceV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts return initClientOpts(client, eo, "rms") } +// NewEPSV1 creates a ServiceClient that may be used to access the EPS service. +func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { + sc, err := initClientOpts(client, eo, "eps") + if err != nil { + return nil, err + } + sc.ResourceBase = sc.Endpoint + return sc, nil +} + func NewCCIServiceV2(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { sc, err := initClientOpts(client, eo, "ccev2.0") if err != nil { diff --git a/openstack/eps/v1/projects/requests.go b/openstack/eps/v1/projects/requests.go new file mode 100644 index 000000000..0ac93f414 --- /dev/null +++ b/openstack/eps/v1/projects/requests.go @@ -0,0 +1,146 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/pagination" +) + +type ListOptsBuilder interface { + ToProjectListQuery() (string, error) +} + +type ListOpts struct { + ID string `q:"id"` + Limit int `q:"limit"` + Name string `q:"name"` + Offset int `q:"offset"` + SortDir string `q:"sort_dir"` + SortKey string `q:"sort_key"` + Status int `q:"status"` +} + +func (opts ListOpts) ToProjectListQuery() (string, error) { + q, err := golangsdk.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +func List(client *golangsdk.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := rootURL(client) + if opts != nil { + query, err := opts.ToProjectListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + + return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page { + return ProjectPage{pagination.SinglePageBase(r)} + }) +} + +type CreateOptsBuilder interface { + ToProjectCreateMap() (map[string]interface{}, error) +} + +type CreateOpts struct { + Name string `json:"name" required:"true"` + Description string `json:"description,omitempty"` +} + +func (opts CreateOpts) ToProjectCreateMap() (map[string]interface{}, error) { + b, err := golangsdk.BuildRequestBody(opts, "") + if err != nil { + return nil, err + } + return b, nil +} + +func Create(client *golangsdk.ServiceClient, opts CreateOptsBuilder) (r CreateResult) { + b, err := opts.ToProjectCreateMap() + if err != nil { + r.Err = err + return + } + _, r.Err = client.Post(rootURL(client), b, &r.Body, &golangsdk.RequestOpts{ + OkCodes: []int{200, 201}, + }) + return +} + +func Get(client *golangsdk.ServiceClient, id string) (r GetResult) { + _, r.Err = client.Get(resourceURL(client, id), &r.Body, nil) + return +} + +type UpdateOptsBuilder interface { + ToProjectUpdateMap() (map[string]interface{}, error) +} + +type UpdateOpts struct { + Name string `json:"name" required:"true"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +func (opts UpdateOpts) ToProjectUpdateMap() (map[string]interface{}, error) { + b, err := golangsdk.BuildRequestBody(opts, "") + if err != nil { + return nil, err + } + return b, nil +} + +func Update(client *golangsdk.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) { + b, err := opts.ToProjectUpdateMap() + if err != nil { + r.Err = err + return + } + _, r.Err = client.Put(resourceURL(client, id), b, &r.Body, &golangsdk.RequestOpts{ + OkCodes: []int{200}, + }) + return +} + +func Delete(client *golangsdk.ServiceClient, id string) (r DeleteResult) { + _, r.Err = client.Delete(resourceURL(client, id), &golangsdk.RequestOpts{ + OkCodes: []int{200, 202, 204}, + }) + return +} + +type ActionOptsBuilder interface { + ToProjectActionMap() (map[string]interface{}, error) +} + +type ActionOpts struct { + Action string `json:"action" required:"true"` +} + +func (opts ActionOpts) ToProjectActionMap() (map[string]interface{}, error) { + b, err := golangsdk.BuildRequestBody(opts, "") + if err != nil { + return nil, err + } + return b, nil +} + +type ActionResult struct { + golangsdk.ErrResult +} + +func Action(client *golangsdk.ServiceClient, id string, opts ActionOptsBuilder) (r ActionResult) { + b, err := opts.ToProjectActionMap() + if err != nil { + r.Err = err + return + } + _, r.Err = client.Post(resourceURL(client, id)+"/action", b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{200, 204}, + }) + return +} diff --git a/openstack/eps/v1/projects/results.go b/openstack/eps/v1/projects/results.go new file mode 100644 index 000000000..8f625a4e8 --- /dev/null +++ b/openstack/eps/v1/projects/results.go @@ -0,0 +1,65 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/pagination" +) + +type EnterpriseProject struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Type string `json:"type"` + DeleteFlag bool `json:"delete_flag"` +} + +type commonResult struct { + golangsdk.Result +} + +func (r commonResult) Extract() (*EnterpriseProject, error) { + var s struct { + EnterpriseProject *EnterpriseProject `json:"enterprise_project"` + } + err := r.ExtractInto(&s) + return s.EnterpriseProject, err +} + +type CreateResult struct { + commonResult +} + +type GetResult struct { + commonResult +} + +type UpdateResult struct { + commonResult +} + +type DeleteResult struct { + golangsdk.ErrResult +} + +type ProjectPage struct { + pagination.SinglePageBase +} + +func (r ProjectPage) IsEmpty() (bool, error) { + data, err := ExtractProjects(r) + if err != nil { + return false, err + } + return len(data) == 0, err +} + +func ExtractProjects(r pagination.Page) ([]EnterpriseProject, error) { + var s struct { + EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` + } + err := (r.(ProjectPage)).ExtractInto(&s) + return s.EnterpriseProjects, err +} diff --git a/openstack/eps/v1/projects/testing/requests_test.go b/openstack/eps/v1/projects/testing/requests_test.go new file mode 100644 index 000000000..62c55c18b --- /dev/null +++ b/openstack/eps/v1/projects/testing/requests_test.go @@ -0,0 +1,117 @@ +package testing + +import ( + "fmt" + "net/http" + "testing" + + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/projects" + "github.com/opentelekomcloud/gophertelekomcloud/pagination" + th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" + "github.com/opentelekomcloud/gophertelekomcloud/testhelper/client" +) + +const epID = "0" + +func TestList(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodGet) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"enterprise_projects":[{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}]}`) + }) + + pages := 0 + err := projects.List(client.ServiceClient(), projects.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + pages++ + actual, err := projects.ExtractProjects(page) + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, len(actual)) + th.AssertEquals(t, epID, actual[0].ID) + th.AssertEquals(t, "default", actual[0].Name) + return true, nil + }) + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, pages) +} + +func TestGet(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodGet) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"enterprise_project":{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}}`) + }) + + actual, err := projects.Get(client.ServiceClient(), epID).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, epID, actual.ID) + th.AssertEquals(t, "default", actual.Name) +} + +func TestCreate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodPost) + th.TestJSONRequest(t, r, `{"name":"project-a","description":"created by test"}`) + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, `{"enterprise_project":{"id":"0","name":"project-a","description":"created by test","status":1,"type":"prod","delete_flag":false}}`) + }) + + actual, err := projects.Create(client.ServiceClient(), projects.CreateOpts{ + Name: "project-a", + Description: "created by test", + }).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, "project-a", actual.Name) +} + +func TestUpdate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodPut) + th.TestJSONRequest(t, r, `{"name":"default","description":"updated by test"}`) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"enterprise_project":{"id":"0","name":"default","description":"updated by test","status":1,"type":"prod","delete_flag":false}}`) + }) + + actual, err := projects.Update(client.ServiceClient(), epID, projects.UpdateOpts{Name: "default", Description: "updated by test"}).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, "updated by test", actual.Description) +} + +func TestDelete(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodDelete) + w.WriteHeader(http.StatusNoContent) + }) + + err := projects.Delete(client.ServiceClient(), epID).ExtractErr() + th.AssertNoErr(t, err) +} + +func TestAction(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/"+epID+"/action", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodPost) + th.TestJSONRequest(t, r, `{"action":"enable"}`) + w.WriteHeader(http.StatusNoContent) + }) + + err := projects.Action(client.ServiceClient(), epID, projects.ActionOpts{Action: "enable"}).ExtractErr() + th.AssertNoErr(t, err) +} diff --git a/openstack/eps/v1/projects/urls.go b/openstack/eps/v1/projects/urls.go new file mode 100644 index 000000000..9341b0032 --- /dev/null +++ b/openstack/eps/v1/projects/urls.go @@ -0,0 +1,11 @@ +package projects + +import golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + +func rootURL(c *golangsdk.ServiceClient) string { + return c.ServiceURL("enterprise-projects") +} + +func resourceURL(c *golangsdk.ServiceClient, id string) string { + return c.ServiceURL("enterprise-projects", id) +} From dfd4fb7ecfb75bfeb44e92554fb33dfff6fdc92b Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Fri, 26 Jun 2026 18:58:56 +0200 Subject: [PATCH 2/9] [Feat] Enhance EPS v1 client with domain-scoped authentication and project lifecycle management --- acceptance/clients/clients.go | 37 +++++++++-- acceptance/openstack/eps/v1/projects_test.go | 62 ++++++++++++++++++- openstack/client.go | 5 +- openstack/eps/v1/projects/requests.go | 7 --- openstack/eps/v1/projects/results.go | 28 +++++---- .../eps/v1/projects/testing/requests_test.go | 22 ++----- openstack/eps/v1/projects/urls.go | 8 ++- 7 files changed, 121 insertions(+), 48 deletions(-) diff --git a/acceptance/clients/clients.go b/acceptance/clients/clients.go index 12a18bb1c..b672771cc 100644 --- a/acceptance/clients/clients.go +++ b/acceptance/clients/clients.go @@ -1130,15 +1130,40 @@ func NewRMSClient() (client *golangsdk.ServiceClient, err error) { }) } -// NewEPSV1Client returns authenticated EPS v1 client +// NewEPSV1Client returns authenticated EPS v1 client with domain-scoped token. +// EPS is a global service that requires domain-scoped authentication and is not +// in the Keystone service catalog, so the endpoint is constructed from the auth URL. func NewEPSV1Client() (client *golangsdk.ServiceClient, err error) { - cc, err := CloudAndClient() + cloud, err := EnvOS.Cloud() if err != nil { - return nil, err + return nil, fmt.Errorf("error constructing cloud configuration: %w", err) } - return openstack.NewEPSV1(cc.ProviderClient, golangsdk.EndpointOpts{ - Region: cc.RegionName, - }) + + opts := golangsdk.AuthOptions{ + IdentityEndpoint: cloud.AuthInfo.AuthURL, + Username: cloud.AuthInfo.Username, + Password: cloud.AuthInfo.Password, + DomainName: cloud.AuthInfo.UserDomainName, + } + + pClient, err := openstack.AuthenticatedClient(opts) + if err != nil { + return nil, fmt.Errorf("error creating domain-scoped provider client: %w", err) + } + + // Derive EPS endpoint from IAM URL: https://iam.{region}.xxx → https://eps.{region}.xxx/v1.0/ + u, err := url.Parse(cloud.AuthInfo.AuthURL) + if err != nil { + return nil, fmt.Errorf("error parsing auth URL: %w", err) + } + epsHost := strings.Replace(u.Host, "iam.", "eps.", 1) + epsEndpoint := fmt.Sprintf("https://%s/v1.0/", epsHost) + + return &golangsdk.ServiceClient{ + ProviderClient: pClient, + Endpoint: epsEndpoint, + ResourceBase: epsEndpoint, + }, nil } // NewCCIClient returns authenticated CCI v2 client diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go index 888022b52..8d559d9ea 100644 --- a/acceptance/openstack/eps/v1/projects_test.go +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -20,13 +20,71 @@ func TestEnterpriseProjectsListAndGet(t *testing.T) { enterpriseProjectID := clients.EnvOS.GetEnv("OS_ENTERPRISE_PROJECT_ID") if enterpriseProjectID == "" { - if len(allProjects) == 0 { + if len(allProjects.EnterpriseProjects) == 0 { t.Skip("no enterprise projects are available") } - enterpriseProjectID = allProjects[0].ID + enterpriseProjectID = allProjects.EnterpriseProjects[0].ID } p, err := projects.Get(client, enterpriseProjectID).Extract() th.AssertNoErr(t, err) th.AssertEquals(t, enterpriseProjectID, p.ID) } + +func TestEnterpriseProjectsLifecycle(t *testing.T) { + client, err := clients.NewEPSV1Client() + th.AssertNoErr(t, err) + + // Create + created, err := projects.Create(client, projects.CreateOpts{ + Name: "eps-sdk-test", + Description: "Created by gophertelekomcloud acceptance test", + }).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, "eps-sdk-test", created.Name) + t.Logf("Created enterprise project: %s (%s)", created.Name, created.ID) + + // Update + updated, err := projects.Update(client, created.ID, projects.UpdateOpts{ + Name: "eps-sdk-test-updated", + Description: "Updated by acceptance test", + }).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, "eps-sdk-test-updated", updated.Name) + + // List with name filter + allPages, err := projects.List(client, projects.ListOpts{ + Name: "eps-sdk-test-updated", + }).AllPages() + th.AssertNoErr(t, err) + + allProjects, err := projects.ExtractProjects(allPages) + th.AssertNoErr(t, err) + if allProjects.TotalCount < 1 { + t.Fatal("expected at least 1 project in filtered list") + } + + // Disable (Action) + err = projects.Action(client, created.ID, projects.ActionOpts{ + Action: "disable", + }).ExtractErr() + th.AssertNoErr(t, err) + t.Log("Disabled enterprise project") + + // Verify disabled + disabled, err := projects.Get(client, created.ID).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, 2, disabled.Status) + + // Enable (Action) + err = projects.Action(client, created.ID, projects.ActionOpts{ + Action: "enable", + }).ExtractErr() + th.AssertNoErr(t, err) + t.Log("Enabled enterprise project") + + // Verify enabled + enabled, err := projects.Get(client, created.ID).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, enabled.Status) +} diff --git a/openstack/client.go b/openstack/client.go index f3b019385..328008b89 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -1107,10 +1107,13 @@ func NewRmsServiceV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts // NewEPSV1 creates a ServiceClient that may be used to access the EPS service. func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { - sc, err := initClientOpts(client, eo, "eps") + sc, err := initCommonServiceClient(client, eo, "eps", "v1.0") if err != nil { return nil, err } + if client.ProjectID != "" { + sc.Endpoint = strings.Replace(sc.Endpoint, client.ProjectID+"/", "", 1) + } sc.ResourceBase = sc.Endpoint return sc, nil } diff --git a/openstack/eps/v1/projects/requests.go b/openstack/eps/v1/projects/requests.go index 0ac93f414..922cd13ae 100644 --- a/openstack/eps/v1/projects/requests.go +++ b/openstack/eps/v1/projects/requests.go @@ -106,13 +106,6 @@ func Update(client *golangsdk.ServiceClient, id string, opts UpdateOptsBuilder) return } -func Delete(client *golangsdk.ServiceClient, id string) (r DeleteResult) { - _, r.Err = client.Delete(resourceURL(client, id), &golangsdk.RequestOpts{ - OkCodes: []int{200, 202, 204}, - }) - return -} - type ActionOptsBuilder interface { ToProjectActionMap() (map[string]interface{}, error) } diff --git a/openstack/eps/v1/projects/results.go b/openstack/eps/v1/projects/results.go index 8f625a4e8..e1f269ff3 100644 --- a/openstack/eps/v1/projects/results.go +++ b/openstack/eps/v1/projects/results.go @@ -9,11 +9,11 @@ type EnterpriseProject struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` + Type string `json:"type"` Status int `json:"status"` + DeleteFlag bool `json:"delete_flag"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` - Type string `json:"type"` - DeleteFlag bool `json:"delete_flag"` } type commonResult struct { @@ -25,7 +25,10 @@ func (r commonResult) Extract() (*EnterpriseProject, error) { EnterpriseProject *EnterpriseProject `json:"enterprise_project"` } err := r.ExtractInto(&s) - return s.EnterpriseProject, err + if err != nil { + return nil, err + } + return s.EnterpriseProject, nil } type CreateResult struct { @@ -40,10 +43,6 @@ type UpdateResult struct { commonResult } -type DeleteResult struct { - golangsdk.ErrResult -} - type ProjectPage struct { pagination.SinglePageBase } @@ -53,13 +52,16 @@ func (r ProjectPage) IsEmpty() (bool, error) { if err != nil { return false, err } - return len(data) == 0, err + return len(data.EnterpriseProjects) == 0, err } -func ExtractProjects(r pagination.Page) ([]EnterpriseProject, error) { - var s struct { - EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` - } +type ProjectListResult struct { + EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` + TotalCount int `json:"total_count"` +} + +func ExtractProjects(r pagination.Page) (*ProjectListResult, error) { + var s ProjectListResult err := (r.(ProjectPage)).ExtractInto(&s) - return s.EnterpriseProjects, err + return &s, err } diff --git a/openstack/eps/v1/projects/testing/requests_test.go b/openstack/eps/v1/projects/testing/requests_test.go index 62c55c18b..325949441 100644 --- a/openstack/eps/v1/projects/testing/requests_test.go +++ b/openstack/eps/v1/projects/testing/requests_test.go @@ -20,7 +20,7 @@ func TestList(t *testing.T) { th.Mux.HandleFunc("/enterprise-projects", func(w http.ResponseWriter, r *http.Request) { th.TestMethod(t, r, http.MethodGet) w.Header().Add("Content-Type", "application/json") - _, _ = fmt.Fprint(w, `{"enterprise_projects":[{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}]}`) + _, _ = fmt.Fprint(w, `{"enterprise_projects":[{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}],"total_count":1}`) }) pages := 0 @@ -28,9 +28,10 @@ func TestList(t *testing.T) { pages++ actual, err := projects.ExtractProjects(page) th.AssertNoErr(t, err) - th.AssertEquals(t, 1, len(actual)) - th.AssertEquals(t, epID, actual[0].ID) - th.AssertEquals(t, "default", actual[0].Name) + th.AssertEquals(t, 1, len(actual.EnterpriseProjects)) + th.AssertEquals(t, 1, actual.TotalCount) + th.AssertEquals(t, epID, actual.EnterpriseProjects[0].ID) + th.AssertEquals(t, "default", actual.EnterpriseProjects[0].Name) return true, nil }) th.AssertNoErr(t, err) @@ -89,19 +90,6 @@ func TestUpdate(t *testing.T) { th.AssertEquals(t, "updated by test", actual.Description) } -func TestDelete(t *testing.T) { - th.SetupHTTP() - defer th.TeardownHTTP() - - th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { - th.TestMethod(t, r, http.MethodDelete) - w.WriteHeader(http.StatusNoContent) - }) - - err := projects.Delete(client.ServiceClient(), epID).ExtractErr() - th.AssertNoErr(t, err) -} - func TestAction(t *testing.T) { th.SetupHTTP() defer th.TeardownHTTP() diff --git a/openstack/eps/v1/projects/urls.go b/openstack/eps/v1/projects/urls.go index 9341b0032..872c1304a 100644 --- a/openstack/eps/v1/projects/urls.go +++ b/openstack/eps/v1/projects/urls.go @@ -2,10 +2,14 @@ package projects import golangsdk "github.com/opentelekomcloud/gophertelekomcloud" +const ( + resourcePath = "enterprise-projects" +) + func rootURL(c *golangsdk.ServiceClient) string { - return c.ServiceURL("enterprise-projects") + return c.ServiceURL(resourcePath) } func resourceURL(c *golangsdk.ServiceClient, id string) string { - return c.ServiceURL("enterprise-projects", id) + return c.ServiceURL(resourcePath, id) } From d054b49a8defcef329a5824fc563237ef703baa9 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Fri, 26 Jun 2026 19:16:38 +0200 Subject: [PATCH 3/9] [Feat] Implement full support for EPS v1 API, including resource management, version querying, and provider listing --- acceptance/openstack/eps/v1/projects_test.go | 75 +++++++- openstack/eps/v1/providers/list.go | 44 +++++ .../eps/v1/providers/testing/list_test.go | 49 +++++ openstack/eps/v1/resources/requests.go | 85 +++++++++ .../eps/v1/resources/testing/requests_test.go | 55 ++++++ openstack/eps/v1/versions/list.go | 38 ++++ .../eps/v1/versions/testing/list_test.go | 34 ++++ review-eps-v1.md | 179 ++++++++++++++++++ 8 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 openstack/eps/v1/providers/list.go create mode 100644 openstack/eps/v1/providers/testing/list_test.go create mode 100644 openstack/eps/v1/resources/requests.go create mode 100644 openstack/eps/v1/resources/testing/requests_test.go create mode 100644 openstack/eps/v1/versions/list.go create mode 100644 openstack/eps/v1/versions/testing/list_test.go create mode 100644 review-eps-v1.md diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go index 8d559d9ea..70c8d3fcc 100644 --- a/acceptance/openstack/eps/v1/projects_test.go +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -1,10 +1,15 @@ package v1 import ( + "fmt" "testing" + "time" "github.com/opentelekomcloud/gophertelekomcloud/acceptance/clients" "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/projects" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/providers" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/resources" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/versions" th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" ) @@ -35,26 +40,35 @@ func TestEnterpriseProjectsLifecycle(t *testing.T) { client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) + suffix := fmt.Sprintf("%d", time.Now().UnixNano()%100000) + createName := "eps-test-" + suffix + updateName := "eps-test-upd-" + suffix + // Create created, err := projects.Create(client, projects.CreateOpts{ - Name: "eps-sdk-test", + Name: createName, Description: "Created by gophertelekomcloud acceptance test", }).Extract() th.AssertNoErr(t, err) - th.AssertEquals(t, "eps-sdk-test", created.Name) + th.AssertEquals(t, createName, created.Name) t.Logf("Created enterprise project: %s (%s)", created.Name, created.ID) + // Ensure cleanup + defer func() { + _ = projects.Action(client, created.ID, projects.ActionOpts{Action: "disable"}).ExtractErr() + }() + // Update updated, err := projects.Update(client, created.ID, projects.UpdateOpts{ - Name: "eps-sdk-test-updated", + Name: updateName, Description: "Updated by acceptance test", }).Extract() th.AssertNoErr(t, err) - th.AssertEquals(t, "eps-sdk-test-updated", updated.Name) + th.AssertEquals(t, updateName, updated.Name) // List with name filter allPages, err := projects.List(client, projects.ListOpts{ - Name: "eps-sdk-test-updated", + Name: updateName, }).AllPages() th.AssertNoErr(t, err) @@ -88,3 +102,54 @@ func TestEnterpriseProjectsLifecycle(t *testing.T) { th.AssertNoErr(t, err) th.AssertEquals(t, 1, enabled.Status) } + +func TestVersions(t *testing.T) { + client, err := clients.NewEPSV1Client() + th.AssertNoErr(t, err) + + allVersions, err := versions.List(client) + th.AssertNoErr(t, err) + + if len(allVersions) == 0 { + t.Fatal("expected at least one API version") + } + t.Logf("Found %d API version(s)", len(allVersions)) + for _, v := range allVersions { + t.Logf(" %s (status: %s)", v.ID, v.Status) + } +} + +func TestProviders(t *testing.T) { + client, err := clients.NewEPSV1Client() + th.AssertNoErr(t, err) + + allProviders, err := providers.List(client, providers.ListOpts{}) + th.AssertNoErr(t, err) + + if len(allProviders) == 0 { + t.Fatal("expected at least one provider") + } + t.Logf("Found %d provider(s)", len(allProviders)) + for _, p := range allProviders[:3] { + t.Logf(" %s (%s)", p.Provider, p.ProviderI18nDisplay) + } +} + +func TestResourcesFilter(t *testing.T) { + client, err := clients.NewEPSV1Client() + th.AssertNoErr(t, err) + + projectID := clients.EnvOS.GetEnv("PROJECT_ID") + if projectID == "" { + t.Skip("OS_PROJECT_ID is required for resource filtering") + } + + // Use the default enterprise project (ID "0") and filter by actual OpenStack project + result, err := resources.Filter(client, "0", resources.FilterOpts{ + Projects: []string{projectID}, + ResourceTypes: []string{"ecs"}, + Limit: 5, + }) + th.AssertNoErr(t, err) + t.Logf("Found %d resource(s) in default project (total: %d)", len(result.Resources), result.TotalCount) +} diff --git a/openstack/eps/v1/providers/list.go b/openstack/eps/v1/providers/list.go new file mode 100644 index 000000000..f0182d163 --- /dev/null +++ b/openstack/eps/v1/providers/list.go @@ -0,0 +1,44 @@ +package providers + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type Provider struct { + Provider string `json:"provider"` + ProviderI18nDisplay string `json:"provider_i18n_display_name"` +} + +type ListOpts struct { + Locale string `q:"locale"` + Limit int `q:"limit"` + Offset int `q:"offset"` +} + +func (opts ListOpts) ToQuery() (string, error) { + q, err := golangsdk.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +// List retrieves the services (providers) that support enterprise projects. +func List(client *golangsdk.ServiceClient, opts ListOpts) ([]Provider, error) { + url := client.ServiceURL("enterprise-projects", "providers") + query, err := opts.ToQuery() + if err != nil { + return nil, err + } + url += query + + raw, err := client.Get(url, nil, nil) + if err != nil { + return nil, err + } + + var res []Provider + err = extract.IntoSlicePtr(raw.Body, &res, "providers") + return res, err +} diff --git a/openstack/eps/v1/providers/testing/list_test.go b/openstack/eps/v1/providers/testing/list_test.go new file mode 100644 index 000000000..e409a0957 --- /dev/null +++ b/openstack/eps/v1/providers/testing/list_test.go @@ -0,0 +1,49 @@ +package testing + +import ( + "fmt" + "net/http" + "testing" + + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/providers" + th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" + "github.com/opentelekomcloud/gophertelekomcloud/testhelper/client" +) + +func TestListProviders(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/providers", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodGet) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"providers":[{"provider":"ecs","provider_i18n_display_name":"Elastic Cloud Server"},{"provider":"evs","provider_i18n_display_name":"Elastic Volume Service"}]}`) + }) + + actual, err := providers.List(client.ServiceClient(), providers.ListOpts{}) + th.AssertNoErr(t, err) + th.AssertEquals(t, 2, len(actual)) + th.AssertEquals(t, "ecs", actual[0].Provider) + th.AssertEquals(t, "Elastic Cloud Server", actual[0].ProviderI18nDisplay) + th.AssertEquals(t, "evs", actual[1].Provider) +} + +func TestListProvidersWithOpts(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/providers", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodGet) + th.AssertEquals(t, "en-us", r.URL.Query().Get("locale")) + th.AssertEquals(t, "10", r.URL.Query().Get("limit")) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"providers":[{"provider":"ecs","provider_i18n_display_name":"Elastic Cloud Server"}]}`) + }) + + actual, err := providers.List(client.ServiceClient(), providers.ListOpts{ + Locale: "en-us", + Limit: 10, + }) + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, len(actual)) +} diff --git a/openstack/eps/v1/resources/requests.go b/openstack/eps/v1/resources/requests.go new file mode 100644 index 000000000..d978035f2 --- /dev/null +++ b/openstack/eps/v1/resources/requests.go @@ -0,0 +1,85 @@ +package resources + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/build" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type Resource struct { + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name"` + ResourceType string `json:"resource_type"` +} + +type ResourceError struct { + ProjectID string `json:"project_id"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_msg"` +} + +type FilterResult struct { + Resources []Resource `json:"resources"` + Errors []ResourceError `json:"errors"` + TotalCount int `json:"total_count"` +} + +type Match struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type FilterOpts struct { + Projects []string `json:"projects,omitempty"` + ResourceTypes []string `json:"resource_types"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Matches []Match `json:"matches,omitempty"` +} + +// Filter queries resources belonging to an enterprise project. +func Filter(client *golangsdk.ServiceClient, projectID string, opts FilterOpts) (*FilterResult, error) { + b, err := build.RequestBody(opts, "") + if err != nil { + return nil, err + } + + url := client.ServiceURL("enterprise-projects", projectID, "resources", "filter") + raw, err := client.Post(url, b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{200}, + }) + if err != nil { + return nil, err + } + + var res FilterResult + err = extract.Into(raw.Body, &res) + return &res, err +} + +type MigrateResource struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + RegionID string `json:"region_id,omitempty"` +} + +type MigrateOpts struct { + ProjectID string `json:"project_id"` + Resources []MigrateResource `json:"resources"` +} + +// Migrate moves resources from one enterprise project to another. +func Migrate(client *golangsdk.ServiceClient, projectID string, opts MigrateOpts) error { + b, err := build.RequestBody(opts, "") + if err != nil { + return err + } + + url := client.ServiceURL("enterprise-projects", projectID, "resources-migrate") + _, err = client.Post(url, b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{204}, + }) + return err +} diff --git a/openstack/eps/v1/resources/testing/requests_test.go b/openstack/eps/v1/resources/testing/requests_test.go new file mode 100644 index 000000000..d604d51a7 --- /dev/null +++ b/openstack/eps/v1/resources/testing/requests_test.go @@ -0,0 +1,55 @@ +package testing + +import ( + "fmt" + "net/http" + "testing" + + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/resources" + th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" + "github.com/opentelekomcloud/gophertelekomcloud/testhelper/client" +) + +func TestFilter(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/project-id-1/resources/filter", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodPost) + th.TestJSONRequest(t, r, `{"projects":["project-id-1"],"resource_types":["ecs"],"limit":10,"matches":[{"key":"resource_name","value":"my-server"}]}`) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"resources":[{"project_id":"project-id-1","project_name":"default","resource_id":"res-1","resource_name":"my-server","resource_type":"ecs"}],"errors":[],"total_count":1}`) + }) + + result, err := resources.Filter(client.ServiceClient(), "project-id-1", resources.FilterOpts{ + Projects: []string{"project-id-1"}, + ResourceTypes: []string{"ecs"}, + Offset: 0, + Limit: 10, + Matches: []resources.Match{{Key: "resource_name", Value: "my-server"}}, + }) + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, result.TotalCount) + th.AssertEquals(t, 1, len(result.Resources)) + th.AssertEquals(t, "res-1", result.Resources[0].ResourceID) + th.AssertEquals(t, "ecs", result.Resources[0].ResourceType) +} + +func TestMigrate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/enterprise-projects/source-project/resources-migrate", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodPost) + th.TestJSONRequest(t, r, `{"project_id":"target-project","resources":[{"resource_id":"res-1","resource_type":"ecs"}]}`) + w.WriteHeader(http.StatusNoContent) + }) + + err := resources.Migrate(client.ServiceClient(), "source-project", resources.MigrateOpts{ + ProjectID: "target-project", + Resources: []resources.MigrateResource{ + {ResourceID: "res-1", ResourceType: "ecs"}, + }, + }) + th.AssertNoErr(t, err) +} diff --git a/openstack/eps/v1/versions/list.go b/openstack/eps/v1/versions/list.go new file mode 100644 index 000000000..b9a7d52d5 --- /dev/null +++ b/openstack/eps/v1/versions/list.go @@ -0,0 +1,38 @@ +package versions + +import ( + "strings" + + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type Version struct { + ID string `json:"id"` + Links []Link `json:"links"` + Status string `json:"status"` + Update string `json:"updated"` +} + +type Link struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +// List queries available API versions at the EPS service root. +func List(client *golangsdk.ServiceClient) ([]Version, error) { + // Strip version path to query the root: https://eps.xxx/v1.0/ → https://eps.xxx/ + baseURL := client.Endpoint + if idx := strings.Index(baseURL, "/v"); idx > 0 { + baseURL = baseURL[:idx] + "/" + } + + raw, err := client.Get(baseURL, nil, nil) + if err != nil { + return nil, err + } + + var res []Version + err = extract.IntoSlicePtr(raw.Body, &res, "versions") + return res, err +} diff --git a/openstack/eps/v1/versions/testing/list_test.go b/openstack/eps/v1/versions/testing/list_test.go new file mode 100644 index 000000000..c5faed437 --- /dev/null +++ b/openstack/eps/v1/versions/testing/list_test.go @@ -0,0 +1,34 @@ +package testing + +import ( + "fmt" + "net/http" + "testing" + + "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/versions" + th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" + "github.com/opentelekomcloud/gophertelekomcloud/testhelper/client" +) + +func TestListVersions(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, http.MethodGet) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"versions":[{"id":"v1.0","links":[{"href":"https://eps.eu-de.otc.t-systems.com/v1.0/","rel":"self"}],"status":"CURRENT","updated":"2018-09-30T00:00:00Z"}]}`) + }) + + sc := client.ServiceClient() + // ServiceClient endpoint is like http://localhost:PORT/ + // versions.List strips /v... so it will hit "/" + sc.Endpoint = th.Endpoint() + + actual, err := versions.List(sc) + th.AssertNoErr(t, err) + th.AssertEquals(t, 1, len(actual)) + th.AssertEquals(t, "v1.0", actual[0].ID) + th.AssertEquals(t, "CURRENT", actual[0].Status) + th.AssertEquals(t, "self", actual[0].Links[0].Rel) +} diff --git a/review-eps-v1.md b/review-eps-v1.md new file mode 100644 index 000000000..4b10bb24d --- /dev/null +++ b/review-eps-v1.md @@ -0,0 +1,179 @@ +# Code Review: EPS (Enterprise Project Service) v1 + +## Summary + +This change adds full support for the **Enterprise Project Service (EPS) v1** API to the `gophertelekomcloud` SDK. EPS is a global OTC service for managing enterprise projects — logical containers for grouping cloud resources. + +## Key Characteristics of EPS + +- **Global service** — not project-scoped +- **Requires domain-scoped token** — project-scoped tokens are rejected with `EPS.0003 Unauthorized` +- **Not in Keystone service catalog** — endpoint must be derived or constructed manually +- **Endpoint URL has no `project_id`** — unlike most OTC services + +## Files Changed + +### Resource Implementation + +| File | Description | +|------|-------------| +| `openstack/eps/v1/projects/requests.go` | CRUD operations: List, Create, Get, Update, Action (enable/disable) | +| `openstack/eps/v1/projects/results.go` | Response types: `EnterpriseProject`, `ProjectListResult`, result wrappers | +| `openstack/eps/v1/projects/urls.go` | URL helpers: `enterprise-projects` base path | +| `openstack/eps/v1/versions/list.go` | Query available API versions at service root | +| `openstack/eps/v1/providers/list.go` | Query supported services (providers) with locale/pagination | +| `openstack/eps/v1/resources/requests.go` | Resource filter and cross-project migration | + +### Client Construction + +| File | Description | +|------|-------------| +| `openstack/client.go` (`NewEPSV1`) | Service client factory using `initCommonServiceClient` with project_id stripping | +| `acceptance/clients/clients.go` (`NewEPSV1Client`) | Acceptance test client with domain-scoped auth and direct endpoint construction | + +### Tests + +| File | Description | +|------|-------------| +| `openstack/eps/v1/projects/testing/requests_test.go` | Unit tests (5): TestList, TestGet, TestCreate, TestUpdate, TestAction | +| `openstack/eps/v1/versions/testing/list_test.go` | Unit test (1): TestListVersions | +| `openstack/eps/v1/providers/testing/list_test.go` | Unit tests (2): TestListProviders, TestListProvidersWithOpts | +| `openstack/eps/v1/resources/testing/requests_test.go` | Unit tests (2): TestFilter, TestMigrate | +| `acceptance/openstack/eps/v1/projects_test.go` | Acceptance tests (5): ListAndGet, Lifecycle, Versions, Providers, ResourcesFilter | + +## Design Decisions to Review + +### 1. Endpoint Construction (`NewEPSV1` in `openstack/client.go`) + +```go +func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { + sc, err := initCommonServiceClient(client, eo, "eps", "v1.0") + if err != nil { + return nil, err + } + if client.ProjectID != "" { + sc.Endpoint = strings.Replace(sc.Endpoint, client.ProjectID+"/", "", 1) + } + sc.ResourceBase = sc.Endpoint + return sc, nil +} +``` + +**Context**: EPS is not in the service catalog. `initCommonServiceClient` derives the URL from `volumev2` (EVS): `https://evs.{region}.xxx/v2/{project_id}` → replaces `evs` with `eps` and `v2` with `v1.0`, then strips `project_id`. + +**Guard**: `if client.ProjectID != ""` — prevents URL corruption when called with a domain-scoped token (empty ProjectID would cause `strings.Replace(url, "/", "", 1)` to strip the first `/` from `https://`). + +**Limitation**: This function only works with project-scoped tokens (needs `volumev2` in catalog). With domain-scoped tokens, `initCommonServiceClient` fails. The acceptance test client works around this. + +### 2. Acceptance Test Client (`NewEPSV1Client`) + +```go +func NewEPSV1Client() (client *golangsdk.ServiceClient, err error) { + // Domain-scoped auth (no TenantID/TenantName) + opts := golangsdk.AuthOptions{ + IdentityEndpoint: cloud.AuthInfo.AuthURL, + Username: cloud.AuthInfo.Username, + Password: cloud.AuthInfo.Password, + DomainName: cloud.AuthInfo.UserDomainName, + } + pClient, err := openstack.AuthenticatedClient(opts) + // ... + // Derive endpoint: iam.{region}.xxx → eps.{region}.xxx/v1.0/ + epsHost := strings.Replace(u.Host, "iam.", "eps.", 1) + epsEndpoint := fmt.Sprintf("https://%s/v1.0/", epsHost) + // ... +} +``` + +**Decision**: Bypasses `NewEPSV1` entirely because domain-scoped tokens have no `volumev2` in catalog. Constructs URL directly from IAM auth URL by replacing `iam.` prefix with `eps.`. + +**Trade-off**: Fragile URL assumption (depends on OTC's consistent `{service}.{region}.{domain}` naming) but practical and proven against real API. + +### 3. `ExtractProjects` returns `*ProjectListResult` (not `[]EnterpriseProject`) + +```go +type ProjectListResult struct { + EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` + TotalCount int `json:"total_count"` +} +``` + +**Rationale**: The API returns `total_count` alongside the project list. Returning only the slice would discard this pagination metadata. + +### 4. Versions Endpoint — URL Stripping + +```go +func List(client *golangsdk.ServiceClient) ([]Version, error) { + baseURL := client.Endpoint + if idx := strings.Index(baseURL, "/v"); idx > 0 { + baseURL = baseURL[:idx] + "/" + } + // GET https://eps.{region}.xxx/ +} +``` + +**Context**: The versions API queries the service root (`/`) without any version prefix, but `ServiceClient.Endpoint` is `https://eps.xxx/v1.0/`. The function strips the version path to reach the root. + +### 5. Resources Filter — `projects` Field Semantics + +The `projects` field in `FilterOpts` refers to **OpenStack/IAM project UUIDs** (Keystone tenants), not enterprise project IDs. The enterprise project ID is passed in the URL path. This can be confusing and is documented in the acceptance test with a skip condition. + +### 6. No Delete Operation + +The API Reference does not document a DELETE endpoint. Enterprise projects can only be **disabled** via `POST /enterprise-projects/{id}/action` with `{"action": "disable"}`. + +## Questions for Reviewer + +1. Is the `initCommonServiceClient` approach for `NewEPSV1` acceptable given it only works with project-scoped tokens? Should there be a fallback for domain-scoped tokens within `NewEPSV1` itself? +2. Is direct endpoint derivation from the auth URL (`iam.` → `eps.`) acceptable for the acceptance test client, or should there be an `OS_EPS_ENDPOINT` env var override? +3. Should `Action` use typed constants (`ActionEnable = "enable"`, `ActionDisable = "disable"`) instead of raw strings? +4. Is `SinglePageBase` the right pagination choice? The API supports `offset`/`limit` but currently returns all results. +5. Should `FilterOpts.Projects` be renamed or documented to avoid confusion with enterprise project IDs? + +## Test Results + +``` +Unit tests: 10/10 PASS +Acceptance tests: 4/5 PASS, 1 SKIP (ResourcesFilter requires OS_PROJECT_ID) +``` + +## Full API Coverage + +| API Operation | Package | Implemented | Unit Test | Acceptance Test | +|---------------|---------|:-----------:|:---------:|:---------------:| +| List Enterprise Projects | `projects` | ✅ | ✅ | ✅ | +| Create Enterprise Project | `projects` | ✅ | ✅ | ✅ | +| Get Enterprise Project | `projects` | ✅ | ✅ | ✅ | +| Update Enterprise Project | `projects` | ✅ | ✅ | ✅ | +| Enable/Disable (Action) | `projects` | ✅ | ✅ | ✅ | +| Query API Versions | `versions` | ✅ | ✅ | ✅ | +| Query Supported Services | `providers` | ✅ | ✅ | ✅ | +| Query/Filter Resources | `resources` | ✅ | ✅ | ⏭ SKIP (needs OS_PROJECT_ID) | +| Migrate Resources | `resources` | ✅ | ✅ | — (destructive, needs real resources) | + +## Package Structure + +``` +openstack/eps/v1/ +├── projects/ +│ ├── requests.go # List, Create, Get, Update, Action +│ ├── results.go # EnterpriseProject, ProjectListResult, result types +│ ├── urls.go # rootURL, resourceURL +│ └── testing/ +│ └── requests_test.go # 5 unit tests +├── versions/ +│ ├── list.go # List (GET /) +│ └── testing/ +│ └── list_test.go # 1 unit test +├── providers/ +│ ├── list.go # List (GET /enterprise-projects/providers) +│ └── testing/ +│ └── list_test.go # 2 unit tests +└── resources/ + ├── requests.go # Filter, Migrate + └── testing/ + └── requests_test.go # 2 unit tests + +acceptance/openstack/eps/v1/ +└── projects_test.go # 5 acceptance tests +``` From df17edc7d6e8f4584861125a7abf3682830a6f18 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Fri, 26 Jun 2026 19:28:58 +0200 Subject: [PATCH 4/9] Remove review documentation for EPS v1 API --- review-eps-v1.md | 179 ----------------------------------------------- 1 file changed, 179 deletions(-) delete mode 100644 review-eps-v1.md diff --git a/review-eps-v1.md b/review-eps-v1.md deleted file mode 100644 index 4b10bb24d..000000000 --- a/review-eps-v1.md +++ /dev/null @@ -1,179 +0,0 @@ -# Code Review: EPS (Enterprise Project Service) v1 - -## Summary - -This change adds full support for the **Enterprise Project Service (EPS) v1** API to the `gophertelekomcloud` SDK. EPS is a global OTC service for managing enterprise projects — logical containers for grouping cloud resources. - -## Key Characteristics of EPS - -- **Global service** — not project-scoped -- **Requires domain-scoped token** — project-scoped tokens are rejected with `EPS.0003 Unauthorized` -- **Not in Keystone service catalog** — endpoint must be derived or constructed manually -- **Endpoint URL has no `project_id`** — unlike most OTC services - -## Files Changed - -### Resource Implementation - -| File | Description | -|------|-------------| -| `openstack/eps/v1/projects/requests.go` | CRUD operations: List, Create, Get, Update, Action (enable/disable) | -| `openstack/eps/v1/projects/results.go` | Response types: `EnterpriseProject`, `ProjectListResult`, result wrappers | -| `openstack/eps/v1/projects/urls.go` | URL helpers: `enterprise-projects` base path | -| `openstack/eps/v1/versions/list.go` | Query available API versions at service root | -| `openstack/eps/v1/providers/list.go` | Query supported services (providers) with locale/pagination | -| `openstack/eps/v1/resources/requests.go` | Resource filter and cross-project migration | - -### Client Construction - -| File | Description | -|------|-------------| -| `openstack/client.go` (`NewEPSV1`) | Service client factory using `initCommonServiceClient` with project_id stripping | -| `acceptance/clients/clients.go` (`NewEPSV1Client`) | Acceptance test client with domain-scoped auth and direct endpoint construction | - -### Tests - -| File | Description | -|------|-------------| -| `openstack/eps/v1/projects/testing/requests_test.go` | Unit tests (5): TestList, TestGet, TestCreate, TestUpdate, TestAction | -| `openstack/eps/v1/versions/testing/list_test.go` | Unit test (1): TestListVersions | -| `openstack/eps/v1/providers/testing/list_test.go` | Unit tests (2): TestListProviders, TestListProvidersWithOpts | -| `openstack/eps/v1/resources/testing/requests_test.go` | Unit tests (2): TestFilter, TestMigrate | -| `acceptance/openstack/eps/v1/projects_test.go` | Acceptance tests (5): ListAndGet, Lifecycle, Versions, Providers, ResourcesFilter | - -## Design Decisions to Review - -### 1. Endpoint Construction (`NewEPSV1` in `openstack/client.go`) - -```go -func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { - sc, err := initCommonServiceClient(client, eo, "eps", "v1.0") - if err != nil { - return nil, err - } - if client.ProjectID != "" { - sc.Endpoint = strings.Replace(sc.Endpoint, client.ProjectID+"/", "", 1) - } - sc.ResourceBase = sc.Endpoint - return sc, nil -} -``` - -**Context**: EPS is not in the service catalog. `initCommonServiceClient` derives the URL from `volumev2` (EVS): `https://evs.{region}.xxx/v2/{project_id}` → replaces `evs` with `eps` and `v2` with `v1.0`, then strips `project_id`. - -**Guard**: `if client.ProjectID != ""` — prevents URL corruption when called with a domain-scoped token (empty ProjectID would cause `strings.Replace(url, "/", "", 1)` to strip the first `/` from `https://`). - -**Limitation**: This function only works with project-scoped tokens (needs `volumev2` in catalog). With domain-scoped tokens, `initCommonServiceClient` fails. The acceptance test client works around this. - -### 2. Acceptance Test Client (`NewEPSV1Client`) - -```go -func NewEPSV1Client() (client *golangsdk.ServiceClient, err error) { - // Domain-scoped auth (no TenantID/TenantName) - opts := golangsdk.AuthOptions{ - IdentityEndpoint: cloud.AuthInfo.AuthURL, - Username: cloud.AuthInfo.Username, - Password: cloud.AuthInfo.Password, - DomainName: cloud.AuthInfo.UserDomainName, - } - pClient, err := openstack.AuthenticatedClient(opts) - // ... - // Derive endpoint: iam.{region}.xxx → eps.{region}.xxx/v1.0/ - epsHost := strings.Replace(u.Host, "iam.", "eps.", 1) - epsEndpoint := fmt.Sprintf("https://%s/v1.0/", epsHost) - // ... -} -``` - -**Decision**: Bypasses `NewEPSV1` entirely because domain-scoped tokens have no `volumev2` in catalog. Constructs URL directly from IAM auth URL by replacing `iam.` prefix with `eps.`. - -**Trade-off**: Fragile URL assumption (depends on OTC's consistent `{service}.{region}.{domain}` naming) but practical and proven against real API. - -### 3. `ExtractProjects` returns `*ProjectListResult` (not `[]EnterpriseProject`) - -```go -type ProjectListResult struct { - EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` - TotalCount int `json:"total_count"` -} -``` - -**Rationale**: The API returns `total_count` alongside the project list. Returning only the slice would discard this pagination metadata. - -### 4. Versions Endpoint — URL Stripping - -```go -func List(client *golangsdk.ServiceClient) ([]Version, error) { - baseURL := client.Endpoint - if idx := strings.Index(baseURL, "/v"); idx > 0 { - baseURL = baseURL[:idx] + "/" - } - // GET https://eps.{region}.xxx/ -} -``` - -**Context**: The versions API queries the service root (`/`) without any version prefix, but `ServiceClient.Endpoint` is `https://eps.xxx/v1.0/`. The function strips the version path to reach the root. - -### 5. Resources Filter — `projects` Field Semantics - -The `projects` field in `FilterOpts` refers to **OpenStack/IAM project UUIDs** (Keystone tenants), not enterprise project IDs. The enterprise project ID is passed in the URL path. This can be confusing and is documented in the acceptance test with a skip condition. - -### 6. No Delete Operation - -The API Reference does not document a DELETE endpoint. Enterprise projects can only be **disabled** via `POST /enterprise-projects/{id}/action` with `{"action": "disable"}`. - -## Questions for Reviewer - -1. Is the `initCommonServiceClient` approach for `NewEPSV1` acceptable given it only works with project-scoped tokens? Should there be a fallback for domain-scoped tokens within `NewEPSV1` itself? -2. Is direct endpoint derivation from the auth URL (`iam.` → `eps.`) acceptable for the acceptance test client, or should there be an `OS_EPS_ENDPOINT` env var override? -3. Should `Action` use typed constants (`ActionEnable = "enable"`, `ActionDisable = "disable"`) instead of raw strings? -4. Is `SinglePageBase` the right pagination choice? The API supports `offset`/`limit` but currently returns all results. -5. Should `FilterOpts.Projects` be renamed or documented to avoid confusion with enterprise project IDs? - -## Test Results - -``` -Unit tests: 10/10 PASS -Acceptance tests: 4/5 PASS, 1 SKIP (ResourcesFilter requires OS_PROJECT_ID) -``` - -## Full API Coverage - -| API Operation | Package | Implemented | Unit Test | Acceptance Test | -|---------------|---------|:-----------:|:---------:|:---------------:| -| List Enterprise Projects | `projects` | ✅ | ✅ | ✅ | -| Create Enterprise Project | `projects` | ✅ | ✅ | ✅ | -| Get Enterprise Project | `projects` | ✅ | ✅ | ✅ | -| Update Enterprise Project | `projects` | ✅ | ✅ | ✅ | -| Enable/Disable (Action) | `projects` | ✅ | ✅ | ✅ | -| Query API Versions | `versions` | ✅ | ✅ | ✅ | -| Query Supported Services | `providers` | ✅ | ✅ | ✅ | -| Query/Filter Resources | `resources` | ✅ | ✅ | ⏭ SKIP (needs OS_PROJECT_ID) | -| Migrate Resources | `resources` | ✅ | ✅ | — (destructive, needs real resources) | - -## Package Structure - -``` -openstack/eps/v1/ -├── projects/ -│ ├── requests.go # List, Create, Get, Update, Action -│ ├── results.go # EnterpriseProject, ProjectListResult, result types -│ ├── urls.go # rootURL, resourceURL -│ └── testing/ -│ └── requests_test.go # 5 unit tests -├── versions/ -│ ├── list.go # List (GET /) -│ └── testing/ -│ └── list_test.go # 1 unit test -├── providers/ -│ ├── list.go # List (GET /enterprise-projects/providers) -│ └── testing/ -│ └── list_test.go # 2 unit tests -└── resources/ - ├── requests.go # Filter, Migrate - └── testing/ - └── requests_test.go # 2 unit tests - -acceptance/openstack/eps/v1/ -└── projects_test.go # 5 acceptance tests -``` From 44bbcb6c3ee8e5b624cd9853fd5886f3938db998 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Fri, 26 Jun 2026 19:29:04 +0200 Subject: [PATCH 5/9] [Refactor] Update EPS V1 client initialization to use initClientOpts and simplify endpoint handling --- openstack/client.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openstack/client.go b/openstack/client.go index 328008b89..5164b1f87 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -1107,14 +1107,10 @@ func NewRmsServiceV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts // NewEPSV1 creates a ServiceClient that may be used to access the EPS service. func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { - sc, err := initCommonServiceClient(client, eo, "eps", "v1.0") + sc, err := initClientOpts(client, eo, "eps") if err != nil { return nil, err } - if client.ProjectID != "" { - sc.Endpoint = strings.Replace(sc.Endpoint, client.ProjectID+"/", "", 1) - } - sc.ResourceBase = sc.Endpoint return sc, nil } From 1deba2e28f7a4fa683dacd948cf0cbd8ba267d29 Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev Date: Wed, 1 Jul 2026 13:59:06 +0200 Subject: [PATCH 6/9] refactoring: EPS v1 project management functionality, including create, update, delete, and list operations --- acceptance/clients/clients.go | 45 +++--- acceptance/openstack/eps/v1/projects_test.go | 37 ++--- openstack/client.go | 7 +- openstack/eps/v1/projects/Action.go | 22 +++ openstack/eps/v1/projects/Create.go | 30 ++++ .../eps/v1/projects/EnterpriseProject.go | 13 ++ openstack/eps/v1/projects/Get.go | 17 +++ openstack/eps/v1/projects/List.go | 37 +++++ openstack/eps/v1/projects/Update.go | 31 ++++ openstack/eps/v1/projects/requests.go | 139 ------------------ openstack/eps/v1/projects/results.go | 67 --------- .../eps/v1/projects/testing/requests_test.go | 39 ++--- openstack/eps/v1/projects/urls.go | 15 -- .../v1/resources/{requests.go => Filter.go} | 29 +--- openstack/eps/v1/resources/Migrate.go | 29 ++++ .../eps/v1/resources/testing/requests_test.go | 1 - 16 files changed, 237 insertions(+), 321 deletions(-) create mode 100644 openstack/eps/v1/projects/Action.go create mode 100644 openstack/eps/v1/projects/Create.go create mode 100644 openstack/eps/v1/projects/EnterpriseProject.go create mode 100644 openstack/eps/v1/projects/Get.go create mode 100644 openstack/eps/v1/projects/List.go create mode 100644 openstack/eps/v1/projects/Update.go delete mode 100644 openstack/eps/v1/projects/requests.go delete mode 100644 openstack/eps/v1/projects/results.go delete mode 100644 openstack/eps/v1/projects/urls.go rename openstack/eps/v1/resources/{requests.go => Filter.go} (61%) create mode 100644 openstack/eps/v1/resources/Migrate.go diff --git a/acceptance/clients/clients.go b/acceptance/clients/clients.go index b672771cc..39a7f356f 100644 --- a/acceptance/clients/clients.go +++ b/acceptance/clients/clients.go @@ -1130,40 +1130,39 @@ func NewRMSClient() (client *golangsdk.ServiceClient, err error) { }) } -// NewEPSV1Client returns authenticated EPS v1 client with domain-scoped token. -// EPS is a global service that requires domain-scoped authentication and is not -// in the Keystone service catalog, so the endpoint is constructed from the auth URL. +// NewEPSV1Client returns authenticated EPS v1 client. +// EPS requires domain-scoped authentication. The endpoint is discovered via +// NewEPSV1 (project-scoped catalog), then re-authenticated with domain scope. func NewEPSV1Client() (client *golangsdk.ServiceClient, err error) { - cloud, err := EnvOS.Cloud() + cc, err := CloudAndClient() if err != nil { - return nil, fmt.Errorf("error constructing cloud configuration: %w", err) + return nil, err + } + sc, err := openstack.NewEPSV1(cc.ProviderClient, golangsdk.EndpointOpts{ + Region: cc.RegionName, + }) + if err != nil { + return nil, err } - opts := golangsdk.AuthOptions{ + // Re-authenticate with domain scope for EPS API + cloud, err := EnvOS.Cloud() + if err != nil { + return nil, err + } + domainOpts := golangsdk.AuthOptions{ IdentityEndpoint: cloud.AuthInfo.AuthURL, Username: cloud.AuthInfo.Username, Password: cloud.AuthInfo.Password, DomainName: cloud.AuthInfo.UserDomainName, + AllowReauth: true, } - - pClient, err := openstack.AuthenticatedClient(opts) - if err != nil { - return nil, fmt.Errorf("error creating domain-scoped provider client: %w", err) - } - - // Derive EPS endpoint from IAM URL: https://iam.{region}.xxx → https://eps.{region}.xxx/v1.0/ - u, err := url.Parse(cloud.AuthInfo.AuthURL) + domainProvider, err := openstack.AuthenticatedClient(domainOpts) if err != nil { - return nil, fmt.Errorf("error parsing auth URL: %w", err) + return nil, err } - epsHost := strings.Replace(u.Host, "iam.", "eps.", 1) - epsEndpoint := fmt.Sprintf("https://%s/v1.0/", epsHost) - - return &golangsdk.ServiceClient{ - ProviderClient: pClient, - Endpoint: epsEndpoint, - ResourceBase: epsEndpoint, - }, nil + sc.ProviderClient = domainProvider + return sc, nil } // NewCCIClient returns authenticated CCI v2 client diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go index 70c8d3fcc..512a678ee 100644 --- a/acceptance/openstack/eps/v1/projects_test.go +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -17,13 +17,10 @@ func TestEnterpriseProjectsListAndGet(t *testing.T) { client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) - allPages, err := projects.List(client, projects.ListOpts{}).AllPages() + allProjects, err := projects.List(client, projects.ListOpts{}) th.AssertNoErr(t, err) - allProjects, err := projects.ExtractProjects(allPages) - th.AssertNoErr(t, err) - - enterpriseProjectID := clients.EnvOS.GetEnv("OS_ENTERPRISE_PROJECT_ID") + enterpriseProjectID := clients.EnvOS.GetEnv("ENTERPRISE_PROJECT_ID") if enterpriseProjectID == "" { if len(allProjects.EnterpriseProjects) == 0 { t.Skip("no enterprise projects are available") @@ -31,7 +28,7 @@ func TestEnterpriseProjectsListAndGet(t *testing.T) { enterpriseProjectID = allProjects.EnterpriseProjects[0].ID } - p, err := projects.Get(client, enterpriseProjectID).Extract() + p, err := projects.Get(client, enterpriseProjectID) th.AssertNoErr(t, err) th.AssertEquals(t, enterpriseProjectID, p.ID) } @@ -48,57 +45,50 @@ func TestEnterpriseProjectsLifecycle(t *testing.T) { created, err := projects.Create(client, projects.CreateOpts{ Name: createName, Description: "Created by gophertelekomcloud acceptance test", - }).Extract() + }) th.AssertNoErr(t, err) th.AssertEquals(t, createName, created.Name) t.Logf("Created enterprise project: %s (%s)", created.Name, created.ID) // Ensure cleanup defer func() { - _ = projects.Action(client, created.ID, projects.ActionOpts{Action: "disable"}).ExtractErr() + _ = projects.Action(client, created.ID, projects.ActionOpts{Action: "disable"}) }() // Update updated, err := projects.Update(client, created.ID, projects.UpdateOpts{ Name: updateName, Description: "Updated by acceptance test", - }).Extract() + }) th.AssertNoErr(t, err) th.AssertEquals(t, updateName, updated.Name) // List with name filter - allPages, err := projects.List(client, projects.ListOpts{ + filtered, err := projects.List(client, projects.ListOpts{ Name: updateName, - }).AllPages() - th.AssertNoErr(t, err) - - allProjects, err := projects.ExtractProjects(allPages) + }) th.AssertNoErr(t, err) - if allProjects.TotalCount < 1 { + if filtered.TotalCount < 1 { t.Fatal("expected at least 1 project in filtered list") } // Disable (Action) - err = projects.Action(client, created.ID, projects.ActionOpts{ - Action: "disable", - }).ExtractErr() + err = projects.Action(client, created.ID, projects.ActionOpts{Action: "disable"}) th.AssertNoErr(t, err) t.Log("Disabled enterprise project") // Verify disabled - disabled, err := projects.Get(client, created.ID).Extract() + disabled, err := projects.Get(client, created.ID) th.AssertNoErr(t, err) th.AssertEquals(t, 2, disabled.Status) // Enable (Action) - err = projects.Action(client, created.ID, projects.ActionOpts{ - Action: "enable", - }).ExtractErr() + err = projects.Action(client, created.ID, projects.ActionOpts{Action: "enable"}) th.AssertNoErr(t, err) t.Log("Enabled enterprise project") // Verify enabled - enabled, err := projects.Get(client, created.ID).Extract() + enabled, err := projects.Get(client, created.ID) th.AssertNoErr(t, err) th.AssertEquals(t, 1, enabled.Status) } @@ -144,7 +134,6 @@ func TestResourcesFilter(t *testing.T) { t.Skip("OS_PROJECT_ID is required for resource filtering") } - // Use the default enterprise project (ID "0") and filter by actual OpenStack project result, err := resources.Filter(client, "0", resources.FilterOpts{ Projects: []string{projectID}, ResourceTypes: []string{"ecs"}, diff --git a/openstack/client.go b/openstack/client.go index 5164b1f87..285cf1c3e 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -1106,11 +1106,16 @@ func NewRmsServiceV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts } // NewEPSV1 creates a ServiceClient that may be used to access the EPS service. +// EPS is a global service — the endpoint URL does not include project_id. func NewEPSV1(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { - sc, err := initClientOpts(client, eo, "eps") + sc, err := initCommonServiceClient(client, eo, "eps", "v1.0") if err != nil { return nil, err } + if client.ProjectID != "" { + sc.Endpoint = strings.Replace(sc.Endpoint, client.ProjectID+"/", "", 1) + } + sc.ResourceBase = sc.Endpoint return sc, nil } diff --git a/openstack/eps/v1/projects/Action.go b/openstack/eps/v1/projects/Action.go new file mode 100644 index 000000000..92d7a7190 --- /dev/null +++ b/openstack/eps/v1/projects/Action.go @@ -0,0 +1,22 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/build" +) + +type ActionOpts struct { + Action string `json:"action" required:"true"` +} + +func Action(client *golangsdk.ServiceClient, id string, opts ActionOpts) error { + b, err := build.RequestBody(opts, "") + if err != nil { + return err + } + + _, err = client.Post(client.ServiceURL("enterprise-projects", id, "action"), b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{200, 204}, + }) + return err +} diff --git a/openstack/eps/v1/projects/Create.go b/openstack/eps/v1/projects/Create.go new file mode 100644 index 000000000..e59c4cdd5 --- /dev/null +++ b/openstack/eps/v1/projects/Create.go @@ -0,0 +1,30 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/build" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type CreateOpts struct { + Name string `json:"name" required:"true"` + Description string `json:"description,omitempty"` +} + +func Create(client *golangsdk.ServiceClient, opts CreateOpts) (*EnterpriseProject, error) { + b, err := build.RequestBody(opts, "") + if err != nil { + return nil, err + } + + raw, err := client.Post(client.ServiceURL("enterprise-projects"), b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{200, 201}, + }) + if err != nil { + return nil, err + } + + var res EnterpriseProject + err = extract.IntoStructPtr(raw.Body, &res, "enterprise_project") + return &res, err +} diff --git a/openstack/eps/v1/projects/EnterpriseProject.go b/openstack/eps/v1/projects/EnterpriseProject.go new file mode 100644 index 000000000..3c78e031b --- /dev/null +++ b/openstack/eps/v1/projects/EnterpriseProject.go @@ -0,0 +1,13 @@ +package projects + +// EnterpriseProject represents an EPS enterprise project. +type EnterpriseProject struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status int `json:"status"` + DeleteFlag bool `json:"delete_flag"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/openstack/eps/v1/projects/Get.go b/openstack/eps/v1/projects/Get.go new file mode 100644 index 000000000..68e5aef6a --- /dev/null +++ b/openstack/eps/v1/projects/Get.go @@ -0,0 +1,17 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +func Get(client *golangsdk.ServiceClient, id string) (*EnterpriseProject, error) { + raw, err := client.Get(client.ServiceURL("enterprise-projects", id), nil, nil) + if err != nil { + return nil, err + } + + var res EnterpriseProject + err = extract.IntoStructPtr(raw.Body, &res, "enterprise_project") + return &res, err +} diff --git a/openstack/eps/v1/projects/List.go b/openstack/eps/v1/projects/List.go new file mode 100644 index 000000000..5aec77daf --- /dev/null +++ b/openstack/eps/v1/projects/List.go @@ -0,0 +1,37 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type ListOpts struct { + ID string `q:"id"` + Limit int `q:"limit"` + Name string `q:"name"` + Offset int `q:"offset"` + SortDir string `q:"sort_dir"` + SortKey string `q:"sort_key"` + Status int `q:"status"` +} + +type ListResult struct { + EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` + TotalCount int `json:"total_count"` +} + +func List(client *golangsdk.ServiceClient, opts ListOpts) (*ListResult, error) { + q, err := golangsdk.BuildQueryString(&opts) + if err != nil { + return nil, err + } + + raw, err := client.Get(client.ServiceURL("enterprise-projects")+q.String(), nil, nil) + if err != nil { + return nil, err + } + + var res ListResult + err = extract.Into(raw.Body, &res) + return &res, err +} diff --git a/openstack/eps/v1/projects/Update.go b/openstack/eps/v1/projects/Update.go new file mode 100644 index 000000000..64e7dac58 --- /dev/null +++ b/openstack/eps/v1/projects/Update.go @@ -0,0 +1,31 @@ +package projects + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/build" + "github.com/opentelekomcloud/gophertelekomcloud/internal/extract" +) + +type UpdateOpts struct { + Name string `json:"name" required:"true"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +func Update(client *golangsdk.ServiceClient, id string, opts UpdateOpts) (*EnterpriseProject, error) { + b, err := build.RequestBody(opts, "") + if err != nil { + return nil, err + } + + raw, err := client.Put(client.ServiceURL("enterprise-projects", id), b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{200}, + }) + if err != nil { + return nil, err + } + + var res EnterpriseProject + err = extract.IntoStructPtr(raw.Body, &res, "enterprise_project") + return &res, err +} diff --git a/openstack/eps/v1/projects/requests.go b/openstack/eps/v1/projects/requests.go deleted file mode 100644 index 922cd13ae..000000000 --- a/openstack/eps/v1/projects/requests.go +++ /dev/null @@ -1,139 +0,0 @@ -package projects - -import ( - golangsdk "github.com/opentelekomcloud/gophertelekomcloud" - "github.com/opentelekomcloud/gophertelekomcloud/pagination" -) - -type ListOptsBuilder interface { - ToProjectListQuery() (string, error) -} - -type ListOpts struct { - ID string `q:"id"` - Limit int `q:"limit"` - Name string `q:"name"` - Offset int `q:"offset"` - SortDir string `q:"sort_dir"` - SortKey string `q:"sort_key"` - Status int `q:"status"` -} - -func (opts ListOpts) ToProjectListQuery() (string, error) { - q, err := golangsdk.BuildQueryString(opts) - if err != nil { - return "", err - } - return q.String(), nil -} - -func List(client *golangsdk.ServiceClient, opts ListOptsBuilder) pagination.Pager { - url := rootURL(client) - if opts != nil { - query, err := opts.ToProjectListQuery() - if err != nil { - return pagination.Pager{Err: err} - } - url += query - } - - return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page { - return ProjectPage{pagination.SinglePageBase(r)} - }) -} - -type CreateOptsBuilder interface { - ToProjectCreateMap() (map[string]interface{}, error) -} - -type CreateOpts struct { - Name string `json:"name" required:"true"` - Description string `json:"description,omitempty"` -} - -func (opts CreateOpts) ToProjectCreateMap() (map[string]interface{}, error) { - b, err := golangsdk.BuildRequestBody(opts, "") - if err != nil { - return nil, err - } - return b, nil -} - -func Create(client *golangsdk.ServiceClient, opts CreateOptsBuilder) (r CreateResult) { - b, err := opts.ToProjectCreateMap() - if err != nil { - r.Err = err - return - } - _, r.Err = client.Post(rootURL(client), b, &r.Body, &golangsdk.RequestOpts{ - OkCodes: []int{200, 201}, - }) - return -} - -func Get(client *golangsdk.ServiceClient, id string) (r GetResult) { - _, r.Err = client.Get(resourceURL(client, id), &r.Body, nil) - return -} - -type UpdateOptsBuilder interface { - ToProjectUpdateMap() (map[string]interface{}, error) -} - -type UpdateOpts struct { - Name string `json:"name" required:"true"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` -} - -func (opts UpdateOpts) ToProjectUpdateMap() (map[string]interface{}, error) { - b, err := golangsdk.BuildRequestBody(opts, "") - if err != nil { - return nil, err - } - return b, nil -} - -func Update(client *golangsdk.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) { - b, err := opts.ToProjectUpdateMap() - if err != nil { - r.Err = err - return - } - _, r.Err = client.Put(resourceURL(client, id), b, &r.Body, &golangsdk.RequestOpts{ - OkCodes: []int{200}, - }) - return -} - -type ActionOptsBuilder interface { - ToProjectActionMap() (map[string]interface{}, error) -} - -type ActionOpts struct { - Action string `json:"action" required:"true"` -} - -func (opts ActionOpts) ToProjectActionMap() (map[string]interface{}, error) { - b, err := golangsdk.BuildRequestBody(opts, "") - if err != nil { - return nil, err - } - return b, nil -} - -type ActionResult struct { - golangsdk.ErrResult -} - -func Action(client *golangsdk.ServiceClient, id string, opts ActionOptsBuilder) (r ActionResult) { - b, err := opts.ToProjectActionMap() - if err != nil { - r.Err = err - return - } - _, r.Err = client.Post(resourceURL(client, id)+"/action", b, nil, &golangsdk.RequestOpts{ - OkCodes: []int{200, 204}, - }) - return -} diff --git a/openstack/eps/v1/projects/results.go b/openstack/eps/v1/projects/results.go deleted file mode 100644 index e1f269ff3..000000000 --- a/openstack/eps/v1/projects/results.go +++ /dev/null @@ -1,67 +0,0 @@ -package projects - -import ( - golangsdk "github.com/opentelekomcloud/gophertelekomcloud" - "github.com/opentelekomcloud/gophertelekomcloud/pagination" -) - -type EnterpriseProject struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - Status int `json:"status"` - DeleteFlag bool `json:"delete_flag"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type commonResult struct { - golangsdk.Result -} - -func (r commonResult) Extract() (*EnterpriseProject, error) { - var s struct { - EnterpriseProject *EnterpriseProject `json:"enterprise_project"` - } - err := r.ExtractInto(&s) - if err != nil { - return nil, err - } - return s.EnterpriseProject, nil -} - -type CreateResult struct { - commonResult -} - -type GetResult struct { - commonResult -} - -type UpdateResult struct { - commonResult -} - -type ProjectPage struct { - pagination.SinglePageBase -} - -func (r ProjectPage) IsEmpty() (bool, error) { - data, err := ExtractProjects(r) - if err != nil { - return false, err - } - return len(data.EnterpriseProjects) == 0, err -} - -type ProjectListResult struct { - EnterpriseProjects []EnterpriseProject `json:"enterprise_projects"` - TotalCount int `json:"total_count"` -} - -func ExtractProjects(r pagination.Page) (*ProjectListResult, error) { - var s ProjectListResult - err := (r.(ProjectPage)).ExtractInto(&s) - return &s, err -} diff --git a/openstack/eps/v1/projects/testing/requests_test.go b/openstack/eps/v1/projects/testing/requests_test.go index 325949441..d3daf0eac 100644 --- a/openstack/eps/v1/projects/testing/requests_test.go +++ b/openstack/eps/v1/projects/testing/requests_test.go @@ -6,13 +6,10 @@ import ( "testing" "github.com/opentelekomcloud/gophertelekomcloud/openstack/eps/v1/projects" - "github.com/opentelekomcloud/gophertelekomcloud/pagination" th "github.com/opentelekomcloud/gophertelekomcloud/testhelper" "github.com/opentelekomcloud/gophertelekomcloud/testhelper/client" ) -const epID = "0" - func TestList(t *testing.T) { th.SetupHTTP() defer th.TeardownHTTP() @@ -23,34 +20,27 @@ func TestList(t *testing.T) { _, _ = fmt.Fprint(w, `{"enterprise_projects":[{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}],"total_count":1}`) }) - pages := 0 - err := projects.List(client.ServiceClient(), projects.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { - pages++ - actual, err := projects.ExtractProjects(page) - th.AssertNoErr(t, err) - th.AssertEquals(t, 1, len(actual.EnterpriseProjects)) - th.AssertEquals(t, 1, actual.TotalCount) - th.AssertEquals(t, epID, actual.EnterpriseProjects[0].ID) - th.AssertEquals(t, "default", actual.EnterpriseProjects[0].Name) - return true, nil - }) + actual, err := projects.List(client.ServiceClient(), projects.ListOpts{}) th.AssertNoErr(t, err) - th.AssertEquals(t, 1, pages) + th.AssertEquals(t, 1, actual.TotalCount) + th.AssertEquals(t, 1, len(actual.EnterpriseProjects)) + th.AssertEquals(t, "0", actual.EnterpriseProjects[0].ID) + th.AssertEquals(t, "default", actual.EnterpriseProjects[0].Name) } func TestGet(t *testing.T) { th.SetupHTTP() defer th.TeardownHTTP() - th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { + th.Mux.HandleFunc("/enterprise-projects/0", func(w http.ResponseWriter, r *http.Request) { th.TestMethod(t, r, http.MethodGet) w.Header().Add("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"enterprise_project":{"id":"0","name":"default","description":"Default project","status":1,"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z","type":"prod","delete_flag":false}}`) }) - actual, err := projects.Get(client.ServiceClient(), epID).Extract() + actual, err := projects.Get(client.ServiceClient(), "0") th.AssertNoErr(t, err) - th.AssertEquals(t, epID, actual.ID) + th.AssertEquals(t, "0", actual.ID) th.AssertEquals(t, "default", actual.Name) } @@ -69,7 +59,7 @@ func TestCreate(t *testing.T) { actual, err := projects.Create(client.ServiceClient(), projects.CreateOpts{ Name: "project-a", Description: "created by test", - }).Extract() + }) th.AssertNoErr(t, err) th.AssertEquals(t, "project-a", actual.Name) } @@ -78,14 +68,17 @@ func TestUpdate(t *testing.T) { th.SetupHTTP() defer th.TeardownHTTP() - th.Mux.HandleFunc("/enterprise-projects/"+epID, func(w http.ResponseWriter, r *http.Request) { + th.Mux.HandleFunc("/enterprise-projects/0", func(w http.ResponseWriter, r *http.Request) { th.TestMethod(t, r, http.MethodPut) th.TestJSONRequest(t, r, `{"name":"default","description":"updated by test"}`) w.Header().Add("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"enterprise_project":{"id":"0","name":"default","description":"updated by test","status":1,"type":"prod","delete_flag":false}}`) }) - actual, err := projects.Update(client.ServiceClient(), epID, projects.UpdateOpts{Name: "default", Description: "updated by test"}).Extract() + actual, err := projects.Update(client.ServiceClient(), "0", projects.UpdateOpts{ + Name: "default", + Description: "updated by test", + }) th.AssertNoErr(t, err) th.AssertEquals(t, "updated by test", actual.Description) } @@ -94,12 +87,12 @@ func TestAction(t *testing.T) { th.SetupHTTP() defer th.TeardownHTTP() - th.Mux.HandleFunc("/enterprise-projects/"+epID+"/action", func(w http.ResponseWriter, r *http.Request) { + th.Mux.HandleFunc("/enterprise-projects/0/action", func(w http.ResponseWriter, r *http.Request) { th.TestMethod(t, r, http.MethodPost) th.TestJSONRequest(t, r, `{"action":"enable"}`) w.WriteHeader(http.StatusNoContent) }) - err := projects.Action(client.ServiceClient(), epID, projects.ActionOpts{Action: "enable"}).ExtractErr() + err := projects.Action(client.ServiceClient(), "0", projects.ActionOpts{Action: "enable"}) th.AssertNoErr(t, err) } diff --git a/openstack/eps/v1/projects/urls.go b/openstack/eps/v1/projects/urls.go deleted file mode 100644 index 872c1304a..000000000 --- a/openstack/eps/v1/projects/urls.go +++ /dev/null @@ -1,15 +0,0 @@ -package projects - -import golangsdk "github.com/opentelekomcloud/gophertelekomcloud" - -const ( - resourcePath = "enterprise-projects" -) - -func rootURL(c *golangsdk.ServiceClient) string { - return c.ServiceURL(resourcePath) -} - -func resourceURL(c *golangsdk.ServiceClient, id string) string { - return c.ServiceURL(resourcePath, id) -} diff --git a/openstack/eps/v1/resources/requests.go b/openstack/eps/v1/resources/Filter.go similarity index 61% rename from openstack/eps/v1/resources/requests.go rename to openstack/eps/v1/resources/Filter.go index d978035f2..6232a63f3 100644 --- a/openstack/eps/v1/resources/requests.go +++ b/openstack/eps/v1/resources/Filter.go @@ -39,15 +39,13 @@ type FilterOpts struct { Matches []Match `json:"matches,omitempty"` } -// Filter queries resources belonging to an enterprise project. func Filter(client *golangsdk.ServiceClient, projectID string, opts FilterOpts) (*FilterResult, error) { b, err := build.RequestBody(opts, "") if err != nil { return nil, err } - url := client.ServiceURL("enterprise-projects", projectID, "resources", "filter") - raw, err := client.Post(url, b, nil, &golangsdk.RequestOpts{ + raw, err := client.Post(client.ServiceURL("enterprise-projects", projectID, "resources", "filter"), b, nil, &golangsdk.RequestOpts{ OkCodes: []int{200}, }) if err != nil { @@ -58,28 +56,3 @@ func Filter(client *golangsdk.ServiceClient, projectID string, opts FilterOpts) err = extract.Into(raw.Body, &res) return &res, err } - -type MigrateResource struct { - ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` - RegionID string `json:"region_id,omitempty"` -} - -type MigrateOpts struct { - ProjectID string `json:"project_id"` - Resources []MigrateResource `json:"resources"` -} - -// Migrate moves resources from one enterprise project to another. -func Migrate(client *golangsdk.ServiceClient, projectID string, opts MigrateOpts) error { - b, err := build.RequestBody(opts, "") - if err != nil { - return err - } - - url := client.ServiceURL("enterprise-projects", projectID, "resources-migrate") - _, err = client.Post(url, b, nil, &golangsdk.RequestOpts{ - OkCodes: []int{204}, - }) - return err -} diff --git a/openstack/eps/v1/resources/Migrate.go b/openstack/eps/v1/resources/Migrate.go new file mode 100644 index 000000000..6e4a8a5ad --- /dev/null +++ b/openstack/eps/v1/resources/Migrate.go @@ -0,0 +1,29 @@ +package resources + +import ( + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/internal/build" +) + +type MigrateResource struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + RegionID string `json:"region_id,omitempty"` +} + +type MigrateOpts struct { + ProjectID string `json:"project_id"` + Resources []MigrateResource `json:"resources"` +} + +func Migrate(client *golangsdk.ServiceClient, projectID string, opts MigrateOpts) error { + b, err := build.RequestBody(opts, "") + if err != nil { + return err + } + + _, err = client.Post(client.ServiceURL("enterprise-projects", projectID, "resources-migrate"), b, nil, &golangsdk.RequestOpts{ + OkCodes: []int{204}, + }) + return err +} diff --git a/openstack/eps/v1/resources/testing/requests_test.go b/openstack/eps/v1/resources/testing/requests_test.go index d604d51a7..7300ad665 100644 --- a/openstack/eps/v1/resources/testing/requests_test.go +++ b/openstack/eps/v1/resources/testing/requests_test.go @@ -24,7 +24,6 @@ func TestFilter(t *testing.T) { result, err := resources.Filter(client.ServiceClient(), "project-id-1", resources.FilterOpts{ Projects: []string{"project-id-1"}, ResourceTypes: []string{"ecs"}, - Offset: 0, Limit: 10, Matches: []resources.Match{{Key: "resource_name", Value: "my-server"}}, }) From d8a8776104d339c0cb11c7c6681169b6f4f222f6 Mon Sep 17 00:00:00 2001 From: anton-sidelnikov Date: Thu, 2 Jul 2026 11:26:06 +0200 Subject: [PATCH 7/9] skip tests --- acceptance/openstack/eps/v1/projects_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go index 512a678ee..3000f71bf 100644 --- a/acceptance/openstack/eps/v1/projects_test.go +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -14,6 +14,7 @@ import ( ) func TestEnterpriseProjectsListAndGet(t *testing.T) { + t.Skip("enterprise project tests not for CI") client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) @@ -34,6 +35,7 @@ func TestEnterpriseProjectsListAndGet(t *testing.T) { } func TestEnterpriseProjectsLifecycle(t *testing.T) { + t.Skip("enterprise project tests not for CI") client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) @@ -126,6 +128,7 @@ func TestProviders(t *testing.T) { } func TestResourcesFilter(t *testing.T) { + t.Skip("enterprise project tests not for CI") client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) From 21f07e053afe757fce6de86c71c4e13dacee8417 Mon Sep 17 00:00:00 2001 From: anton-sidelnikov Date: Thu, 2 Jul 2026 11:36:33 +0200 Subject: [PATCH 8/9] skip tests again --- acceptance/openstack/eps/v1/projects_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acceptance/openstack/eps/v1/projects_test.go b/acceptance/openstack/eps/v1/projects_test.go index 3000f71bf..a992e6c1b 100644 --- a/acceptance/openstack/eps/v1/projects_test.go +++ b/acceptance/openstack/eps/v1/projects_test.go @@ -96,6 +96,7 @@ func TestEnterpriseProjectsLifecycle(t *testing.T) { } func TestVersions(t *testing.T) { + t.Skip("enterprise project tests not for CI") client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) @@ -112,6 +113,7 @@ func TestVersions(t *testing.T) { } func TestProviders(t *testing.T) { + t.Skip("enterprise project tests not for CI") client, err := clients.NewEPSV1Client() th.AssertNoErr(t, err) From daa4ff160cc5889da809085e217b3c17cc4b3f9f Mon Sep 17 00:00:00 2001 From: anton-sidelnikov Date: Thu, 2 Jul 2026 13:05:51 +0200 Subject: [PATCH 9/9] fix lts hg test (delete codes change) --- openstack/lts/v2/cloud-structuring/Delete.go | 2 +- openstack/lts/v2/host-groups/Delete.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openstack/lts/v2/cloud-structuring/Delete.go b/openstack/lts/v2/cloud-structuring/Delete.go index 84da54d58..03d7ad11a 100644 --- a/openstack/lts/v2/cloud-structuring/Delete.go +++ b/openstack/lts/v2/cloud-structuring/Delete.go @@ -20,7 +20,7 @@ func Delete(client *golangsdk.ServiceClient, id string) (err error) { MoreHeaders: map[string]string{ "content-type": "application/json", }, - OkCodes: []int{200}, + OkCodes: []int{200, 204}, }) if err != nil { return err diff --git a/openstack/lts/v2/host-groups/Delete.go b/openstack/lts/v2/host-groups/Delete.go index f68b07eea..0b1b3c65d 100644 --- a/openstack/lts/v2/host-groups/Delete.go +++ b/openstack/lts/v2/host-groups/Delete.go @@ -22,7 +22,7 @@ func Delete(client *golangsdk.ServiceClient, opts DeleteOpts) (*DeleteResult, er MoreHeaders: map[string]string{ "content-type": "application/json", }, - OkCodes: []int{200}, + OkCodes: []int{200, 204}, }) if err != nil { return nil, err