initial commit

This commit is contained in:
Mike Mason
2023-07-01 00:04:52 +00:00
commit 80fb879ef6
65 changed files with 3544 additions and 0 deletions

15
internal/metal/config.go Normal file
View File

@@ -0,0 +1,15 @@
package metal
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
)
// Config provides configuration for connecting to the metal provider.
type Config struct {
// EMGQL sets the provider to Equinix Metal GraphQL.
EMGQL emgql.Config
// EMAPI sets the provider to Equinix Metal API.
EMAPI emapi.Config
}

2
internal/metal/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package metal provides a simple interface for getting details about organizations, projects and users.
package metal

8
internal/metal/errors.go Normal file
View File

@@ -0,0 +1,8 @@
package metal
import "errors"
var (
// ErrUnauthorized is returned when the token provided did not validate to a user.
ErrUnauthorized = errors.New("unauthorized key")
)

46
internal/metal/metal.go Normal file
View File

@@ -0,0 +1,46 @@
package metal
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.infratographer.com/x/gidx"
"go.uber.org/zap"
)
// Client is the Equinix Metal API Client struct.
type Client struct {
logger *zap.Logger
provider provider.Provider
}
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return c.provider.GetOrganizationDetails(ctx, id)
}
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return c.provider.GetProjectDetails(ctx, id)
}
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return c.provider.GetUserDetails(ctx, id)
}
// New creates a new Client.
func New(options ...Option) (*Client, error) {
client := new(Client)
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop()
}
return client, nil
}

View File

@@ -0,0 +1,12 @@
package models
const (
// IDPrefixOrganization defines the ID Prefix for an Organization.
IDPrefixOrganization = "metlorg"
// IDPrefixProject defines the ID Prefix for a Project.
IDPrefixProject = "metlprj"
// IDPrefixUser defines the ID Prefix for a User.
IDPrefixUser = "metlusr"
)

View File

@@ -0,0 +1,8 @@
package models
type Membership[T any] struct {
ID string `json:"id"`
User *UserDetails `json:"user"`
Entity *T `json:"entity"`
Roles []string `json:"roles"`
}

View File

@@ -0,0 +1,18 @@
package models
import "go.infratographer.com/x/gidx"
type OrganizationDetails struct {
ID string `json:"id"`
Name string `json:"name"`
Memberships []*Membership[OrganizationDetails] `json:"memberships"`
Projects []*ProjectDetails `json:"projects"`
}
func (d *OrganizationDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixOrganization + "-" + d.ID)
}

View File

@@ -0,0 +1,18 @@
package models
import "go.infratographer.com/x/gidx"
type ProjectDetails struct {
ID string `json:"id"`
Name string `json:"name"`
Memberships []*Membership[ProjectDetails] `json:"memberships"`
Organization *OrganizationDetails `json:"organization"`
}
func (d *ProjectDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixProject + "-" + d.ID)
}

View File

@@ -0,0 +1,23 @@
package models
import "go.infratographer.com/x/gidx"
const (
MetalUserPrefix = "metlusr"
)
type UserDetails struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Organizations []*OrganizationDetails `json:"organizations"`
Projects []*ProjectDetails `json:"projects"`
Roles []string `json:"roles"`
}
func (d *UserDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixUser + "-" + d.ID)
}

60
internal/metal/options.go Normal file
View File

