Skip to content

Commit b305000

Browse files
committed
feat: implement checkout config data sync
0 parents  commit b305000

14 files changed

Lines changed: 2583 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/commercetools/checkout-cloud-sync/internal/config"
10+
"github.com/commercetools/checkout-cloud-sync/internal/sync"
11+
)
12+
13+
var (
14+
configFile string
15+
full bool
16+
)
17+
18+
var rootCmd = &cobra.Command{
19+
Use: "checkout-cloud-sync",
20+
Short: "Migrate commercetools Checkout resources between cloud providers",
21+
Long: `checkout-cloud-sync migrates commercetools Checkout resources
22+
(applications and payment-integrations) from a source project to a target project.
23+
24+
Without -f, runs in dry-run mode and prints what would be migrated.
25+
With -f, performs the actual migration of both applications and payment-integrations.
26+
27+
Example:
28+
checkout-cloud-sync -c config.yml # dry-run
29+
checkout-cloud-sync -c config.yml -f # execute migration`,
30+
SilenceUsage: true,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
cfg, err := config.Load(configFile)
33+
if err != nil {
34+
return fmt.Errorf("loading config: %w", err)
35+
}
36+
37+
syncer, err := sync.New(cfg, os.Stdout)
38+
if err != nil {
39+
return fmt.Errorf("initializing syncer: %w", err)
40+
}
41+
42+
return syncer.Sync(cmd.Context(), full)
43+
},
44+
}
45+
46+
func init() {
47+
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "config.yml", "Path to config file")
48+
rootCmd.Flags().BoolVarP(&full, "full", "f", false, "Execute migration (omit for dry-run)")
49+
}
50+
51+
// Execute runs the CLI.
52+
func Execute() {
53+
if err := rootCmd.Execute(); err != nil {
54+
fmt.Fprintln(os.Stderr, err)
55+
os.Exit(1)
56+
}
57+
}

config.example.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
source:
2+
project_key: ""
3+
client_id: ""
4+
client_secret: ""
5+
# OAuth token endpoint (Composable Commerce), not the Checkout API host.
6+
auth_url: "https://auth.europe-west1.gcp.commercetools.com/oauth/token"
7+
# Prefer explicit Checkout API host.
8+
checkout_api_url: "https://checkout.europe-west1.gcp.commercetools.com"
9+
scopes: ""
10+
11+
target:
12+
project_key: ""
13+
client_id: ""
14+
client_secret: ""
15+
auth_url: "https://auth.eu-central-1.aws.commercetools.com/oauth/token"
16+
checkout_api_url: "https://checkout.eu-central-1.aws.commercetools.com"
17+
scopes: ""
18+
19+
# Map source connector deployment UUIDs to target deployment UUIDs when IDs differ between clouds.
20+
# deployment_mapping:
21+
# "source-deployment-uuid": "target-deployment-uuid"
22+
deployment_mapping:
23+
"7da2cbeb-d00d-40d3-ab29-5f23f1bdcb21": "5e20cfe4-e849-4abe-b600-c8f569519138"

