diff --git a/client/client.go b/client/client.go index adee410..38082b4 100644 --- a/client/client.go +++ b/client/client.go @@ -93,8 +93,29 @@ type AuthenticatingClient interface { IdentityAuthOptions() (identity.AuthOptions, error) } -// A single http client is shared between all Goose clients. -var sharedHttpClient = goosehttp.New() +// Option allows the adaptation of a client given new options. +// Both client.Client and http.Client have Options. To allow isolation between +// layers, we have separate options. If client.Client and http.Client want +// different options they can do so, without causing conflict. +type Option func(*options) + +type options struct { + httpHeadersFunc goosehttp.HeadersFunc +} + +// WithHTTPHeadersFunc allows passing in a new HTTP headers func for the client +// to execute for each request. +func WithHTTPHeadersFunc(httpHeadersFunc goosehttp.HeadersFunc) Option { + return func(options *options) { + options.httpHeadersFunc = httpHeadersFunc + } +} + +func newOptions() *options { + return &options{ + httpHeadersFunc: goosehttp.DefaultHeaders, + } +} // This client sends requests without authenticating. type client struct { @@ -142,19 +163,72 @@ func (c *authenticatingClient) EndpointsForRegion(region string) identity.Servic var _ AuthenticatingClient = (*authenticatingClient)(nil) -func NewPublicClient(baseURL string, logger logging.CompatLogger) Client { - client := client{baseURL: baseURL, logger: logger, httpClient: sharedHttpClient} - return &client +// TODO (stickupkid): The needs some clean up. +// All the following New constructor methods should actually be placed into +// one factory method with a given configuration so that there is only one +// place that a client can be constructed. + +// NewPublicClient creates a new Client that validates against TLS. +func NewPublicClient(baseURL string, logger logging.CompatLogger, options ...Option) Client { + opts := newOptions() + for _, option := range options { + option(opts) + } + + return &client{ + baseURL: baseURL, + logger: logger, + httpClient: goosehttp.New(goosehttp.WithHeadersFunc(opts.httpHeadersFunc)), + } } -func NewNonValidatingPublicClient(baseURL string, logger logging.CompatLogger) Client { +// NewNonValidatingPublicClient creates a new Client that doesn't validate +// against TLS. +func NewNonValidatingPublicClient(baseURL string, logger logging.CompatLogger, options ...Option) Client { + opts := newOptions() + for _, option := range options { + option(opts) + } + return &client{ baseURL: baseURL, logger: logger, - httpClient: goosehttp.NewNonSSLValidating(), + httpClient: goosehttp.NewNonSSLValidating(goosehttp.WithHeadersFunc(opts.httpHeadersFunc)), } } +// NewClient creates a new authenticated client. +func NewClient(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger, options ...Option) AuthenticatingClient { + opts := newOptions() + for _, option := range options { + option(opts) + } + + return newClient(creds, authMethod, goosehttp.New(goosehttp.WithHeadersFunc(opts.httpHeadersFunc)), logger) +} + +// NewNonValidatingClient creates a new authenticated client that doesn't +// validate against TLS. +func NewNonValidatingClient(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger, options ...Option) AuthenticatingClient { + opts := newOptions() + for _, option := range options { + option(opts) + } + + return newClient(creds, authMethod, goosehttp.NewNonSSLValidating(goosehttp.WithHeadersFunc(opts.httpHeadersFunc)), logger) +} + +// NewClientTLSConfig creates a new authenticated client that allows passing +// in a new TLS config. +func NewClientTLSConfig(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger, config *tls.Config, options ...Option) AuthenticatingClient { + opts := newOptions() + for _, option := range options { + option(opts) + } + + return newClient(creds, authMethod, goosehttp.NewWithTLSConfig(config, goosehttp.WithHeadersFunc(opts.httpHeadersFunc)), logger) +} + var defaultRequiredServiceTypes = []string{"compute", "object-store"} func newClient(creds *identity.Credentials, auth_method identity.AuthMode, httpClient goosehttp.HttpClient, logger logging.CompatLogger) AuthenticatingClient { @@ -169,9 +243,12 @@ func newClient(creds *identity.Credentials, auth_method identity.AuthMode, httpC client_creds.URL = client_creds.URL + apiTokens } client := authenticatingClient{ - creds: &client_creds, - requiredServiceTypes: defaultRequiredServiceTypes, - client: client{logger: logger, httpClient: httpClient}, + creds: &client_creds, + requiredServiceTypes: defaultRequiredServiceTypes, + client: client{ + logger: logger, + httpClient: httpClient, + }, apiVersionDiscoveryDisabled: set.NewStrings(), } client.auth = &client @@ -179,18 +256,6 @@ func newClient(creds *identity.Credentials, auth_method identity.AuthMode, httpC return &client } -func NewClient(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger) AuthenticatingClient { - return newClient(creds, authMethod, sharedHttpClient, logger) -} - -func NewNonValidatingClient(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger) AuthenticatingClient { - return newClient(creds, authMethod, goosehttp.NewNonSSLValidating(), logger) -} - -func NewClientTLSConfig(creds *identity.Credentials, authMethod identity.AuthMode, logger logging.CompatLogger, config *tls.Config) AuthenticatingClient { - return newClient(creds, authMethod, goosehttp.NewWithTLSConfig(config), logger) -} - func (c *client) sendRequest(method, url, token string, requestData *goosehttp.RequestData) (err error) { if requestData.ReqValue != nil || requestData.RespValue != nil { err = c.httpClient.JsonRequest(method, url, token, requestData, c.logger) diff --git a/http/client.go b/http/client.go index 8b977e9..db7b92b 100644 --- a/http/client.go +++ b/http/client.go @@ -37,8 +37,33 @@ type HttpClient interface { PostForm(url string, data url.Values) (resp *http.Response, err error) } +// Option allows the adaptation of a http client given new options. +// Both client.Client and http.Client have Options. To allow isolation between +// layers, we have separate options. If client.Client and http.Client want +// different options they can do so, without causing conflict. +type Option func(*options) + +type options struct { + headersFunc HeadersFunc +} + +// WithHeadersFunc allows passing in a new headers func for the http.Client +// to execute for each request. +func WithHeadersFunc(headersFunc HeadersFunc) Option { + return func(options *options) { + options.headersFunc = headersFunc + } +} + +func newOptions() *options { + return &options{ + headersFunc: DefaultHeaders, + } +} + type Client struct { http.Client + headersFunc HeadersFunc maxSendAttempts int } @@ -112,11 +137,26 @@ var insecureClient *http.Client var insecureClientMutex sync.Mutex // New returns a new goose http *Client using the default net/http client. -func New() *Client { - return &Client{*http.DefaultClient, MaxSendAttempts} +func New(options ...Option) *Client { + opts := newOptions() + for _, option := range options { + option(opts) + } + + return &Client{ + Client: *http.DefaultClient, + headersFunc: opts.headersFunc, + maxSendAttempts: MaxSendAttempts, + } } -func NewNonSSLValidating() *Client { +// NewNonSSLValidating creates a new goose http *Client skipping SSL validation. +func NewNonSSLValidating(options ...Option) *Client { + opts := newOptions() + for _, option := range options { + option(opts) + } + insecureClientMutex.Lock() httpClient := insecureClient if httpClient == nil { @@ -126,41 +166,38 @@ func NewNonSSLValidating() *Client { httpClient = insecureClient } insecureClientMutex.Unlock() - return &Client{*httpClient, MaxSendAttempts} + + return &Client{ + Client: *httpClient, + headersFunc: opts.headersFunc, + maxSendAttempts: MaxSendAttempts, + } } -func NewWithTLSConfig(tlsConfig *tls.Config) *Client { +// NewWithTLSConfig creates a new goose http *Client with a TLS config. +func NewWithTLSConfig(tlsConfig *tls.Config, options ...Option) *Client { + opts := newOptions() + for _, option := range options { + option(opts) + } + defaultClient := *http.DefaultClient defaultClient.Transport = &http.Transport{ TLSClientConfig: tlsConfig, } - return &Client{defaultClient, MaxSendAttempts} + + return &Client{ + Client: defaultClient, + headersFunc: opts.headersFunc, + maxSendAttempts: MaxSendAttempts, + } } +// gooseAgent returns the current client goose agent version. func gooseAgent() string { return fmt.Sprintf("goose (%s)", goose.Version) } -func createHeaders(extraHeaders http.Header, contentType, authToken string, payloadExists bool) http.Header { - headers := make(http.Header) - if extraHeaders != nil { - for header, values := range extraHeaders { - for _, value := range values { - headers.Add(header, value) - } - } - } - if authToken != "" { - headers.Set("X-Auth-Token", authToken) - } - if payloadExists { - headers.Add("Content-Type", contentType) - } - headers.Add("Accept", contentType) - headers.Add("User-Agent", gooseAgent()) - return headers -} - // JsonRequest JSON encodes and sends the object in reqData.ReqValue (if any) to the specified URL. // Optional method arguments are passed using the RequestData object. // Relevant RequestData fields: @@ -186,7 +223,7 @@ func (c *Client) JsonRequest(method, url, token string, reqData *RequestData, lo } length = int64(len(data)) } - headers := createHeaders(reqData.ReqHeaders, contentTypeJSON, token, reqData.ReqValue != nil) + headers := c.headersFunc(method, reqData.ReqHeaders, contentTypeJSON, token, reqData.ReqValue != nil) resp, err := c.sendRequest( method, url, @@ -231,7 +268,7 @@ func (c *Client) BinaryRequest(method, url, token string, reqData *RequestData, if reqData.Params != nil { url += "?" + reqData.Params.Encode() } - headers := createHeaders(reqData.ReqHeaders, contentTypeOctetStream, token, reqData.ReqLength != 0) + headers := c.headersFunc(method, reqData.ReqHeaders, contentTypeOctetStream, token, reqData.ReqLength != 0) resp, err := c.sendRequest( method, url, diff --git a/http/client_test.go b/http/client_test.go index 8373f10..ea12d81 100644 --- a/http/client_test.go +++ b/http/client_test.go @@ -46,7 +46,7 @@ var _ = gc.Suite(&HTTPClientTestSuite{}) func (s *HTTPClientTestSuite) assertHeaderValues(c *gc.C, token string) { emptyHeaders := http.Header{} - headers := createHeaders(emptyHeaders, "content-type", token, true) + headers := DefaultHeaders("GET", emptyHeaders, "content-type", token, true) contentTypes := []string{"content-type"} headerData := map[string][]string{ "Content-Type": contentTypes, "Accept": contentTypes, "User-Agent": {gooseAgent()}} @@ -71,7 +71,7 @@ func (s *HTTPClientTestSuite) TestCreateHeadersCopiesSupplied(c *gc.C) { initialHeaders["Foo"] = []string{"Bar"} contentType := contentTypeJSON contentTypes := []string{contentType} - headers := createHeaders(initialHeaders, contentType, "", true) + headers := DefaultHeaders("GET", initialHeaders, contentType, "", true) // it should not change the headers passed in c.Assert(initialHeaders, gc.DeepEquals, http.Header{"Foo": []string{"Bar"}}) // The initial headers should be in the output diff --git a/http/headers.go b/http/headers.go new file mode 100644 index 0000000..74ec78d --- /dev/null +++ b/http/headers.go @@ -0,0 +1,40 @@ +package http + +import "net/http" + +// HeadersFunc is type for aligning the creation of a series of client headers. +type HeadersFunc = func(method string, headers http.Header, contentType, authToken string, hasPayload bool) http.Header + +// DefaultHeaders creates a set of http.Headers from the given arguments passed +// in. +// In this case it applies the headers passed in first, then sets the following: +// - X-Auth-Token +// - Content-Type +// - Accept +// - User-Agent +// +func DefaultHeaders(method string, extraHeaders http.Header, contentType, authToken string, payloadExists bool) http.Header { + headers := BasicHeaders() + if extraHeaders != nil { + for header, values := range extraHeaders { + for _, value := range values { + headers.Add(header, value) + } + } + } + if authToken != "" { + headers.Set("X-Auth-Token", authToken) + } + if payloadExists { + headers.Add("Content-Type", contentType) + } + headers.Add("Accept", contentType) + return headers +} + +// BasicHeaders constructs basic http.Headers with expected default values. +func BasicHeaders() http.Header { + headers := make(http.Header) + headers.Add("User-Agent", gooseAgent()) + return headers +} diff --git a/identity/legacy.go b/identity/legacy.go index 019a005..a17f008 100644 --- a/identity/legacy.go +++ b/identity/legacy.go @@ -17,39 +17,45 @@ func (l *Legacy) Auth(creds *Credentials) (*AuthDetails, error) { if l.client == nil { l.client = goosehttp.New() } + request, err := http.NewRequest("GET", creds.URL, nil) if err != nil { return nil, err } request.Header.Set("X-Auth-User", creds.User) request.Header.Set("X-Auth-Key", creds.Secrets) + response, err := l.client.Do(request) if err != nil { return nil, err } defer response.Body.Close() + if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK { content, _ := ioutil.ReadAll(response.Body) return nil, fmt.Errorf("Failed to Authenticate (code %d %s): %s", response.StatusCode, response.Status, content) } + details := &AuthDetails{} details.Token = response.Header.Get("X-Auth-Token") if details.Token == "" { return nil, gooseerrors.NewUnauthorisedf(nil, "", "Did not get valid Token from auth request") } details.RegionServiceURLs = make(map[string]ServiceURLs) + serviceURLs := make(ServiceURLs) + // Legacy authentication doesn't require a region so use "". details.RegionServiceURLs[""] = serviceURLs - nova_url := response.Header.Get("X-Server-Management-Url") - serviceURLs["compute"] = nova_url + novaURL := response.Header.Get("X-Server-Management-Url") + serviceURLs["compute"] = novaURL - swift_url := response.Header.Get("X-Storage-Url") - if swift_url == "" { + swiftURL := response.Header.Get("X-Storage-Url") + if swiftURL == "" { return nil, fmt.Errorf("Did not get valid swift management URL from auth request") } - serviceURLs["object-store"] = swift_url + serviceURLs["object-store"] = swiftURL return details, nil } diff --git a/identity/legacy_test.go b/identity/legacy_test.go index 6f623a2..c020734 100644 --- a/identity/legacy_test.go +++ b/identity/legacy_test.go @@ -16,10 +16,13 @@ var _ = gc.Suite(&LegacyTestSuite{}) func (s *LegacyTestSuite) TestAuthAgainstServer(c *gc.C) { service := identityservice.NewLegacy() s.Mux.Handle("/", service) + userInfo := service.AddUser("joe-user", "secrets", "tenant", "default") service.SetManagementURL("http://management.test.invalid/url") + var l Authenticator = &Legacy{} creds := Credentials{User: "joe-user", URL: s.Server.URL, Secrets: "secrets"} + auth, err := l.Auth(&creds) c.Assert(err, gc.IsNil) c.Assert(auth.Token, gc.Equals, userInfo.Token) @@ -32,7 +35,9 @@ func (s *LegacyTestSuite) TestAuthAgainstServer(c *gc.C) { func (s *LegacyTestSuite) TestBadAuth(c *gc.C) { service := identityservice.NewLegacy() s.Mux.Handle("/", service) + _ = service.AddUser("joe-user", "secrets", "tenant", "default") + var l Authenticator = &Legacy{} creds := Credentials{User: "joe-user", URL: s.Server.URL, Secrets: "bad-secrets"} auth, err := l.Auth(&creds) diff --git a/identity/live_test.go b/identity/live_test.go index 1fd5cb4..567170f 100644 --- a/identity/live_test.go +++ b/identity/live_test.go @@ -40,8 +40,10 @@ func (s *LiveTests) TearDownTest(c *gc.C) { func (s *LiveTests) TestAuth(c *gc.C) { err := s.client.Authenticate() c.Assert(err, gc.IsNil) + serviceURL, err := s.client.MakeServiceURL("compute", "v2", []string{}) c.Assert(err, gc.IsNil) + _, err = url.Parse(serviceURL) c.Assert(err, gc.IsNil) } diff --git a/identity/local_test.go b/identity/local_test.go index 5864c67..173e9ef 100644 --- a/identity/local_test.go +++ b/identity/local_test.go @@ -64,8 +64,10 @@ func (s *localLiveSuite) TearDownTest(c *gc.C) { func (s *localLiveSuite) TestProductStreamsEndpoint(c *gc.C) { err := s.client.Authenticate() c.Assert(err, gc.IsNil) + serviceURL, err := s.client.MakeServiceURL("product-streams", "", nil) c.Assert(err, gc.IsNil) + _, err = url.Parse(serviceURL) c.Assert(err, gc.IsNil) c.Assert(strings.HasSuffix(serviceURL, "/imagemetadata"), gc.Equals, true) @@ -74,8 +76,10 @@ func (s *localLiveSuite) TestProductStreamsEndpoint(c *gc.C) { func (s *localLiveSuite) TestJujuToolsEndpoint(c *gc.C) { err := s.client.Authenticate() c.Assert(err, gc.IsNil) + serviceURL, err := s.client.MakeServiceURL("juju-tools", "", nil) c.Assert(err, gc.IsNil) + _, err = url.Parse(serviceURL) c.Assert(err, gc.IsNil) } diff --git a/identity/userpass_test.go b/identity/userpass_test.go index 58e4f13..3eaf1c1 100644 --- a/identity/userpass_test.go +++ b/identity/userpass_test.go @@ -16,9 +16,13 @@ var _ = gc.Suite(&UserPassTestSuite{}) func (s *UserPassTestSuite) TestAuthAgainstServer(c *gc.C) { service := identityservice.NewUserPass() service.SetupHTTP(s.Mux) + userInfo := service.AddUser("joe-user", "secrets", "tenant", "default") + var l Authenticator = &UserPass{} + creds := Credentials{User: "joe-user", URL: s.Server.URL + "/tokens", Secrets: "secrets"} + auth, err := l.Auth(&creds) c.Assert(err, gc.IsNil) c.Assert(auth.Token, gc.Equals, userInfo.Token) @@ -29,7 +33,9 @@ func (s *UserPassTestSuite) TestAuthAgainstServer(c *gc.C) { func (s *UserPassTestSuite) TestRegionMatch(c *gc.C) { service := identityservice.NewUserPass() service.SetupHTTP(s.Mux) + userInfo := service.AddUser("joe-user", "secrets", "tenant", "default") + serviceDef := identityservice.V2Service{ Name: "swift", Type: "object-store", @@ -37,6 +43,7 @@ func (s *UserPassTestSuite) TestRegionMatch(c *gc.C) { {PublicURL: "http://swift", Region: "RegionOne"}, }} service.AddService(identityservice.Service{V2: serviceDef}) + serviceDef = identityservice.V2Service{ Name: "nova", Type: "compute", @@ -44,6 +51,7 @@ func (s *UserPassTestSuite) TestRegionMatch(c *gc.C) { {PublicURL: "http://nova", Region: "zone1.RegionOne"}, }} service.AddService(identityservice.Service{V2: serviceDef}) + serviceDef = identityservice.V2Service{ Name: "nova", Type: "compute", @@ -58,7 +66,9 @@ func (s *UserPassTestSuite) TestRegionMatch(c *gc.C) { Secrets: "secrets", Region: "zone1.RegionOne", } + var l Authenticator = &UserPass{} + auth, err := l.Auth(&creds) c.Assert(err, gc.IsNil) c.Assert(auth.RegionServiceURLs["RegionOne"]["object-store"], gc.Equals, "http://swift") diff --git a/neutron/clientheaders.go b/neutron/clientheaders.go new file mode 100644 index 0000000..a8a1db1 --- /dev/null +++ b/neutron/clientheaders.go @@ -0,0 +1,63 @@ +package neutron + +import ( + "net/http" + + goosehttp "gopkg.in/goose.v2/http" +) + +// NeutronHeaders creates a set of http.Headers from the given arguments passed +// in. +// In this case it applies the headers passed in first, then sets the following: +// - X-Auth-Token +// - Content-Type +// - Accept +// - User-Agent +// +func NeutronHeaders(method string, extraHeaders http.Header, contentType, authToken string, payloadExists bool) http.Header { + headers := goosehttp.BasicHeaders() + + if authToken != "" { + headers.Set("X-Auth-Token", authToken) + } + + // Officially we should also take into account the method, as we should not + // be applying this to every request. + if payloadExists { + headers.Add("Content-Type", contentType) + } + + // According to the REST specs, the following should be considered the + // correct implementation of requests. Openstack implementation follow + // these specs and will return errors if certain headers are pressent. + // + // POST allows: [Content-Type, Accept] + // PUT allows: [Content-Type, Accept] + // GET allows: [Accept] + // PATCH allows: [Content-Type, Accept] + // HEAD allows: [] + // OPTIONS allows: [] + // DELETE allows: [] + // COPY allows: [] + + var ignoreAccept bool + switch method { + case "DELETE", "HEAD", "OPTIONS", "COPY": + ignoreAccept = true + } + + if !ignoreAccept { + headers.Add("Accept", contentType) + } + + // Now apply the passed in headers to the newly created headers. + if extraHeaders != nil { + for header, values := range extraHeaders { + for _, value := range values { + headers.Add(header, value) + } + } + } + + return headers +} diff --git a/neutron/clientheaders_test.go b/neutron/clientheaders_test.go new file mode 100644 index 0000000..c048f26 --- /dev/null +++ b/neutron/clientheaders_test.go @@ -0,0 +1,102 @@ +package neutron + +import ( + "fmt" + "net/http" + + gc "gopkg.in/check.v1" + goosehttp "gopkg.in/goose.v2/http" +) + +type clientHeaderSuite struct{} + +var _ = gc.Suite(&clientHeaderSuite{}) + +func (s *clientHeaderSuite) TestNeutronHeaders(c *gc.C) { + makeHeaders := func(m map[string]string) http.Header { + headers := goosehttp.BasicHeaders() + for k, v := range m { + headers.Add(k, v) + } + return headers + } + + type test struct { + name string + method string + headers http.Header + contentType string + authToken string + payloadExists bool + expected http.Header + } + + tests := []test{ + { + name: "test GET with empty args", + method: "GET", + expected: makeHeaders(map[string]string{ + "Accept": "", + }), + }, + { + // TODO (stickupkid): This test is actually wrong, it shouldn't + // return a Content-Type for GET, but to keep backwards + // compatibility, we accept this. + name: "test GET", + method: "GET", + contentType: "application/json", + payloadExists: true, + expected: makeHeaders(map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }), + }, + } + + // Test that Content-Type and Accept are correctly applied. + for _, method := range []string{"POST", "PUT", "PATCH"} { + tests = append(tests, test{ + name: fmt.Sprintf("test %s", method), + method: method, + contentType: "application/json", + payloadExists: true, + expected: makeHeaders(map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }), + }, test{ + name: fmt.Sprintf("test %s", method), + method: method, + contentType: "application/json", + expected: makeHeaders(map[string]string{ + "Accept": "application/json", + }), + }) + } + + // Test that Content-Type is correctly applied, but not Accept. + for _, method := range []string{"HEAD", "OPTIONS", "DELETE", "COPY"} { + tests = append(tests, test{ + name: fmt.Sprintf("test %s", method), + method: method, + contentType: "application/json", + payloadExists: true, + expected: makeHeaders(map[string]string{ + "Content-Type": "application/json", + }), + }, test{ + name: fmt.Sprintf("test %s", method), + method: method, + contentType: "application/json", + expected: makeHeaders(map[string]string{}), + }) + } + + for i, test := range tests { + c.Logf("test: %d, %s", i, test.name) + + got := NeutronHeaders(test.method, test.headers, test.contentType, test.authToken, test.payloadExists) + c.Assert(got, gc.DeepEquals, test.expected) + } +} diff --git a/neutron/neutron.go b/neutron/neutron.go index e3cd341..ba0947a 100644 --- a/neutron/neutron.go +++ b/neutron/neutron.go @@ -75,7 +75,9 @@ type Client struct { // New creates a new Client. func New(client client.Client) *Client { - return &Client{client} + return &Client{ + client: client, + } } // ---------------------------------------------------------------------------- @@ -251,12 +253,14 @@ func (c *Client) DeleteFloatingIPV2(ipId string) error { // PortV2 describes a defined network for administrating a port. type PortV2 struct { + AdminStateUp bool `json:"admin_state_up,omitempty"` Description string `json:"description,omitempty"` DeviceId string `json:"device_id,omitempty"` + DeviceOwner string `json:"device_owner,omitempty"` FixedIPs []PortFixedIPsV2 `json:"fixed_ips,omitempty"` Id string `json:"id,omitempty"` Name string `json:"name,omitempty"` - NetworkId string `json:"network_id"` + NetworkId string `json:"network_id,omitempty"` PortSecurityEnabled bool `json:"port_security_enabled,omitempty"` SecurityGroups []string `json:"security_groups,omitempty"` Status string `json:"status,omitempty"`