@@ -0,0 +1,60 @@
package metal
import (
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithProvider sets the provider on the client.
func WithProvider(provider provider.Provider) Option {
return func(c *Client) error {
c.provider = provider
return nil
}
}
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.Logger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.EMGQL.Populated() {
client, err := emgql.New(emgql.WithConfig(config.EMGQL))
if err != nil {
return err
}
options = append(options, WithProvider(client))
} else if config.EMAPI.Populated() {
client, err := emapi.New(emapi.WithConfig(config.EMAPI))
if err != nil {
return err
}
options = append(options, WithProvider(client))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,134 @@
package emapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
)
const (
defaultHTTPTimeout = 20 * time.Second
defaultBaseURL = "https://api.equinix.com/metal/v1"
authHeader = "X-Auth-Token"
consumerHeader = "X-Consumer-Token"
staffHeader = "X-Packet-Staff"
staffHeaderValue = "true"
)
var DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout,
}
var _ provider.Provider = &Client{}
type Client struct {
logger *zap.SugaredLogger
httpClient *http.Client
baseURL *url.URL
authToken string
consumerToken string
}
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
if c.authToken != "" {
req.Header.Set(authHeader, c.authToken)
}
if c.consumerToken != "" {
req.Header.Set(consumerHeader, c.consumerToken)
}
if c.authToken != "" && c.consumerToken != "" {
req.Header.Set(staffHeader, staffHeaderValue)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if out != nil {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp, err
}
}
return resp, nil
}
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
path = strings.TrimPrefix(path, c.baseURL.Path)
pathURL, err := url.Parse(path)
if err != nil {
return nil, err
}
url := c.baseURL.JoinPath(pathURL.Path)
query := url.Query()
for k, v := range pathURL.Query() {
query[k] = v
}
url.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
resp, err := c.Do(req, out)
if err != nil {
return nil, fmt.Errorf("failed to get response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp, nil
}
// New creates a new emapi client
func New(options ...Option) (*Client, error) {
client := &Client{}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop().Sugar()
}
if client.httpClient == nil {
client.httpClient = DefaultHTTPClient
}
if client.baseURL == nil {
u, err := url.Parse(defaultBaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse emapi base url %s: %w", defaultBaseURL, err)
}
client.baseURL = u
}
return client, nil
}

View File

@@ -0,0 +1,81 @@
package emapi
import (
"fmt"
"net/url"
)
// Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string
// AuthToken is the token to interact with the Equinix Metal API
AuthToken string
// ConsumerToken is the token to grant higher privileges in the Equinix Metal API
ConsumerToken string
}
func (c Config) Populated() bool {
return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != ""
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.BaseURL != "" {
options = append(options, WithBaseURL(config.BaseURL))
}
if config.AuthToken != "" {
options = append(options, WithAuthToken(config.AuthToken))
}
if config.ConsumerToken != "" {
options = append(options, WithConsumerToken(config.ConsumerToken))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}
// WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
u, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err)
}
c.baseURL = u
return nil
}
}
// WithAuthToken sets the auth token to authenticate the request with.
func WithAuthToken(token string) Option {
return func(c *Client) error {
c.authToken = token
return nil
}
}
// WithConsumerToken sets the consumer token to elevate privileges for the request.
func WithConsumerToken(token string) Option {
return func(c *Client) error {
c.consumerToken = token
return nil
}
}

View File

@@ -0,0 +1,5 @@
package emapi
import "errors"
var ErrBaseURLRequired = errors.New("emapi base url required")

View File

@@ -0,0 +1,84 @@
package emapi
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
type Roles []string
type Memberships []*Membership
func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] {
memberships := make([]*models.Membership[models.OrganizationDetails], len(m))
var nextIndex int
for _, membership := range m {
memberships[nextIndex] = membership.ToDetailsWithOrganizationDetails(orgDetails)
if memberships[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(m) {
return memberships[:nextIndex]
}
return memberships
}
func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] {
memberships := make([]*models.Membership[models.ProjectDetails], len(m))
var nextIndex int
for _, membership := range m {
memberships[nextIndex] = membership.ToDetailsWithProjectDetails(projDetails)
if memberships[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(m) {
return memberships[:nextIndex]
}
return memberships
}
type Membership struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Roles Roles `json:"roles"`
User *User `json:"user"`
}
func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] {
if m.ID == "" {
return nil
}
return &models.Membership[models.OrganizationDetails]{
ID: m.ID,
Roles: m.Roles,
User: m.User.ToDetails(),
Entity: orgDetails,
}
}
func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] {
if m.ID == "" {
return nil
}
return &models.Membership[models.ProjectDetails]{
ID: m.ID,
Roles: m.Roles,
User: m.User.ToDetails(),
Entity: projDetails,
}
}

View File

@@ -0,0 +1,17 @@
package emapi
import (
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}

View File