go.mod

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module github.com/commercetools/checkout-cloud-sync
2+
3+
go 1.21
4+
5+
require (
6+
github.com/spf13/cobra v1.8.0
7+
gopkg.in/yaml.v3 v3.0.1
8+
)
9+
10+
require (
11+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
12+
github.com/spf13/pflag v1.0.5 // indirect
13+
)

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
2+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5+
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
6+
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
7+
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
8+
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
12+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/client/client.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Package client provides an authenticated HTTP client for the commercetools Checkout API.
2+
package client
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"strings"
14+
"time"
15+
16+
"github.com/commercetools/checkout-cloud-sync/internal/config"
17+
)
18+
19+
// Client is an authenticated Checkout API client for a single project.
20+
type Client struct {
21+
http *http.Client
22+
cfg config.ProjectConfig
23+
accessToken string
24+
tokenExpiry time.Time
25+
}
26+
27+
type tokenResponse struct {
28+
AccessToken string `json:"access_token"`
29+
ExpiresIn int `json:"expires_in"`
30+
}
31+
32+
// ctError is the standard commercetools error envelope.
33+
type ctError struct {
34+
Message string `json:"message"`
35+
Errors []struct {
36+
Code string `json:"code"`
37+
Message string `json:"message"`
38+
} `json:"errors"`
39+
}
40+
41+
// HTTPError represents an unsuccessful HTTP response from the Checkout API.
42+
// Callers can use errors.As to inspect the status code rather than parsing
43+
// the error string.
44+
type HTTPError struct {
45+
Method string
46+
Path string
47+
StatusCode int
48+
Message string
49+
}
50+
51+
func (e *HTTPError) Error() string {
52+
return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Message)
53+
}
54+
55+
// New creates a Client and eagerly fetches an OAuth2 client-credentials token.
56+
func New(cfg config.ProjectConfig) (*Client, error) {
57+
c := &Client{
58+
http: &http.Client{Timeout: 30 * time.Second},
59+
cfg: cfg,
60+
}
61+
if err := c.fetchToken(context.Background()); err != nil {
62+
return nil, fmt.Errorf("authenticating against %s: %w", cfg.AuthURL, err)
63+
}
64+
return c, nil
65+
}
66+
67+
func (c *Client) fetchToken(ctx context.Context) error {
68+
form := url.Values{"grant_type": {"client_credentials"}}
69+
if c.cfg.Scopes != "" {
70+
form.Set("scope", c.cfg.Scopes)
71+
}
72+
73+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.AuthURL, strings.NewReader(form.Encode()))
74+
if err != nil {
75+
return err
76+
}
77+
creds := base64.StdEncoding.EncodeToString([]byte(c.cfg.ClientID + ":" + c.cfg.ClientSecret))
78+
req.Header.Set("Authorization", "Basic "+creds)
79+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
80+
81+
resp, err := c.http.Do(req)
82+
if err != nil {
83+
return fmt.Errorf("token request failed: %w", err)
84+
}
85+
defer resp.Body.Close()
86+
87+
body, _ := io.ReadAll(resp.Body)
88+
if resp.StatusCode != http.StatusOK {
89+
return fmt.Errorf("token endpoint returned HTTP %d: %s", resp.StatusCode, body)
90+
}
91+
92+
var t tokenResponse
93+
if err := json.Unmarshal(body, &t); err != nil {
94+
return fmt.Errorf("parsing token response: %w", err)
95+
}
96+
c.accessToken = t.AccessToken
97+
// Refresh 30 seconds before the token expires to avoid races.
98+
c.tokenExpiry = time.Now().Add(time.Duration(t.ExpiresIn-30) * time.Second)
99+
return nil
100+
}
101+
102+
func (c *Client) ensureToken(ctx context.Context) error {
103+
if time.Now().Before(c.tokenExpiry) {
104+
return nil
105+
}
106+
return c.fetchToken(ctx)
107+
}
108+
109+
// baseURL returns the project-scoped API root, e.g. https://checkout.example.com/my-project.
110+
func (c *Client) baseURL() string {
111+
return strings.TrimRight(c.cfg.CheckoutAPIURL, "/") + "/" + c.cfg.ProjectKey
112+
}
113+
114+
// Get performs a GET and decodes the JSON response into result.
115+
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
116+
return c.do(ctx, http.MethodGet, path, nil, result, http.StatusOK)
117+
}
118+
119+
// Post performs a POST with a JSON body and decodes the response into result (may be nil).
120+
func (c *Client) Post(ctx context.Context, path string, reqBody interface{}, result interface{}) error {
121+
return c.do(ctx, http.MethodPost, path, reqBody, result, http.StatusOK, http.StatusCreated)
122+
}
123+
124+
// Delete performs a DELETE.
125+
func (c *Client) Delete(ctx context.Context, path string) error {
126+
return c.do(ctx, http.MethodDelete, path, nil, nil, http.StatusOK, http.StatusNoContent)
127+
}
128+
129+
func (c *Client) do(ctx context.Context, method, path string, body interface{}, result interface{}, acceptStatus ...int) error {
130+
if err := c.ensureToken(ctx); err != nil {
131+
return err
132+
}
133+
134+
var reqBody io.Reader
135+
if body != nil {
136+
b, err := json.Marshal(body)
137+
if err != nil {
138+
return fmt.Errorf("marshaling request body: %w", err)
139+
}
140+
reqBody = bytes.NewReader(b)
141+
}
142+
143+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, reqBody)
144+
if err != nil {
145+
return err
146+
}
147+
req.Header.Set("Authorization", "Bearer "+c.accessToken)
148+
if body != nil {
149+
req.Header.Set("Content-Type", "application/json")
150+
}
151+
152+
resp, err := c.http.Do(req)
153+
if err != nil {
154+
return fmt.Errorf("%s %s: %w", method, path, err)
155+
}
156+
defer resp.Body.Close()
157+
158+
respBody, _ := io.ReadAll(resp.Body)
159+
160+
for _, s := range acceptStatus {
161+
if resp.StatusCode == s {
162+
if result != nil {
163+
return json.Unmarshal(respBody, result)
164+
}
165+
return nil
166+
}
167+
}
168+
169+
// Surface the commercetools error message when available.
170+
msg := string(respBody)
171+
var ctErr ctError
172+
if json.Unmarshal(respBody, &ctErr) == nil && ctErr.Message != "" {
173+
msg = ctErr.Message
174+
}
175+
return &HTTPError{Method: method, Path: path, StatusCode: resp.StatusCode, Message: msg}
176+
}

0 commit comments

Comments
 (0)