From 945ef82a5e43b684b20d6c1dff79ceca5017cdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20J=C4=99czalik?= Date: Wed, 12 Jun 2024 16:19:49 +0200 Subject: [PATCH] provider/stack: add new stack resource for Stacks API integration This PR adds a "scylladbcloud_stack" resource which is used for accounting managed resources with the Stacks API. It uses new client, as the payloads for Stacks API are HMAC signed. --- CODEOWNERS | 1 + internal/provider/cluster/cluster.go | 25 ++- internal/provider/connection/connection.go | 7 +- internal/provider/provider.go | 22 +-- internal/provider/stack/stack.go | 135 ++++++++++++++++ internal/provider/vpcpeering/vpc_peering.go | 18 ++- internal/scylla/client.go | 67 +++++--- internal/scylla/model/model.go | 6 + internal/scylla/v2/options.go | 85 ++++++++++ internal/scylla/v2/scylla.go | 163 ++++++++++++++++++++ 10 files changed, 478 insertions(+), 51 deletions(-) create mode 100644 internal/provider/stack/stack.go create mode 100644 internal/scylla/v2/options.go create mode 100644 internal/scylla/v2/scylla.go diff --git a/CODEOWNERS b/CODEOWNERS index e4e881c..e65baa1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,5 @@ /internal/provider/scylla/ @rjeczalik +/internal/provider/stack/ @rjeczalik /internal/provider/allowlistrule/ @rjeczalik /internal/provider/cluster/ @rjeczalik @charconstpointer @ksinica /internal/provider/cqlauth/ @rjeczalik diff --git a/internal/provider/cluster/cluster.go b/internal/provider/cluster/cluster.go index e013815..4855842 100644 --- a/internal/provider/cluster/cluster.go +++ b/internal/provider/cluster/cluster.go @@ -200,6 +200,11 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int nodeDiskSize, nodeDiskSizeOK = d.GetOk("node_disk_size") ) + m, err := c.Meta() + if err != nil { + return diag.Errorf("error reading metadata: %s", err) + } + if !enableVpcPeering { r.BroadcastType = "PUBLIC" } @@ -221,7 +226,7 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int _ = d.Set("cidr_block", cidr) } - p := c.Meta.ProviderByName(cloud) + p := m.ProviderByName(cloud) if p == nil { return diag.Errorf(`unrecognized value %q for "cloud" attribute`, cloud) } @@ -254,8 +259,8 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int r.InstanceID = mi.ID if !versionOK { - r.ScyllaVersionID = c.Meta.ScyllaVersions.DefaultScyllaVersionID - } else if mv := c.Meta.VersionByName(version.(string)); mv != nil { + r.ScyllaVersionID = m.ScyllaVersions.DefaultScyllaVersionID + } else if mv := m.VersionByName(version.(string)); mv != nil { r.ScyllaVersionID = mv.VersionID } else { return diag.Errorf(`unrecognized value %q for "scylla_version" attribute`, version) @@ -294,6 +299,11 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { c := meta.(*scylla.Client) + m, err := c.Meta() + if err != nil { + return diag.Errorf("error reading metadata: %s", err) + } + clusterID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { return diag.Errorf("error reading id=%q: %s", d.Id(), err) @@ -326,7 +336,7 @@ func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta inter return diag.Errorf("error reading cluster: %s", err) } - p := c.Meta.ProviderByID(cluster.CloudProviderID) + p := m.ProviderByID(cluster.CloudProviderID) if p == nil { return diag.Errorf("unexpected cloud provider ID: %d", cluster.CloudProviderID) } @@ -448,6 +458,11 @@ func resourceClusterUpgradeV0(ctx context.Context, rawState map[string]any, meta nodeType, nodeTypeOK = rawState["node_type"].(string) ) + m, err := c.Meta() + if err != nil { + return nil, fmt.Errorf("error reading metadata: %w", err) + } + if !cloudOK { return nil, fmt.Errorf(`"cloud" is undefined`) } @@ -456,7 +471,7 @@ func resourceClusterUpgradeV0(ctx context.Context, rawState map[string]any, meta return nil, fmt.Errorf(`"node_type" is undefined`) } - p := c.Meta.ProviderByName(cloud) + p := m.ProviderByName(cloud) if p == nil { return nil, fmt.Errorf(`unrecognized value %q for "cloud"`, cloud) } diff --git a/internal/provider/connection/connection.go b/internal/provider/connection/connection.go index 1900216..aa187d7 100644 --- a/internal/provider/connection/connection.go +++ b/internal/provider/connection/connection.go @@ -150,6 +150,11 @@ func resourceClusterConnectionCreate(ctx context.Context, d *schema.ResourceData p *scylla.CloudProvider ) + m, err := c.Meta() + if err != nil { + return diag.Errorf("error reading metadata: %s", err) + } + dcs, err := c.ListDataCenters(ctx, int64(clusterID)) if err != nil { return diag.Errorf("error reading clusters: %s", err) @@ -158,7 +163,7 @@ func resourceClusterConnectionCreate(ctx context.Context, d *schema.ResourceData for _, dc := range dcs { if strings.EqualFold(dc.Name, dcName) { r.ClusterDCID = dc.ID - p = c.Meta.ProviderByID(dc.CloudProviderID) + p = m.ProviderByID(dc.CloudProviderID) if p == nil { return diag.Errorf("unable to find cloud provider with id=%d", dc.CloudProviderID) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6ba3f03..cc3e1f8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -10,9 +10,8 @@ import ( "github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/connection" "github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/cqlauth" "github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/serverless" + "github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/stack" "github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/vpcpeering" - "github.com/scylladb/terraform-provider-scylladbcloud/internal/tfcontext" - "github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -52,6 +51,7 @@ func New(_ context.Context) (*schema.Provider, error) { "scylladbcloud_vpc_peering": vpcpeering.ResourceVPCPeering(), "scylladbcloud_serverless_cluster": serverless.ResourceServerlessCluster(), "scylladbcloud_cluster_connection": connection.ResourceClusterConnection(), + "scylladbcloud_stack": stack.ResourceStack(), }, } @@ -68,27 +68,11 @@ func configure(ctx context.Context, p *schema.Provider, d *schema.ResourceData) token = d.Get("token").(string) ) - c, err := scylla.NewClient() + c, err := scylla.NewClient(endpoint, token, userAgent(p.TerraformVersion)) if err != nil { return nil, diag.Errorf("could not create new Scylla client: %s", err) } - ctx = tfcontext.AddProviderInfo(ctx, endpoint) - if c.Endpoint, err = url.Parse(endpoint); err != nil { - return nil, diag.FromErr(err) - } - - if c.Meta, err = scylla.BuildCloudmeta(ctx, c); err != nil { - return nil, diag.Errorf("could not build Cloudmeta: %s", err) - } - - c.Headers.Set("Accept", "application/json; charset=utf-8") - c.Headers.Set("User-Agent", userAgent(p.TerraformVersion)) - - if err := c.Auth(ctx, token); err != nil { - return nil, diag.FromErr(err) - } - return c, nil } diff --git a/internal/provider/stack/stack.go b/internal/provider/stack/stack.go new file mode 100644 index 0000000..196fd33 --- /dev/null +++ b/internal/provider/stack/stack.go @@ -0,0 +1,135 @@ +package stack + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla" + "github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla/model" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + stackTimeout = 60 * time.Second +) + +func ResourceStack() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceStackCreate, + ReadContext: resourceStackRead, + UpdateContext: resourceStackUpdate, + DeleteContext: resourceStackDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(stackTimeout), + Update: schema.DefaultTimeout(stackTimeout), + Delete: schema.DefaultTimeout(stackTimeout), + }, + + Schema: map[string]*schema.Schema{ + "attributes": { + Description: "List of managed resources", + Required: true, + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceStackCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + c = meta.(*scylla.Client) + s = &model.Stack{ + RequestType: "Create", + ResourceProperties: d.Get("attributes").(map[string]interface{}), + } + ) + + id, err := sendStack(ctx, c, s) + if err != nil { + return diag.Errorf("failed to create stack: %s", err) + } + + d.SetId(id) + + return nil +} + +func resourceStackRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*scylla.Client) + + _ = c + + tflog.Trace(ctx, "\n\nXDDD\n\nstack read") + + return nil +} + +func resourceStackUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + c = meta.(*scylla.Client) + s = &model.Stack{ + RequestType: "Update", + ResourceProperties: d.Get("attributes").(map[string]interface{}), + } + ) + + _, err := sendStack(ctx, c, s) + if err != nil { + return diag.Errorf("failed to create stack: %s", err) + } + + return nil +} + +func resourceStackDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ( + c = meta.(*scylla.Client) + s = &model.Stack{ + RequestType: "Delete", + ResourceProperties: d.Get("attributes").(map[string]interface{}), + } + ) + + _, err := sendStack(ctx, c, s) + if err != nil { + return diag.Errorf("failed to create stack: %s", err) + } + + return nil +} + +func sendStack(ctx context.Context, c *scylla.Client, s *model.Stack) (string, error) { + auth := strings.Split(c.Token, ":") + + if len(auth) != 2 { + return "", errors.New("invalid token format") + } + + req := c.V2.Request(ctx, "POST", s, "/") + + req.Header.Set("X-Scylla-Cloud-Stack-Flavor", "tf") + + if err := c.V2.BasicSign(req, auth[0], []byte(auth[1])); err != nil { + return "", fmt.Errorf("failed to sign request: %w", err) + } + + if _, err := c.V2.Do(req, s); err != nil { + return "", fmt.Errorf("failed to create stack: %w", err) + } + + return auth[0], nil +} diff --git a/internal/provider/vpcpeering/vpc_peering.go b/internal/provider/vpcpeering/vpc_peering.go index 647083a..09e8e5b 100644 --- a/internal/provider/vpcpeering/vpc_peering.go +++ b/internal/provider/vpcpeering/vpc_peering.go @@ -133,6 +133,11 @@ func resourceVPCPeeringCreate(ctx context.Context, d *schema.ResourceData, meta dc *model.Datacenter ) + m, err := c.Meta() + if err != nil { + return diag.Errorf("error reading metadata: %s", err) + } + dcs, err := c.ListDataCenters(ctx, int64(clusterID)) if err != nil { return diag.Errorf("error reading clusters: %s", err) @@ -143,7 +148,7 @@ func resourceVPCPeeringCreate(ctx context.Context, d *schema.ResourceData, meta if strings.EqualFold(dc.Name, dcName) { r.DatacenterID = dc.ID - p = c.Meta.ProviderByID(dc.CloudProviderID) + p = m.ProviderByID(dc.CloudProviderID) break } } @@ -167,14 +172,14 @@ func resourceVPCPeeringCreate(ctx context.Context, d *schema.ResourceData, meta return diag.Errorf(`"peer_cidr_blocks" is required for %q cloud`, p.CloudProvider.Name) } - cidr, ok := c.Meta.GCPBlocks[pr] + cidr, ok := m.GCPBlocks[pr] if !ok { return diag.Errorf("no default peer CIDR block found for %q region", pr) } cidrBlocks = []any{cidr} } else if strings.EqualFold(p.CloudProvider.Name, "GCP") { - cidr, ok := c.Meta.GCPBlocks[pr] + cidr, ok := m.GCPBlocks[pr] if !ok { return diag.Errorf("no default peer CIDR block found for %q region", pr) } @@ -226,6 +231,11 @@ func resourceVPCPeeringRead(ctx context.Context, d *schema.ResourceData, meta in p *scylla.CloudProvider ) + m, err := c.Meta() + if err != nil { + return diag.Errorf("error reading metadata: %s", err) + } + clusters, err := c.ListClusters(ctx) if err != nil { return diag.Errorf("error reading cluster list: %s", err) @@ -261,7 +271,7 @@ lookup: return nil } - if p = c.Meta.ProviderByID(cluster.CloudProviderID); p == nil { + if p = m.ProviderByID(cluster.CloudProviderID); p == nil { return diag.Errorf("unable to find cloud provider with id=%d", cluster.CloudProviderID) } diff --git a/internal/scylla/client.go b/internal/scylla/client.go index eefe85c..da781a0 100644 --- a/internal/scylla/client.go +++ b/internal/scylla/client.go @@ -10,12 +10,14 @@ import ( "net/http" "net/url" stdpath "path" + "sync" "time" - "github.com/hashicorp/terraform-plugin-log/tflog" + v2scylla "github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla/v2" "github.com/scylladb/terraform-provider-scylladbcloud/internal/tfcontext" "github.com/eapache/go-resiliency/retrier" + "github.com/hashicorp/terraform-plugin-log/tflog" ) const ( @@ -26,7 +28,7 @@ const ( // Client represents a client to call the Scylla Cloud API type Client struct { - Meta *Cloudmeta + Meta func() (*Cloudmeta, error) // Headers holds headers that will be set for all http requests. Headers http.Header @@ -34,6 +36,9 @@ type Client struct { // API endpoint Endpoint *url.URL + // Token is the API token used to authenticate requests. + Token string + // ErrCodes provides a human-readable translation of ScyllaDB API errors. ErrCodes map[string]string // code -> message @@ -44,52 +49,70 @@ type Client struct { // AccountID holds the account ID used in requests to the API. AccountID int64 - Retry *retrier.Retrier // Retrier is used to retry requests to the API. + // Retry is used to retry requests to the API. + Retry *retrier.Retrier + + // V2 is the client to call the V2 API, it does not require costly + // metadata building. + V2 *v2scylla.Client } -func NewClient() (*Client, error) { +func NewClient(endpoint, token, useragent string) (*Client, error) { errCodes, err := parse(codes, codesDelim, codesFunc) if err != nil { return nil, fmt.Errorf("failed to parse error codes: %w", err) } + end, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + + ctx := context.Background() + retry := retrier.New( + retrier.ExponentialBackoff(5, 5*time.Second), + DefaultClassifier, + ) + c := &Client{ + Token: token, ErrCodes: errCodes, Headers: make(http.Header), HTTPClient: http.DefaultClient, - Retry: retrier.New( - retrier.ExponentialBackoff(5, 5*time.Second), - DefaultClassifier, + Retry: retry, + Endpoint: end, + V2: v2scylla.New( + v2scylla.WithRetryPolicy(retry), + v2scylla.WithUserAgent(useragent), + v2scylla.WithBaseURL(endpoint), + v2scylla.WithGlobalCookieJar(), ), } + c.Headers.Set("Authorization", "Bearer "+c.Token) + c.Headers.Set("Accept", "application/json; charset=utf-8") + c.Headers.Set("User-Agent", useragent) + + c.Meta = sync.OnceValues(func() (*Cloudmeta, error) { return c.init(ctx) }) + return c, nil } -// NewClient represents a new client to call the API -func (c *Client) Auth(ctx context.Context, token string) error { +func (c *Client) init(ctx context.Context) (*Cloudmeta, error) { if c.HTTPClient == nil { c.HTTPClient = &http.Client{Timeout: defaultTimeout} } - if c.Headers == nil { - c.Headers = make(http.Header) - } - - c.Headers.Set("Authorization", "Bearer "+token) - - if c.Meta == nil { - var err error - if c.Meta, err = BuildCloudmeta(ctx, c); err != nil { - return fmt.Errorf("error building metadata: %w", err) - } + meta, err := BuildCloudmeta(ctx, c) + if err != nil { + return nil, fmt.Errorf("error building metadata: %w", err) } if err := c.findAndSaveAccountID(ctx); err != nil { - return err + return nil, err } - return nil + return meta, nil } func (c *Client) newHttpRequest(ctx context.Context, method, path string, reqBody interface{}, query ...string) (*http.Request, error) { diff --git a/internal/scylla/model/model.go b/internal/scylla/model/model.go index 09574c5..05b1d09 100644 --- a/internal/scylla/model/model.go +++ b/internal/scylla/model/model.go @@ -331,3 +331,9 @@ func NodesDNSNames(n []Node) []string { type Datacenters struct { Datacenters []Datacenter `json:"dataCenters"` } + +type Stack struct { + RequestType string `json:"RequestType"` + RequestID string `json:"RequestId"` + ResourceProperties map[string]any `json:"ResourceProperties"` +} diff --git a/internal/scylla/v2/options.go b/internal/scylla/v2/options.go new file mode 100644 index 0000000..0bbc26e --- /dev/null +++ b/internal/scylla/v2/options.go @@ -0,0 +1,85 @@ +package scylla + +import ( + "net/http" + "net/http/cookiejar" + "net/url" + "path" + + "github.com/eapache/go-resiliency/retrier" + "golang.org/x/net/publicsuffix" +) + +var globalCookieJar *cookiejar.Jar + +func init() { + var err error + globalCookieJar, err = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + panic("unexpected error: " + err.Error()) + } +} + +func WithRetryPolicy(r *retrier.Retrier) func(*Client) { + return func(c *Client) { + c.retry = r + } +} + +func WithUserAgent(s string) func(*Client) { + return func(c *Client) { + c.reqmw = append(c.reqmw, func(r *http.Request) { + r.Header.Set("User-Agent", s) + }) + } +} + +func WithGlobalCookieJar() func(*Client) { + return func(c *Client) { + c.client.Jar = globalCookieJar + } +} + +func WithCookieJar() func(*Client) { + return func(c *Client) { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + panic("unexpected error: " + err.Error()) + } + c.client.Jar = jar + } +} + +func WithBaseURL(s string) func(*Client) { + return func(c *Client) { + if s == "" { + return + } + u, err := url.Parse(s) + if err != nil { + panic("unexpected error: " + err.Error()) + } + c.reqmw = append(c.reqmw, func(r *http.Request) { + if u.Scheme != "" { + r.URL.Scheme = u.Scheme + } + if u.Host != "" { + r.URL.Host = u.Host + } + if u.Path != "" { + r.URL.Path = path.Join("/", u.Path, r.URL.Path) + } + if q := u.Query(); len(q) != 0 { + orig := r.URL.Query() + for k, v := range q { + orig[k] = v + } + r.URL.RawQuery = orig.Encode() + } + }) + } +} diff --git a/internal/scylla/v2/scylla.go b/internal/scylla/v2/scylla.go new file mode 100644 index 0000000..ce122ac --- /dev/null +++ b/internal/scylla/v2/scylla.go @@ -0,0 +1,163 @@ +package scylla + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/eapache/go-resiliency/retrier" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type Client struct { + reqmw []func(*http.Request) + client *http.Client + retry *retrier.Retrier +} + +func New(opts ...func(*Client)) *Client { + return (&Client{ + client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + }).With(opts...) +} + +func (c *Client) With(opts ...func(*Client)) *Client { + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Client) Request(ctx context.Context, method string, payload interface{}, format string, args ...interface{}) *http.Request { + var body io.Reader + if payload != nil { + p, err := json.Marshal(payload) + if err != nil { + panic("unexpected error marshaling payload: " + err.Error()) + } + body = bytes.NewReader(p) + } + + req, err := http.NewRequestWithContext(ctx, method, buildURL(format, args...), body) + if err != nil { + panic("unexpected error creating request: " + err.Error()) + } + + for _, mw := range c.reqmw { + mw(req) + } + + req.Header.Set("Accept", "application/json") + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req +} + +func (c *Client) Do(req *http.Request, out interface{}) (*http.Response, error) { + var ( + resp *http.Response + attempt int + ) + + err := c.retry.RunCtx(req.Context(), func(ctx context.Context) (err error) { + if attempt++; attempt > 1 { + if req.Body != http.NoBody && req.GetBody != nil { + req.Body, err = req.GetBody() + if err != nil { + return fmt.Errorf("failed to get request body: %w", err) + } + } + } + + tflog.Trace(ctx, "api call", map[string]interface{}{ + "attempt": attempt, + "method": req.Method, + "url": req.URL.String(), + }) + + resp, err = c.client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode >= 300 { + p, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, p) + } + + if out == nil { + return nil + } + + if p, ok := out.(*[]byte); ok { + *p, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading body failed: %w", err) + } + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) + }) + + return resp, err +} + +func (c *Client) BasicSign(req *http.Request, user string, secret []byte) error { + mac := hmac.New(sha256.New, secret) + + if req.Body == nil || req.Body == http.NoBody { + return errors.New("request body is empty") + } + + if req.GetBody == nil { + return errors.New("GetBody is nil, unable to rewind") + } + + n, err := io.Copy(mac, req.Body) + if n == 0 { + return fmt.Errorf("error signing request body: body is empty") + } + if err != nil { + return fmt.Errorf("error signing request body: %w", err) + } + + digest := "v1." + hex.EncodeToString(mac.Sum(nil)) + + req.SetBasicAuth(user, digest) + + req.Body, err = req.GetBody() + if err != nil { + return fmt.Errorf("error rewinding request body: %w", err) + } + + return nil +} + +func buildURL(format string, args ...any) string { + u, err := url.Parse(fmt.Sprintf(format, args...)) + if err != nil { + panic("unexpected error creating request: " + err.Error()) + } + u.Path = strings.TrimRight(path.Join("/", u.Path), "/") + return u.String() +}