@@ -0,0 +1,84 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
organizationsPath = "/organizations"
)
type Organizations []*Organization
func (o Organizations) ToDetails() []*models.OrganizationDetails {
orgs := make([]*models.OrganizationDetails, len(o))
var nextIndex int
for _, org := range o {
orgs[nextIndex] = org.ToDetails()
if orgs[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(o) {
return orgs[:nextIndex]
}
return orgs
}
type Organization struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Memberships Memberships `json:"memberships"`
Projects Projects `json:"projects"`
}
func (o *Organization) ToDetails() *models.OrganizationDetails {
if o == nil || o.ID == "" {
return nil
}
details := &models.OrganizationDetails{
ID: o.ID,
Name: o.Name,
Projects: o.Projects.ToDetails(),
}
details.Memberships = o.Memberships.ToDetailsWithOrganizationDetails(details)
return details
}
func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) {
var org Organization
_, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user,projects.memberships.user", nil, &org)
if err != nil {
return nil, fmt.Errorf("error loading organization: %w", err)
}
return &org, nil
}
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil {
return nil, err
}
return org.ToDetails(), nil
}

View File

@@ -0,0 +1,84 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
projectsPath = "/projects"
)
type Projects []*Project
func (p Projects) ToDetails() []*models.ProjectDetails {
projects := make([]*models.ProjectDetails, len(p))
var nextIndex int
for _, project := range p {
projects[nextIndex] = project.ToDetails()
if projects[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(p) {
return projects[:nextIndex]
}
return projects
}
type Project struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Memberships Memberships `json:"memberships"`
Organization *Organization `json:"organization"`
}
func (p *Project) ToDetails() *models.ProjectDetails {
if p == nil || p.ID == "" {
return nil
}
details := &models.ProjectDetails{
ID: p.ID,
Name: p.Name,
Organization: p.Organization.ToDetails(),
}
details.Memberships = p.Memberships.ToDetailsWithProjectDetails(details)
return details
}
func (c *Client) getProject(ctx context.Context, id string) (*Project, error) {
var project Project
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project)
if err != nil {
return nil, fmt.Errorf("error loading project: %w", err)
}
return &project, nil
}
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
project, err := c.getProject(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil {
return nil, err
}
return project.ToDetails(), nil
}

View File

@@ -0,0 +1,80 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
usersPath = "/users"
)
type Users []*User
func (u Users) ToDetails() []*models.UserDetails {
users := make([]*models.UserDetails, len(u))
var nextIndex int
for _, user := range u {
users[nextIndex] = user.ToDetails()
if users[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(u) {
return users[:nextIndex]
}
return users
}
type User struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
FullName string `json:"full_name"`
Email string `json:"email"`
Projects Projects `json:"projects"`
}
func (u *User) ToDetails() *models.UserDetails {
if u.ID == "" {
return nil
}
return &models.UserDetails{
ID: u.ID,
FullName: u.FullName,
Organizations: nil,
Projects: u.Projects.ToDetails(),
}
}
func (c *Client) getUser(ctx context.Context, id string) (*User, error) {
var user User
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(usersPath, id).String(), nil, &user)
if err != nil {
return nil, fmt.Errorf("error loading user: %w", err)
}
return &user, nil
}
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil {
return nil, err
}
return user.ToDetails(), nil
}

View File

@@ -0,0 +1,49 @@
package emgql
import (
"net/http"
"net/url"
"time"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.uber.org/zap"
)
const (
defaultHTTPTimeout = 5 * time.Second
)
var (
DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout,
}
)
var _ provider.Provider = &Client{}
type Client struct {
logger *zap.SugaredLogger
httpClient *http.Client
baseURL *url.URL
}
// New creates a new emapi client
func New(options ...Option) (*Client, error) {
client := &Client{}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop().Sugar()
}
if client.httpClient == nil {
client.httpClient = DefaultHTTPClient
}
return client, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
// Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string
}
func (c Config) Populated() bool {
return c.BaseURL != ""
}

View File

@@ -0,0 +1,52 @@
package emgql
import (
"net/url"
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}
// WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
u, err := url.Parse(baseURL)
if err != nil {
return err
}
c.baseURL = u
return nil
}
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.BaseURL != "" {
options = append(options, WithBaseURL(config.BaseURL))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,14 @@
package provider
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
type Provider interface {
GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error)
GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error)
GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error)
}