diff --git a/Makefile b/Makefile index 0e506cb..9bc2f40 100644 --- a/Makefile +++ b/Makefile @@ -5,18 +5,22 @@ LDFLAGS = -X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.Version=$(VERSIO -X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.GitCommit=$(COMMIT) \ -X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.BuildDate=$(DATE) -.PHONY: build build: go build -ldflags "$(LDFLAGS)" -o bin/ctrlc ./cmd/ctrlc -.PHONY: install install: go install -ldflags "$(LDFLAGS)" ./cmd/ctrlc -.PHONY: test test: go test -v ./... -.PHONY: clean clean: - rm -rf bin/ \ No newline at end of file + rm -rf bin/ + +lint: + golangci-lint run ./... + +format: + go fmt ./... + +.PHONY: build install test clean lint format \ No newline at end of file diff --git a/cmd/ctrlc/root/sync/salesforce/README.md b/cmd/ctrlc/root/sync/salesforce/README.md new file mode 100644 index 0000000..8467e4b --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/README.md @@ -0,0 +1,137 @@ +# Salesforce Sync + +Sync Salesforce CRM data (Accounts, Opportunities) into Ctrlplane as resources. + +## Quick Start + +```bash +# Set credentials (via environment or flags) +export SALESFORCE_DOMAIN="https://mycompany.my.salesforce.com" +export SALESFORCE_CONSUMER_KEY="your-key" +export SALESFORCE_CONSUMER_SECRET="your-secret" + +# Sync all accounts +ctrlc sync salesforce accounts + +# Sync opportunities with filters +ctrlc sync salesforce opportunities --where="IsWon = true AND Amount > 50000" + +# Map custom fields to metadata +ctrlc sync salesforce accounts \ + --metadata="account/tier=Tier__c" \ + --metadata="account/health=Customer_Health__c" +``` + +## Authentication + +Requires Salesforce OAuth2 credentials from a Connected App with `api` and `refresh_token` scopes. + +Credentials can be provided via: +- Environment variables: `SALESFORCE_DOMAIN`, `SALESFORCE_CONSUMER_KEY`, `SALESFORCE_CONSUMER_SECRET` +- Command flags: `--salesforce-domain`, `--salesforce-consumer-key`, `--salesforce-consumer-secret` + +## Common Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--provider`, `-p` | Resource provider name | Auto-generated from domain | +| `--metadata` | Map Salesforce fields to metadata | Built-in defaults | +| `--where` | SOQL WHERE clause filter | None | +| `--limit` | Maximum records to sync | 0 (no limit) | +| `--list-all-fields` | Log available Salesforce fields | false | + +## Metadata Mappings + +Map any Salesforce field (including custom fields) to Ctrlplane metadata: + +```bash +# Format: metadata-key=SalesforceFieldName +--metadata="account/tier=Tier__c" +--metadata="opportunity/stage-custom=Custom_Stage__c" +``` + +- Custom fields typically end with `__c` +- Use `--list-all-fields` to discover available fields +- All metadata values are stored as strings + +## Resource Examples + +### Account Resource +```json +{ + "version": "ctrlplane.dev/crm/account/v1", + "kind": "SalesforceAccount", + "name": "Acme Corporation", + "identifier": "001XX000003DHPh", + "config": { + "name": "Acme Corporation", + "type": "Customer", + "salesforceAccount": { + "recordId": "001XX000003DHPh", + "ownerId": "005XX000001SvogAAC", + // ... address, dates, etc. + } + }, + "metadata": { + "account/id": "001XX000003DHPh", + "account/type": "Customer", + // Custom fields from --metadata + "account/tier": "Enterprise" + } +} +``` + +### Opportunity Resource +```json +{ + "version": "ctrlplane.dev/crm/opportunity/v1", + "kind": "SalesforceOpportunity", + "name": "Enterprise Deal", + "identifier": "006XX000003DHPh", + "config": { + "amount": 250000, + "stage": "Negotiation", + "salesforceOpportunity": { + "recordId": "006XX000003DHPh", + "accountId": "001XX000003DHPh", + // ... dates, fiscal info, etc. + } + }, + "metadata": { + "opportunity/amount": "250000", + "opportunity/stage": "Negotiation" + } +} +``` + +## Advanced Usage + +### Filtering with SOQL + +```bash +# Complex account filters +ctrlc sync salesforce accounts \ + --where="Type = 'Customer' AND AnnualRevenue > 1000000" + +# Filter opportunities by custom fields +ctrlc sync salesforce opportunities \ + --where="Custom_Field__c != null AND Stage = 'Closed Won'" +``` + +### Pagination + +- Automatically handles large datasets with ID-based pagination +- Fetches up to 1000 records per API call +- Use `--limit` to restrict total records synced + +### Default Provider Names + +If no `--provider` is specified, names are auto-generated from your Salesforce subdomain: +- `https://acme.my.salesforce.com` → `acme-salesforce-accounts` + +## Implementation Notes + +- Uses `map[string]any` for Salesforce's dynamic schema +- Null values are omitted from resources +- Numbers and booleans are preserved in config, converted to strings in metadata +- Dates are formatted to RFC3339 where applicable \ No newline at end of file diff --git a/cmd/ctrlc/root/sync/salesforce/accounts/accounts.go b/cmd/ctrlc/root/sync/salesforce/accounts/accounts.go new file mode 100644 index 0000000..2fec776 --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/accounts/accounts.go @@ -0,0 +1,193 @@ +package accounts + +import ( + "context" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/common" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/k-capehart/go-salesforce/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewSalesforceAccountsCmd() *cobra.Command { + var name string + var metadataMappings map[string]string + var limit int + var listAllFields bool + var whereClause string + + cmd := &cobra.Command{ + Use: "accounts", + Short: "Sync Salesforce accounts into Ctrlplane", + Example: heredoc.Doc(` + # Sync all Salesforce accounts + $ ctrlc sync salesforce accounts \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" + + # Sync accounts with a specific filter + $ ctrlc sync salesforce accounts --where="Customer_Health__c != null" + + # Sync accounts and list all available fields in logs + $ ctrlc sync salesforce accounts --list-all-fields + + # Sync accounts with custom provider name + $ ctrlc sync salesforce accounts --provider my-salesforce + + # Sync with custom metadata mappings + $ ctrlc sync salesforce accounts \ + --metadata="account/id=Id" \ + --metadata="account/owner-id=OwnerId" \ + --metadata="account/tier=Tier__c" \ + --metadata="account/region=Region__c" \ + --metadata="account/annual-revenue=Annual_Revenue__c" \ + --metadata="account/health=Customer_Health__c" + + # Sync with a limit on number of records + $ ctrlc sync salesforce accounts --limit 500 + + # Combine filters with metadata mappings + $ ctrlc sync salesforce accounts \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" \ + --where="Type = 'Customer' AND AnnualRevenue > 1000000" \ + --metadata="account/revenue=AnnualRevenue" + `), + RunE: func(cmd *cobra.Command, args []string) error { + domain := viper.GetString("salesforce-domain") + consumerKey := viper.GetString("salesforce-consumer-key") + consumerSecret := viper.GetString("salesforce-consumer-secret") + + log.Info("Syncing Salesforce accounts into Ctrlplane", "domain", domain) + + ctx := context.Background() + + sf, err := common.InitSalesforceClient(domain, consumerKey, consumerSecret) + if err != nil { + return err + } + + resources, err := processAccounts(ctx, sf, metadataMappings, limit, listAllFields, whereClause) + if err != nil { + return err + } + + if name == "" { + subdomain := common.GetSalesforceSubdomain(domain) + name = fmt.Sprintf("%s-salesforce-accounts", subdomain) + } + + return common.UpsertToCtrlplane(ctx, resources, name) + }, + } + + cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider") + cmd.Flags().StringToStringVar(&metadataMappings, "metadata", map[string]string{}, "Custom metadata mappings (format: metadata/key=SalesforceField)") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of records to sync (0 = no limit)") + cmd.Flags().BoolVar(&listAllFields, "list-all-fields", false, "List all available Salesforce fields in the logs") + cmd.Flags().StringVar(&whereClause, "where", "", "SOQL WHERE clause to filter records (e.g., \"Customer_Health__c != null\")") + + return cmd +} + +func processAccounts(ctx context.Context, sf *salesforce.Salesforce, metadataMappings map[string]string, limit int, listAllFields bool, whereClause string) ([]api.CreateResource, error) { + additionalFields := make([]string, 0, len(metadataMappings)) + for _, fieldName := range metadataMappings { + additionalFields = append(additionalFields, fieldName) + } + + var accounts []map[string]any + err := common.QuerySalesforceObject(ctx, sf, "Account", limit, listAllFields, &accounts, additionalFields, whereClause) + if err != nil { + return nil, err + } + + log.Info("Found Salesforce accounts", "count", len(accounts)) + + resources := []api.CreateResource{} + for _, account := range accounts { + resource := transformAccountToResource(account, metadataMappings) + resources = append(resources, resource) + } + + return resources, nil +} + +func transformAccountToResource(account map[string]any, metadataMappings map[string]string) api.CreateResource { + metadata := map[string]string{} + common.AddToMetadata(metadata, "account/id", account["Id"]) + common.AddToMetadata(metadata, "account/owner-id", account["OwnerId"]) + common.AddToMetadata(metadata, "account/industry", account["Industry"]) + common.AddToMetadata(metadata, "account/billing-city", account["BillingCity"]) + common.AddToMetadata(metadata, "account/billing-state", account["BillingState"]) + common.AddToMetadata(metadata, "account/billing-country", account["BillingCountry"]) + common.AddToMetadata(metadata, "account/website", account["Website"]) + common.AddToMetadata(metadata, "account/phone", account["Phone"]) + common.AddToMetadata(metadata, "account/type", account["Type"]) + common.AddToMetadata(metadata, "account/source", account["AccountSource"]) + common.AddToMetadata(metadata, "account/shipping-city", account["ShippingCity"]) + common.AddToMetadata(metadata, "account/parent-id", account["ParentId"]) + common.AddToMetadata(metadata, "account/employees", account["NumberOfEmployees"]) + + for metadataKey, fieldName := range metadataMappings { + if value, exists := account[fieldName]; exists { + common.AddToMetadata(metadata, metadataKey, value) + } + } + + config := map[string]interface{}{ + "name": fmt.Sprintf("%v", account["Name"]), + "industry": fmt.Sprintf("%v", account["Industry"]), + "id": fmt.Sprintf("%v", account["Id"]), + "type": fmt.Sprintf("%v", account["Type"]), + "phone": fmt.Sprintf("%v", account["Phone"]), + "website": fmt.Sprintf("%v", account["Website"]), + + "salesforceAccount": map[string]interface{}{ + "recordId": fmt.Sprintf("%v", account["Id"]), + "ownerId": fmt.Sprintf("%v", account["OwnerId"]), + "parentId": fmt.Sprintf("%v", account["ParentId"]), + "type": fmt.Sprintf("%v", account["Type"]), + "accountSource": fmt.Sprintf("%v", account["AccountSource"]), + "numberOfEmployees": account["NumberOfEmployees"], + "description": fmt.Sprintf("%v", account["Description"]), + "billingAddress": map[string]interface{}{ + "street": fmt.Sprintf("%v", account["BillingStreet"]), + "city": fmt.Sprintf("%v", account["BillingCity"]), + "state": fmt.Sprintf("%v", account["BillingState"]), + "postalCode": fmt.Sprintf("%v", account["BillingPostalCode"]), + "country": fmt.Sprintf("%v", account["BillingCountry"]), + "latitude": account["BillingLatitude"], + "longitude": account["BillingLongitude"], + }, + "shippingAddress": map[string]interface{}{ + "street": fmt.Sprintf("%v", account["ShippingStreet"]), + "city": fmt.Sprintf("%v", account["ShippingCity"]), + "state": fmt.Sprintf("%v", account["ShippingState"]), + "postalCode": fmt.Sprintf("%v", account["ShippingPostalCode"]), + "country": fmt.Sprintf("%v", account["ShippingCountry"]), + "latitude": account["ShippingLatitude"], + "longitude": account["ShippingLongitude"], + }, + "createdDate": fmt.Sprintf("%v", account["CreatedDate"]), + "lastModifiedDate": fmt.Sprintf("%v", account["LastModifiedDate"]), + "isDeleted": account["IsDeleted"], + "photoUrl": fmt.Sprintf("%v", account["PhotoUrl"]), + }, + } + + return api.CreateResource{ + Version: "ctrlplane.dev/crm/account/v1", + Kind: "SalesforceAccount", + Name: fmt.Sprintf("%v", account["Name"]), + Identifier: fmt.Sprintf("%v", account["Id"]), + Config: config, + Metadata: metadata, + } +} diff --git a/cmd/ctrlc/root/sync/salesforce/common/client.go b/cmd/ctrlc/root/sync/salesforce/common/client.go new file mode 100644 index 0000000..85128ca --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/common/client.go @@ -0,0 +1,19 @@ +package common + +import ( + "fmt" + + "github.com/k-capehart/go-salesforce/v2" +) + +func InitSalesforceClient(domain, consumerKey, consumerSecret string) (*salesforce.Salesforce, error) { + sf, err := salesforce.Init(salesforce.Creds{ + Domain: domain, + ConsumerKey: consumerKey, + ConsumerSecret: consumerSecret, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Salesforce client: %w", err) + } + return sf, nil +} diff --git a/cmd/ctrlc/root/sync/salesforce/common/util.go b/cmd/ctrlc/root/sync/salesforce/common/util.go new file mode 100644 index 0000000..83a87ef --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/common/util.go @@ -0,0 +1,273 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "reflect" + "strings" + + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/k-capehart/go-salesforce/v2" + "github.com/spf13/viper" +) + +func GetSalesforceSubdomain(domain string) string { + subdomain := "salesforce" + if strings.HasPrefix(domain, "https://") || strings.HasPrefix(domain, "http://") { + parts := strings.Split(domain, "//") + if len(parts) > 1 { + hostParts := strings.Split(parts[1], ".") + if len(hostParts) > 0 { + subdomain = hostParts[0] + } + } + } + return subdomain +} + +// QuerySalesforceObject performs a generic query on any Salesforce object with pagination support +func QuerySalesforceObject(ctx context.Context, sf *salesforce.Salesforce, objectName string, limit int, listAllFields bool, target interface{}, additionalFields []string, whereClause string) error { + targetValue := reflect.ValueOf(target).Elem() + if targetValue.Kind() != reflect.Slice { + return fmt.Errorf("target must be a pointer to a slice") + } + + fieldMap := make(map[string]bool) + + var standardFields []string + switch objectName { + case "Account": + standardFields = []string{ + "Id", "Name", "Type", "Industry", "Website", "Phone", + "BillingStreet", "BillingCity", "BillingState", "BillingPostalCode", "BillingCountry", + "BillingLatitude", "BillingLongitude", + "ShippingStreet", "ShippingCity", "ShippingState", "ShippingPostalCode", "ShippingCountry", + "ShippingLatitude", "ShippingLongitude", + "NumberOfEmployees", "Description", "OwnerId", "ParentId", "AccountSource", + "CreatedDate", "LastModifiedDate", "IsDeleted", "PhotoUrl", + } + case "Opportunity": + standardFields = []string{ + "Id", "Name", "Amount", "StageName", "CloseDate", "AccountId", + "Probability", "Type", "NextStep", "LeadSource", "IsClosed", "IsWon", + "ForecastCategory", "Description", "OwnerId", "ContactId", "CampaignId", + "HasOpenActivity", "CreatedDate", "LastModifiedDate", "LastActivityDate", + } + default: + standardFields = []string{"Id", "Name", "CreatedDate", "LastModifiedDate"} + } + + for _, field := range standardFields { + fieldMap[field] = true + } + + for _, field := range additionalFields { + fieldMap[field] = true + } + + fieldNames := make([]string, 0, len(fieldMap)) + for field := range fieldMap { + fieldNames = append(fieldNames, field) + } + + if listAllFields { + if err := logAvailableFields(sf, objectName); err != nil { + return err + } + } + + return paginateQuery(ctx, sf, objectName, fieldNames, whereClause, limit, targetValue) +} + +func logAvailableFields(sf *salesforce.Salesforce, objectName string) error { + resp, err := sf.DoRequest("GET", fmt.Sprintf("/sobjects/%s/describe", objectName), nil) + if err != nil { + return fmt.Errorf("failed to describe %s object: %w", objectName, err) + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode describe response: %w", err) + } + + fields, ok := result["fields"].([]interface{}) + if !ok { + return fmt.Errorf("unexpected describe response format") + } + + fieldNames := make([]string, 0, len(fields)) + for _, field := range fields { + if fieldMap, ok := field.(map[string]interface{}); ok { + if name, ok := fieldMap["name"].(string); ok { + fieldNames = append(fieldNames, name) + } + } + } + + log.Info("Available fields", "object", objectName, "count", len(fieldNames), "fields", fieldNames) + return nil +} + +// buildSOQL constructs a SOQL query with pagination +func buildSOQL(objectName string, fields []string, whereClause string, lastId string, limit int) string { + query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(fields, ", "), objectName) + + conditions := []string{} + if whereClause != "" { + conditions = append(conditions, whereClause) + } + if lastId != "" { + conditions = append(conditions, fmt.Sprintf("Id > '%s'", lastId)) + } + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + query += " ORDER BY Id" + + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + return query +} + +func getRecordId(record reflect.Value) string { + if record.Kind() == reflect.Map && record.Type().Key().Kind() == reflect.String { + for _, key := range record.MapKeys() { + if key.String() == "Id" { + idValue := record.MapIndex(key) + if idValue.IsValid() && idValue.CanInterface() { + return fmt.Sprintf("%v", idValue.Interface()) + } + } + } + } + return "" +} + +func paginateQuery(ctx context.Context, sf *salesforce.Salesforce, objectName string, fields []string, whereClause string, limit int, targetValue reflect.Value) error { + const batchSize = 200 + totalRetrieved := 0 + lastId := "" + + for { + batchLimit := batchSize + if limit > 0 && limit-totalRetrieved < batchSize { + batchLimit = limit - totalRetrieved + } + + query := buildSOQL(objectName, fields, whereClause, lastId, batchLimit) + batch, err := executeQuery(sf, query, targetValue.Type()) + if err != nil { + return fmt.Errorf("failed to query %s: %w", objectName, err) + } + + if batch.Len() == 0 { + break + } + + for i := 0; i < batch.Len(); i++ { + targetValue.Set(reflect.Append(targetValue, batch.Index(i))) + } + + recordCount := batch.Len() + totalRetrieved += recordCount + + if recordCount > 0 { + lastId = getRecordId(batch.Index(recordCount - 1)) + } + + log.Debug("Retrieved batch", "object", objectName, "batch_size", recordCount, "total", totalRetrieved) + + if (limit > 0 && totalRetrieved >= limit) || recordCount < batchLimit { + break + } + } + + if limit > 0 && targetValue.Len() > limit { + targetValue.Set(targetValue.Slice(0, limit)) + } + + return nil +} + +// executeQuery executes a SOQL query and returns the unmarshaled records +func executeQuery(sf *salesforce.Salesforce, query string, targetType reflect.Type) (reflect.Value, error) { + encodedQuery := url.QueryEscape(query) + resp, err := sf.DoRequest("GET", fmt.Sprintf("/query?q=%s", encodedQuery), nil) + if err != nil { + return reflect.Value{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return reflect.Value{}, fmt.Errorf("failed to read response: %w", err) + } + + var result struct { + Records json.RawMessage `json:"records"` + } + if err := json.Unmarshal(body, &result); err != nil { + return reflect.Value{}, fmt.Errorf("failed to unmarshal response: %w", err) + } + + batch := reflect.New(targetType).Elem() + if err := json.Unmarshal(result.Records, batch.Addr().Interface()); err != nil { + return reflect.Value{}, fmt.Errorf("failed to unmarshal records: %w", err) + } + + return batch, nil +} + +func AddToMetadata(metadata map[string]string, key string, value any) { + if value != nil { + strVal := fmt.Sprintf("%v", value) + if strVal != "" && strVal != "" { + metadata[key] = strVal + } + } +} + +func UpsertToCtrlplane(ctx context.Context, resources []api.CreateResource, providerName string) error { + apiURL := viper.GetString("url") + apiKey := viper.GetString("api-key") + workspaceId := viper.GetString("workspace") + + ctrlplaneClient, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + providerResp, err := ctrlplaneClient.UpsertResourceProviderWithResponse(ctx, workspaceId, providerName) + if err != nil { + return fmt.Errorf("failed to upsert resource provider: %w", err) + } + + if providerResp.JSON200 == nil { + return fmt.Errorf("failed to upsert resource provider: %s", providerResp.Body) + } + + providerId := providerResp.JSON200.Id + log.Info("Upserting resources", "provider", providerName, "count", len(resources)) + + setResp, err := ctrlplaneClient.SetResourceProvidersResourcesWithResponse(ctx, providerId, api.SetResourceProvidersResourcesJSONRequestBody{ + Resources: resources, + }) + if err != nil { + return fmt.Errorf("failed to set resources: %w", err) + } + + if setResp.JSON200 == nil { + return fmt.Errorf("failed to set resources: %s", setResp.Body) + } + + log.Info("Successfully synced resources", "count", len(resources)) + return nil +} diff --git a/cmd/ctrlc/root/sync/salesforce/opportunities/opportunities.go b/cmd/ctrlc/root/sync/salesforce/opportunities/opportunities.go new file mode 100644 index 0000000..3dcf419 --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/opportunities/opportunities.go @@ -0,0 +1,212 @@ +package opportunities + +import ( + "context" + "fmt" + "time" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/common" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/k-capehart/go-salesforce/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewSalesforceOpportunitiesCmd() *cobra.Command { + var name string + var metadataMappings map[string]string + var limit int + var listAllFields bool + var whereClause string + + cmd := &cobra.Command{ + Use: "opportunities", + Short: "Sync Salesforce opportunities into Ctrlplane", + Example: heredoc.Doc(` + # Sync all Salesforce opportunities + $ ctrlc sync salesforce opportunities \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" + + # Sync opportunities with a specific filter + $ ctrlc sync salesforce opportunities --where="Amount > 100000" + + # Sync opportunities and list all available fields in logs + $ ctrlc sync salesforce opportunities --list-all-fields + + # Sync opportunities with custom provider name + $ ctrlc sync salesforce opportunities --provider my-salesforce-opportunities + + # Sync with custom metadata mappings + $ ctrlc sync salesforce opportunities \ + --metadata="opportunity/id=Id" \ + --metadata="opportunity/owner-id=OwnerId" \ + --metadata="opportunity/forecast=ForecastCategory" \ + --metadata="opportunity/stage-custom=Custom_Stage__c" + + # Sync with a limit on number of records + $ ctrlc sync salesforce opportunities --limit 500 + + # Combine filters with metadata mappings + $ ctrlc sync salesforce opportunities \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" \ + --where="StageName = 'Closed Won' AND Amount > 50000" \ + --metadata="opportunity/revenue=Amount" + `), + RunE: func(cmd *cobra.Command, args []string) error { + domain := viper.GetString("salesforce-domain") + consumerKey := viper.GetString("salesforce-consumer-key") + consumerSecret := viper.GetString("salesforce-consumer-secret") + + log.Info("Syncing Salesforce opportunities into Ctrlplane", "domain", domain) + + ctx := context.Background() + + sf, err := common.InitSalesforceClient(domain, consumerKey, consumerSecret) + if err != nil { + return err + } + + resources, err := processOpportunities(ctx, sf, metadataMappings, limit, listAllFields, whereClause) + if err != nil { + return err + } + + if name == "" { + subdomain := common.GetSalesforceSubdomain(domain) + name = fmt.Sprintf("%s-salesforce-opportunities", subdomain) + } + + return common.UpsertToCtrlplane(ctx, resources, name) + }, + } + + cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider") + cmd.Flags().StringToStringVar(&metadataMappings, "metadata", map[string]string{}, "Custom metadata mappings (format: metadata/key=SalesforceField)") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of records to sync (0 = no limit)") + cmd.Flags().BoolVar(&listAllFields, "list-all-fields", false, "List all available Salesforce fields in the logs") + cmd.Flags().StringVar(&whereClause, "where", "", "SOQL WHERE clause to filter records (e.g., \"Amount > 100000\")") + + return cmd +} + +// processOpportunities queries and transforms opportunities +func processOpportunities(ctx context.Context, sf *salesforce.Salesforce, metadataMappings map[string]string, limit int, listAllFields bool, whereClause string) ([]api.CreateResource, error) { + additionalFields := make([]string, 0, len(metadataMappings)) + for _, fieldName := range metadataMappings { + additionalFields = append(additionalFields, fieldName) + } + + var opportunities []map[string]any + err := common.QuerySalesforceObject(ctx, sf, "Opportunity", limit, listAllFields, &opportunities, additionalFields, whereClause) + if err != nil { + return nil, err + } + + log.Info("Found Salesforce opportunities", "count", len(opportunities)) + + resources := []api.CreateResource{} + for _, opp := range opportunities { + resource := transformOpportunityToResource(opp, metadataMappings) + resources = append(resources, resource) + } + + return resources, nil +} + +func formatCloseDate(closeDate any) string { + if closeDate == nil { + return "" + } + + closeDateStr := fmt.Sprintf("%v", closeDate) + if str, ok := closeDate.(string); ok && str != "" { + if t, err := time.Parse("2006-01-02", str); err == nil { + return t.Format(time.RFC3339) + } + } + return closeDateStr +} + +func transformOpportunityToResource(opportunity map[string]any, metadataMappings map[string]string) api.CreateResource { + metadata := map[string]string{} + + common.AddToMetadata(metadata, "opportunity/id", opportunity["Id"]) + common.AddToMetadata(metadata, "opportunity/account-id", opportunity["AccountId"]) + common.AddToMetadata(metadata, "opportunity/stage", opportunity["StageName"]) + common.AddToMetadata(metadata, "opportunity/amount", opportunity["Amount"]) + common.AddToMetadata(metadata, "opportunity/probability", opportunity["Probability"]) + common.AddToMetadata(metadata, "opportunity/name", opportunity["Name"]) + common.AddToMetadata(metadata, "opportunity/type", opportunity["Type"]) + common.AddToMetadata(metadata, "opportunity/owner-id", opportunity["OwnerId"]) + common.AddToMetadata(metadata, "opportunity/is-closed", opportunity["IsClosed"]) + common.AddToMetadata(metadata, "opportunity/is-won", opportunity["IsWon"]) + common.AddToMetadata(metadata, "opportunity/lead-source", opportunity["LeadSource"]) + common.AddToMetadata(metadata, "opportunity/forecast-category", opportunity["ForecastCategory"]) + common.AddToMetadata(metadata, "opportunity/contact-id", opportunity["ContactId"]) + common.AddToMetadata(metadata, "opportunity/campaign-id", opportunity["CampaignId"]) + common.AddToMetadata(metadata, "opportunity/created-date", opportunity["CreatedDate"]) + common.AddToMetadata(metadata, "opportunity/last-modified", opportunity["LastModifiedDate"]) + + if closeDate := formatCloseDate(opportunity["CloseDate"]); closeDate != "" { + metadata["opportunity/close-date"] = closeDate + } + + for metadataKey, fieldName := range metadataMappings { + if value, exists := opportunity[fieldName]; exists { + common.AddToMetadata(metadata, metadataKey, value) + } + } + + fiscalQuarter := 0 + fiscalYear := 0 + if period, ok := opportunity["FiscalPeriod"].(string); ok && period != "" { + // Period format is typically like "2024 Q1" + if n, err := fmt.Sscanf(period, "%d Q%d", &fiscalYear, &fiscalQuarter); err != nil || n != 2 { + log.Debug("Failed to parse fiscal period", "period", period, "error", err) + // Leave fiscalQuarter and fiscalYear as 0 + } + } + + config := map[string]interface{}{ + "name": fmt.Sprintf("%v", opportunity["Name"]), + "amount": opportunity["Amount"], + "stage": fmt.Sprintf("%v", opportunity["StageName"]), + "id": fmt.Sprintf("%v", opportunity["Id"]), + "probability": opportunity["Probability"], + "isClosed": opportunity["IsClosed"], + "isWon": opportunity["IsWon"], + + "salesforceOpportunity": map[string]interface{}{ + "recordId": fmt.Sprintf("%v", opportunity["Id"]), + "accountId": fmt.Sprintf("%v", opportunity["AccountId"]), + "ownerId": fmt.Sprintf("%v", opportunity["OwnerId"]), + "type": fmt.Sprintf("%v", opportunity["Type"]), + "leadSource": fmt.Sprintf("%v", opportunity["LeadSource"]), + "closeDate": formatCloseDate(opportunity["CloseDate"]), + "forecastCategory": fmt.Sprintf("%v", opportunity["ForecastCategory"]), + "description": fmt.Sprintf("%v", opportunity["Description"]), + "nextStep": fmt.Sprintf("%v", opportunity["NextStep"]), + "hasOpenActivity": opportunity["HasOpenActivity"], + "createdDate": fmt.Sprintf("%v", opportunity["CreatedDate"]), + "lastModifiedDate": fmt.Sprintf("%v", opportunity["LastModifiedDate"]), + "lastActivityDate": fmt.Sprintf("%v", opportunity["LastActivityDate"]), + "fiscalQuarter": fiscalQuarter, + "fiscalYear": fiscalYear, + }, + } + + return api.CreateResource{ + Version: "ctrlplane.dev/crm/opportunity/v1", + Kind: "SalesforceOpportunity", + Name: fmt.Sprintf("%v", opportunity["Name"]), + Identifier: fmt.Sprintf("%v", opportunity["Id"]), + Config: config, + Metadata: metadata, + } +} diff --git a/cmd/ctrlc/root/sync/salesforce/salesforce.go b/cmd/ctrlc/root/sync/salesforce/salesforce.go new file mode 100644 index 0000000..e0e385a --- /dev/null +++ b/cmd/ctrlc/root/sync/salesforce/salesforce.go @@ -0,0 +1,62 @@ +package salesforce + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/accounts" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/opportunities" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewSalesforceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "salesforce", + Short: "Sync Salesforce resources into Ctrlplane", + Example: heredoc.Doc(` + # Sync Salesforce accounts + $ ctrlc sync salesforce accounts \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" + + # Sync Salesforce opportunities + $ ctrlc sync salesforce opportunities \ + --salesforce-domain="https://mycompany.my.salesforce.com" \ + --salesforce-consumer-key="your-key" \ + --salesforce-consumer-secret="your-secret" + `), + } + + cmd.PersistentFlags().String("salesforce-domain", "", "Salesforce domain (e.g., https://my-domain.my.salesforce.com) (can also be set via SALESFORCE_DOMAIN env var)") + cmd.PersistentFlags().String("salesforce-consumer-key", "", "Salesforce consumer key (can also be set via SALESFORCE_CONSUMER_KEY env var)") + cmd.PersistentFlags().String("salesforce-consumer-secret", "", "Salesforce consumer secret (can also be set via SALESFORCE_CONSUMER_SECRET env var)") + + viper.AutomaticEnv() + + if err := viper.BindEnv("salesforce-domain", "SALESFORCE_DOMAIN"); err != nil { + panic(fmt.Errorf("failed to bind SALESFORCE_DOMAIN env var: %w", err)) + } + if err := viper.BindEnv("salesforce-consumer-key", "SALESFORCE_CONSUMER_KEY"); err != nil { + panic(fmt.Errorf("failed to bind SALESFORCE_CONSUMER_KEY env var: %w", err)) + } + if err := viper.BindEnv("salesforce-consumer-secret", "SALESFORCE_CONSUMER_SECRET"); err != nil { + panic(fmt.Errorf("failed to bind SALESFORCE_CONSUMER_SECRET env var: %w", err)) + } + + if err := viper.BindPFlag("salesforce-domain", cmd.PersistentFlags().Lookup("salesforce-domain")); err != nil { + panic(fmt.Errorf("failed to bind salesforce-domain flag: %w", err)) + } + if err := viper.BindPFlag("salesforce-consumer-key", cmd.PersistentFlags().Lookup("salesforce-consumer-key")); err != nil { + panic(fmt.Errorf("failed to bind salesforce-consumer-key flag: %w", err)) + } + if err := viper.BindPFlag("salesforce-consumer-secret", cmd.PersistentFlags().Lookup("salesforce-consumer-secret")); err != nil { + panic(fmt.Errorf("failed to bind salesforce-consumer-secret flag: %w", err)) + } + + cmd.AddCommand(accounts.NewSalesforceAccountsCmd()) + cmd.AddCommand(opportunities.NewSalesforceOpportunitiesCmd()) + + return cmd +} diff --git a/cmd/ctrlc/root/sync/sync.go b/cmd/ctrlc/root/sync/sync.go index 6c430ae..82d5482 100644 --- a/cmd/ctrlc/root/sync/sync.go +++ b/cmd/ctrlc/root/sync/sync.go @@ -8,6 +8,7 @@ import ( "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/github" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/google" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/kubernetes" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/tailscale" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/terraform" "github.com/ctrlplanedev/cli/internal/cliutil" @@ -38,6 +39,7 @@ func NewSyncCmd() *cobra.Command { cmd.AddCommand(kubernetes.NewSyncKubernetesCmd()) cmd.AddCommand(kubernetes.NewSyncVclusterCmd()) cmd.AddCommand(github.NewSyncGitHubCmd()) + cmd.AddCommand(salesforce.NewSalesforceCmd()) return cmd } diff --git a/go.mod b/go.mod index db29724..d166415 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 + github.com/MakeNowJust/heredoc v1.0.0 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/Masterminds/semver v1.5.0 github.com/avast/retry-go v3.0.0+incompatible @@ -26,6 +27,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-tfe v1.73.1 + github.com/k-capehart/go-salesforce/v2 v2.5.2 github.com/loft-sh/vcluster v0.25.0 github.com/mitchellh/go-homedir v1.1.0 github.com/moby/term v0.5.0 @@ -38,7 +40,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.32.1 - k8s.io/apimachinery v0.32.1 + k8s.io/apimachinery v0.33.2 k8s.io/client-go v0.32.1 ) @@ -51,7 +53,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect - github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect @@ -86,6 +87,7 @@ require ( github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/forcedotcom/go-soql v0.0.0-20220705175410-00f698360bee // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/getkin/kin-openapi v0.127.0 // indirect @@ -96,17 +98,17 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.22.0 // indirect - github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect + github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-github/v53 v53.2.1-0.20230815134205-bb00f570d301 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -126,6 +128,7 @@ require ( github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jszwec/csvutil v1.10.0 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.17.11 // indirect @@ -174,7 +177,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect - github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect @@ -226,7 +229,7 @@ require ( k8s.io/component-base v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-aggregator v0.32.1 // indirect - k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/kubectl v0.32.1 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect @@ -234,7 +237,8 @@ require ( sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/kustomize/api v0.18.0 // indirect sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 170bb29..4a9e053 100644 --- a/go.sum +++ b/go.sum @@ -154,6 +154,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/forcedotcom/go-soql v0.0.0-20220705175410-00f698360bee h1:UViGyUS6N3GdlALmKBczIi/mXrKkpQcZRyk0Hd5IqvU= +github.com/forcedotcom/go-soql v0.0.0-20220705175410-00f698360bee/go.mod h1:bON16NgZr710tAa9hHPeSNoNihIEXDEbVWy6rKP6rL8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -187,6 +189,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -211,8 +215,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.22.0 h1:b3FJZxpiv1vTMo2/5RDUqAHPxkT8mmMfJIrq1llbf7g= github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -292,7 +296,11 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= +github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/k-capehart/go-salesforce/v2 v2.5.2 h1:KcTvQofM+RGfJ+ov0BqXiWw9XS00onHCLnXfrVk47/o= +github.com/k-capehart/go-salesforce/v2 v2.5.2/go.mod h1:XdA3KHaEoAGra10uJdnGhMqRxaY1cqK7llJUb1B6fAU= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -383,6 +391,7 @@ github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmt github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -450,8 +459,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -574,6 +583,7 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191204025024-5ee1b9f4859a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -635,6 +645,7 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -691,6 +702,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= @@ -702,6 +714,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -715,8 +728,8 @@ k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc= k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k= k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw= k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto= -k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs= -k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/apiserver v0.32.1 h1:oo0OozRos66WFq87Zc5tclUX2r0mymoVHRq8JmR7Aak= k8s.io/apiserver v0.32.1/go.mod h1:UcB9tWjBY7aryeI5zAgzVJB/6k7E97bkr1RgqDz0jPw= k8s.io/cli-runtime v0.32.1 h1:19nwZPlYGJPUDbhAxDIS2/oydCikvKMHsxroKNGA2mM= @@ -729,8 +742,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-aggregator v0.32.1 h1:cztPyIHbo6tgrhYHDqmdmvxUufJKuxgAC/vog7yeWek= k8s.io/kube-aggregator v0.32.1/go.mod h1:sXjL5T8FO/rlBzTbBhahw9V5Nnr1UtzZHKTj9WxQCOU= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/kubectl v0.32.1 h1:/btLtXLQUU1rWx8AEvX9jrb9LaI6yeezt3sFALhB8M8= k8s.io/kubectl v0.32.1/go.mod h1:sezNuyWi1STk4ZNPVRIFfgjqMI6XMf+oCVLjZen/pFQ= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= @@ -745,7 +758,10 @@ sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=