From 81c7a16697707a87066cebff0f2c48a1b022c1b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 20:17:23 +0000 Subject: [PATCH 01/28] Add sourcemaps command for uploading frontend sourcemaps Co-Authored-By: vkorolik@launchdarkly.com --- cmd/root.go | 2 + cmd/sourcemaps/sourcemaps.go | 22 +++ cmd/sourcemaps/upload.go | 366 +++++++++++++++++++++++++++++++++++ cmd/templates.go | 1 + 4 files changed, 391 insertions(+) create mode 100644 cmd/sourcemaps/sourcemaps.go create mode 100644 cmd/sourcemaps/upload.go diff --git a/cmd/root.go b/cmd/root.go index e7034017..bb4b7b1a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" "github.com/launchdarkly/ldcli/internal/dev_server" @@ -203,6 +204,7 @@ func NewRootCommand( cmd.AddCommand(logincmd.NewLoginCmd(resources.NewClient(version))) cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(resources.NewClient(version), analyticsTrackerFn, dev_server.NewClient(version))) + cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd()) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) // add non-generated commands diff --git a/cmd/sourcemaps/sourcemaps.go b/cmd/sourcemaps/sourcemaps.go new file mode 100644 index 00000000..f13310e0 --- /dev/null +++ b/cmd/sourcemaps/sourcemaps.go @@ -0,0 +1,22 @@ +package sourcemaps + +import ( + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSourcemapsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sourcemaps", + Short: "Manage sourcemaps", + Long: "Manage sourcemaps for LaunchDarkly error monitoring", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand(NewUploadCmd(resources.NewClient(""))) + + return cmd +} diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go new file mode 100644 index 00000000..f267bf54 --- /dev/null +++ b/cmd/sourcemaps/upload.go @@ -0,0 +1,366 @@ +package sourcemaps + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +const ( + apiKeyFlag = "api-key" + appVersionFlag = "app-version" + pathFlag = "path" + basePathFlag = "base-path" + backendUrlFlag = "backend-url" + + defaultPath = "." + defaultBackendUrl = "https://app.launchdarkly.com" + + verifyApiKeyQuery = ` + query ApiKeyToOrgID($api_key: String!) { + api_key_to_org_id(api_key: $api_key) + } + ` + + getSourceMapUrlsQuery = ` + query GetSourceMapUploadUrls($api_key: String!, $paths: [String!]!) { + get_source_map_upload_urls(api_key: $api_key, paths: $paths) + } + ` +) + +func NewUploadCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "upload", + Short: "Upload sourcemaps", + Long: "Upload JavaScript sourcemaps to LaunchDarkly for error monitoring", + RunE: runE(client), + } + + cmd.Flags().String(apiKeyFlag, "", "The LaunchDarkly API key") + _ = cmd.MarkFlagRequired(apiKeyFlag) + _ = cmd.Flags().SetAnnotation(apiKeyFlag, "required", []string{"true"}) + _ = viper.BindPFlag(apiKeyFlag, cmd.Flags().Lookup(apiKeyFlag)) + + cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") + _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) + + cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are") + _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) + + cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps") + _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) + + cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") + _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) + + return cmd +} + +func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + var tracker analytics.Tracker = &analytics.NoopClient{} + if analyticsTrackerFn, ok := cmd.Root().Annotations["analytics_tracker_fn"]; ok { + trackerFn := analytics.NoopClientFn{}.Tracker() + if analyticsTrackerFn == "client" { + trackerFn = analytics.ClientFn{ + ID: "ldcli", + Version: "dev", + }.Tracker + } + tracker = trackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + } + + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "sourcemaps", + map[string]interface{}{ + "action": "upload", + })) + + apiKey := viper.GetString(apiKeyFlag) + appVersion := viper.GetString(appVersionFlag) + path := viper.GetString(pathFlag) + basePath := viper.GetString(basePathFlag) + backendUrl := viper.GetString(backendUrlFlag) + + if apiKey == "" { + return fmt.Errorf("api key cannot be empty") + } + + organizationId, err := verifyApiKey(apiKey, backendUrl) + if err != nil { + return err + } + + fmt.Printf("Starting to upload source maps from %s\n", path) + + fileList, err := getAllSourceMapFiles(path) + if err != nil { + return err + } + + if len(fileList) == 0 { + return fmt.Errorf("no source maps found in %s, is this the correct path?", path) + } + + s3Keys := make([]string, len(fileList)) + for i, file := range fileList { + s3Keys[i] = getS3Key(organizationId, appVersion, basePath, file.Name) + } + + uploadUrls, err := getSourceMapUploadUrls(apiKey, s3Keys, backendUrl) + if err != nil { + return err + } + + for i, file := range fileList { + err = uploadFile(file.Path, uploadUrls[i], file.Name) + if err != nil { + return err + } + } + + fmt.Println("Successfully uploaded all sourcemaps") + return nil + } +} + +func verifyApiKey(apiKey, backendUrl string) (string, error) { + variables := map[string]interface{}{ + "api_key": apiKey, + } + + body, err := json.Marshal(map[string]interface{}{ + "query": verifyApiKeyQuery, + "variables": variables, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ApiKey", apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var result struct { + Data struct { + ApiKeyToOrgID string `json:"api_key_to_org_id"` + } `json:"data"` + } + + err = json.Unmarshal(respBody, &result) + if err != nil { + return "", err + } + + if result.Data.ApiKeyToOrgID == "" || result.Data.ApiKeyToOrgID == "0" { + return "", fmt.Errorf("invalid api key") + } + + return result.Data.ApiKeyToOrgID, nil +} + +func getSourceMapUploadUrls(apiKey string, paths []string, backendUrl string) ([]string, error) { + variables := map[string]interface{}{ + "api_key": apiKey, + "paths": paths, + } + + body, err := json.Marshal(map[string]interface{}{ + "query": getSourceMapUrlsQuery, + "variables": variables, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result struct { + Data struct { + GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls"` + } `json:"data"` + } + + err = json.Unmarshal(respBody, &result) + if err != nil { + return nil, err + } + + if len(result.Data.GetSourceMapUploadUrls) == 0 { + return nil, fmt.Errorf("unable to generate source map upload urls") + } + + return result.Data.GetSourceMapUploadUrls, nil +} + +type SourceMapFile struct { + Path string + Name string +} + +func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { + var fileList []SourceMapFile + + absPath, err := filepath.Abs(path) + if err != nil { + return nil, err + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + return nil, err + } + + if fileInfo.IsDir() { + err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + if info.Name() == "node_modules" { + return filepath.SkipDir + } + return nil + } + + if strings.HasSuffix(info.Name(), ".js.map") { + relPath, err := filepath.Rel(absPath, path) + if err != nil { + return err + } + + fileList = append(fileList, SourceMapFile{ + Path: path, + Name: relPath, + }) + } + + return nil + }) + + if err != nil { + return nil, err + } + } else { + if strings.HasSuffix(fileInfo.Name(), ".js.map") { + fileList = append(fileList, SourceMapFile{ + Path: absPath, + Name: fileInfo.Name(), + }) + } + } + + return fileList, nil +} + +func getS3Key(organizationId, version, basePath, fileName string) string { + if version == "" { + version = "unversioned" + } + + if basePath != "" && !strings.HasSuffix(basePath, "/") { + basePath = basePath + "/" + } + + return fmt.Sprintf("%s/%s/%s%s", organizationId, version, basePath, fileName) +} + +func uploadFile(filePath, uploadUrl, name string) error { + fileContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return err + } + + _, err = part.Write(fileContent) + if err != nil { + return err + } + + err = writer.Close() + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", uploadUrl, bytes.NewReader(fileContent)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("failed to upload %s: %s", name, resp.Status) + } + + fmt.Printf("Uploaded %s\n", name) + return nil +} diff --git a/cmd/templates.go b/cmd/templates.go index b772bafa..221061f9 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -23,6 +23,7 @@ Common resource commands: {{rpad "projects" 29}} List, create, and manage projects {{rpad "members" 29}} Invite new members to an account {{rpad "segments" 29}} List, create, modify, and delete segments + {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: From 953f9a5866763d012c4a3c36053c02e769e138a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 13:33:18 +0000 Subject: [PATCH 02/28] Update sourcemaps command to use @highlight-run/sourcemap-uploader npm package Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 276 +++++---------------------------------- 1 file changed, 30 insertions(+), 246 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index f267bf54..945c2088 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -2,13 +2,9 @@ package sourcemaps import ( "bytes" - "encoding/json" "fmt" - "io" - "mime/multipart" - "net/http" "os" - "path/filepath" + "os/exec" "strings" "github.com/spf13/cobra" @@ -30,17 +26,7 @@ const ( defaultPath = "." defaultBackendUrl = "https://app.launchdarkly.com" - verifyApiKeyQuery = ` - query ApiKeyToOrgID($api_key: String!) { - api_key_to_org_id(api_key: $api_key) - } - ` - - getSourceMapUrlsQuery = ` - query GetSourceMapUploadUrls($api_key: String!, $paths: [String!]!) { - get_source_map_upload_urls(api_key: $api_key, paths: $paths) - } - ` + npmPackage = "@highlight-run/sourcemap-uploader" ) func NewUploadCmd(client resources.Client) *cobra.Command { @@ -106,261 +92,59 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error return fmt.Errorf("api key cannot be empty") } - organizationId, err := verifyApiKey(apiKey, backendUrl) - if err != nil { - return err + if err := checkNodeInstalled(); err != nil { + return fmt.Errorf("Node.js is required to upload sourcemaps: %v", err) } - fmt.Printf("Starting to upload source maps from %s\n", path) + args := []string{npmPackage, "upload", "--apiKey", apiKey} - fileList, err := getAllSourceMapFiles(path) - if err != nil { - return err + if appVersion != "" { + args = append(args, "--appVersion", appVersion) } - if len(fileList) == 0 { - return fmt.Errorf("no source maps found in %s, is this the correct path?", path) + if path != defaultPath { + args = append(args, "--path", path) } - s3Keys := make([]string, len(fileList)) - for i, file := range fileList { - s3Keys[i] = getS3Key(organizationId, appVersion, basePath, file.Name) + if basePath != "" { + args = append(args, "--basePath", basePath) } - uploadUrls, err := getSourceMapUploadUrls(apiKey, s3Keys, backendUrl) - if err != nil { - return err - } - - for i, file := range fileList { - err = uploadFile(file.Path, uploadUrls[i], file.Name) - if err != nil { - return err - } + if backendUrl != defaultBackendUrl { + args = append(args, "--backendUrl", backendUrl) } - fmt.Println("Successfully uploaded all sourcemaps") - return nil - } -} - -func verifyApiKey(apiKey, backendUrl string) (string, error) { - variables := map[string]interface{}{ - "api_key": apiKey, - } - - body, err := json.Marshal(map[string]interface{}{ - "query": verifyApiKeyQuery, - "variables": variables, - }) - if err != nil { - return "", err - } - - req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(body)) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("ApiKey", apiKey) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var result struct { - Data struct { - ApiKeyToOrgID string `json:"api_key_to_org_id"` - } `json:"data"` - } + fmt.Printf("Starting to upload source maps from %s using %s\n", path, npmPackage) - err = json.Unmarshal(respBody, &result) - if err != nil { - return "", err - } - - if result.Data.ApiKeyToOrgID == "" || result.Data.ApiKeyToOrgID == "0" { - return "", fmt.Errorf("invalid api key") - } + cmd := exec.Command("npx", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = os.Environ() - return result.Data.ApiKeyToOrgID, nil -} - -func getSourceMapUploadUrls(apiKey string, paths []string, backendUrl string) ([]string, error) { - variables := map[string]interface{}{ - "api_key": apiKey, - "paths": paths, - } - - body, err := json.Marshal(map[string]interface{}{ - "query": getSourceMapUrlsQuery, - "variables": variables, - }) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var result struct { - Data struct { - GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls"` - } `json:"data"` - } - - err = json.Unmarshal(respBody, &result) - if err != nil { - return nil, err - } - - if len(result.Data.GetSourceMapUploadUrls) == 0 { - return nil, fmt.Errorf("unable to generate source map upload urls") - } - - return result.Data.GetSourceMapUploadUrls, nil -} - -type SourceMapFile struct { - Path string - Name string -} - -func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { - var fileList []SourceMapFile - - absPath, err := filepath.Abs(path) - if err != nil { - return nil, err - } - - fileInfo, err := os.Stat(absPath) - if err != nil { - return nil, err - } - - if fileInfo.IsDir() { - err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if info.IsDir() { - if info.Name() == "node_modules" { - return filepath.SkipDir - } - return nil - } - - if strings.HasSuffix(info.Name(), ".js.map") { - relPath, err := filepath.Rel(absPath, path) - if err != nil { - return err - } - - fileList = append(fileList, SourceMapFile{ - Path: path, - Name: relPath, - }) - } - - return nil - }) + err := cmd.Run() + fmt.Print(stdout.String()) if err != nil { - return nil, err - } - } else { - if strings.HasSuffix(fileInfo.Name(), ".js.map") { - fileList = append(fileList, SourceMapFile{ - Path: absPath, - Name: fileInfo.Name(), - }) + fmt.Print(stderr.String()) + return fmt.Errorf("failed to upload sourcemaps: %v", err) } - } - - return fileList, nil -} -func getS3Key(organizationId, version, basePath, fileName string) string { - if version == "" { - version = "unversioned" - } - - if basePath != "" && !strings.HasSuffix(basePath, "/") { - basePath = basePath + "/" + fmt.Println("Successfully uploaded all sourcemaps") + return nil } - - return fmt.Sprintf("%s/%s/%s%s", organizationId, version, basePath, fileName) } -func uploadFile(filePath, uploadUrl, name string) error { - fileContent, err := os.ReadFile(filePath) - if err != nil { - return err - } - - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - - part, err := writer.CreateFormFile("file", filepath.Base(filePath)) +func checkNodeInstalled() error { + _, err := exec.LookPath("node") if err != nil { - return err + return fmt.Errorf("Node.js is not installed or not in PATH: %v", err) } - _, err = part.Write(fileContent) + _, err = exec.LookPath("npx") if err != nil { - return err - } - - err = writer.Close() - if err != nil { - return err - } - - req, err := http.NewRequest("PUT", uploadUrl, bytes.NewReader(fileContent)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/octet-stream") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return fmt.Errorf("failed to upload %s: %s", name, resp.Status) + return fmt.Errorf("npx is not installed or not in PATH: %v", err) } - fmt.Printf("Uploaded %s\n", name) return nil } From c4c2673b9fed1e7f58792a2eb60db8bfb065ba91 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 13:35:01 +0000 Subject: [PATCH 03/28] Fix variable shadowing issues in sourcemaps upload command Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 945c2088..4598467e 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/exec" - "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -96,33 +95,33 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error return fmt.Errorf("Node.js is required to upload sourcemaps: %v", err) } - args := []string{npmPackage, "upload", "--apiKey", apiKey} + npxArgs := []string{npmPackage, "upload", "--apiKey", apiKey} if appVersion != "" { - args = append(args, "--appVersion", appVersion) + npxArgs = append(npxArgs, "--appVersion", appVersion) } if path != defaultPath { - args = append(args, "--path", path) + npxArgs = append(npxArgs, "--path", path) } if basePath != "" { - args = append(args, "--basePath", basePath) + npxArgs = append(npxArgs, "--basePath", basePath) } if backendUrl != defaultBackendUrl { - args = append(args, "--backendUrl", backendUrl) + npxArgs = append(npxArgs, "--backendUrl", backendUrl) } fmt.Printf("Starting to upload source maps from %s using %s\n", path, npmPackage) - cmd := exec.Command("npx", args...) + execCmd := exec.Command("npx", npxArgs...) var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - cmd.Env = os.Environ() + execCmd.Stdout = &stdout + execCmd.Stderr = &stderr + execCmd.Env = os.Environ() - err := cmd.Run() + err := execCmd.Run() fmt.Print(stdout.String()) if err != nil { From 85058bf5d101575b5ee9b32f6eabf9c2c107978c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 23:29:37 +0000 Subject: [PATCH 04/28] Revert npm package integration and implement sourcemap uploader directly in Go Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 268 ++++++++++++++++++++++++++++++++++----- 1 file changed, 238 insertions(+), 30 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 4598467e..116839fd 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -2,9 +2,15 @@ package sourcemaps import ( "bytes" + "encoding/json" "fmt" + "io" + "io/fs" + "net/http" "os" - "os/exec" + "path/filepath" + "regexp" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -25,9 +31,36 @@ const ( defaultPath = "." defaultBackendUrl = "https://app.launchdarkly.com" - npmPackage = "@highlight-run/sourcemap-uploader" + verifyApiKeyQuery = ` + query ApiKeyToOrgID($api_key: String!) { + api_key_to_org_id(api_key: $api_key) + } + ` + + getSourceMapUrlsQuery = ` + query GetSourceMapUploadUrls($api_key: String!, $paths: [String!]!) { + get_source_map_upload_urls(api_key: $api_key, paths: $paths) + } + ` ) +type ApiKeyResponse struct { + Data struct { + ApiKeyToOrgID string `json:"api_key_to_org_id"` + } `json:"data"` +} + +type SourceMapUrlsResponse struct { + Data struct { + GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls"` + } `json:"data"` +} + +type SourceMapFile struct { + Path string + Name string +} + func NewUploadCmd(client resources.Client) *cobra.Command { cmd := &cobra.Command{ Use: "upload", @@ -88,62 +121,237 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl := viper.GetString(backendUrlFlag) if apiKey == "" { - return fmt.Errorf("api key cannot be empty") + apiKey = os.Getenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") + if apiKey == "" { + return fmt.Errorf("api key cannot be empty") + } + } + + if backendUrl == "" { + backendUrl = defaultBackendUrl } - if err := checkNodeInstalled(); err != nil { - return fmt.Errorf("Node.js is required to upload sourcemaps: %v", err) + organizationID, err := verifyApiKey(apiKey, backendUrl) + if err != nil { + return fmt.Errorf("failed to verify API key: %v", err) } - npxArgs := []string{npmPackage, "upload", "--apiKey", apiKey} + fmt.Printf("Starting to upload source maps from %s\n", path) - if appVersion != "" { - npxArgs = append(npxArgs, "--appVersion", appVersion) + files, err := getAllSourceMapFiles(path) + if err != nil { + return fmt.Errorf("failed to find sourcemap files: %v", err) } - if path != defaultPath { - npxArgs = append(npxArgs, "--path", path) + if len(files) == 0 { + return fmt.Errorf("no source maps found in %s, is this the correct path?", path) } - if basePath != "" { - npxArgs = append(npxArgs, "--basePath", basePath) + s3Keys := make([]string, 0, len(files)) + for _, file := range files { + s3Keys = append(s3Keys, getS3Key(organizationID, appVersion, basePath, file.Name)) } - if backendUrl != defaultBackendUrl { - npxArgs = append(npxArgs, "--backendUrl", backendUrl) + uploadUrls, err := getSourceMapUploadUrls(apiKey, s3Keys, backendUrl) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %v", err) } - fmt.Printf("Starting to upload source maps from %s using %s\n", path, npmPackage) + for i, file := range files { + if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil { + return fmt.Errorf("failed to upload file %s: %v", file.Path, err) + } + } - execCmd := exec.Command("npx", npxArgs...) - var stdout, stderr bytes.Buffer - execCmd.Stdout = &stdout - execCmd.Stderr = &stderr - execCmd.Env = os.Environ() + fmt.Println("Successfully uploaded all sourcemaps") + return nil + } +} + +func verifyApiKey(apiKey, backendUrl string) (string, error) { + variables := map[string]string{ + "api_key": apiKey, + } - err := execCmd.Run() - fmt.Print(stdout.String()) + reqBody, err := json.Marshal(map[string]interface{}{ + "query": verifyApiKeyQuery, + "variables": variables, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ApiKey", apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var apiKeyResp ApiKeyResponse + if err := json.Unmarshal(body, &apiKeyResp); err != nil { + return "", err + } + + if apiKeyResp.Data.ApiKeyToOrgID == "" || apiKeyResp.Data.ApiKeyToOrgID == "0" { + return "", fmt.Errorf("invalid API key") + } + + return apiKeyResp.Data.ApiKeyToOrgID, nil +} + +func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { + var files []SourceMapFile + routeGroupPattern := regexp.MustCompile(`\(.+?\)/`) + + fileInfo, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + files = append(files, SourceMapFile{ + Path: path, + Name: filepath.Base(path), + }) + return files, nil + } + err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { if err != nil { - fmt.Print(stderr.String()) - return fmt.Errorf("failed to upload sourcemaps: %v", err) + return err + } + + if d.IsDir() && d.Name() == "node_modules" { + return filepath.SkipDir + } + + if !d.IsDir() && (strings.HasSuffix(filePath, ".js.map") || strings.HasSuffix(filePath, ".js")) { + relPath, err := filepath.Rel(path, filePath) + if err != nil { + return err + } + + files = append(files, SourceMapFile{ + Path: filePath, + Name: relPath, + }) + + routeGroupRemovedPath := routeGroupPattern.ReplaceAllString(relPath, "") + if routeGroupRemovedPath != relPath { + files = append(files, SourceMapFile{ + Path: filePath, + Name: routeGroupRemovedPath, + }) + } } - fmt.Println("Successfully uploaded all sourcemaps") return nil + }) + + if err != nil { + return nil, err + } + + if len(files) == 0 { + return nil, fmt.Errorf("no .js.map files found. Please double check that you have generated sourcemaps for your app") + } + + return files, nil +} + +func getS3Key(organizationID, version, basePath, fileName string) string { + if version == "" { + version = "unversioned" + } + + if basePath != "" && !strings.HasSuffix(basePath, "/") { + basePath = basePath + "/" + } + + return fmt.Sprintf("%s/%s/%s%s", organizationID, version, basePath, fileName) +} + +func getSourceMapUploadUrls(apiKey string, paths []string, backendUrl string) ([]string, error) { + variables := map[string]interface{}{ + "api_key": apiKey, + "paths": paths, + } + + reqBody, err := json.Marshal(map[string]interface{}{ + "query": getSourceMapUrlsQuery, + "variables": variables, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var urlsResp SourceMapUrlsResponse + if err := json.Unmarshal(body, &urlsResp); err != nil { + return nil, err } + + if len(urlsResp.Data.GetSourceMapUploadUrls) == 0 { + return nil, fmt.Errorf("unable to generate source map upload urls") + } + + return urlsResp.Data.GetSourceMapUploadUrls, nil } -func checkNodeInstalled() error { - _, err := exec.LookPath("node") +func uploadFile(filePath, uploadUrl, name string) error { + fileContent, err := os.ReadFile(filePath) if err != nil { - return fmt.Errorf("Node.js is not installed or not in PATH: %v", err) + return err } - _, err = exec.LookPath("npx") + req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(fileContent)) if err != nil { - return fmt.Errorf("npx is not installed or not in PATH: %v", err) + return err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload failed with status code: %d", resp.StatusCode) } + fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name) return nil } From 7040d52246faf6e5fcd003b019c45d91c610f330 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 21 May 2025 11:40:33 -0500 Subject: [PATCH 05/28] local testing --- cmd/sourcemaps/upload.go | 70 ++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 116839fd..9368cdfc 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -15,9 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" - "github.com/launchdarkly/ldcli/cmd/cliflags" - "github.com/launchdarkly/ldcli/internal/analytics" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" "github.com/launchdarkly/ldcli/internal/resources" ) @@ -29,7 +27,7 @@ const ( backendUrlFlag = "backend-url" defaultPath = "." - defaultBackendUrl = "https://app.launchdarkly.com" + defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" verifyApiKeyQuery = ` query ApiKeyToOrgID($api_key: String!) { @@ -69,51 +67,14 @@ func NewUploadCmd(client resources.Client) *cobra.Command { RunE: runE(client), } - cmd.Flags().String(apiKeyFlag, "", "The LaunchDarkly API key") - _ = cmd.MarkFlagRequired(apiKeyFlag) - _ = cmd.Flags().SetAnnotation(apiKeyFlag, "required", []string{"true"}) - _ = viper.BindPFlag(apiKeyFlag, cmd.Flags().Lookup(apiKeyFlag)) - - cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") - _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) - - cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are") - _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) - - cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps") - _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) - - cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") - _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initFlags(cmd) return cmd } func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { - var tracker analytics.Tracker = &analytics.NoopClient{} - if analyticsTrackerFn, ok := cmd.Root().Annotations["analytics_tracker_fn"]; ok { - trackerFn := analytics.NoopClientFn{}.Tracker() - if analyticsTrackerFn == "client" { - trackerFn = analytics.ClientFn{ - ID: "ldcli", - Version: "dev", - }.Tracker - } - tracker = trackerFn( - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), - viper.GetBool(cliflags.AnalyticsOptOut), - ) - } - - tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( - cmd, - "sourcemaps", - map[string]interface{}{ - "action": "upload", - })) - apiKey := viper.GetString(apiKeyFlag) appVersion := viper.GetString(appVersionFlag) path := viper.GetString(pathFlag) @@ -277,11 +238,11 @@ func getS3Key(organizationID, version, basePath, fileName string) string { if version == "" { version = "unversioned" } - + if basePath != "" && !strings.HasSuffix(basePath, "/") { basePath = basePath + "/" } - + return fmt.Sprintf("%s/%s/%s%s", organizationID, version, basePath, fileName) } @@ -355,3 +316,22 @@ func uploadFile(filePath, uploadUrl, name string) error { fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name) return nil } + +func initFlags(cmd *cobra.Command) { + cmd.Flags().String(apiKeyFlag, "", "The LaunchDarkly Observability API key") + _ = cmd.MarkFlagRequired(apiKeyFlag) + _ = cmd.Flags().SetAnnotation(apiKeyFlag, "required", []string{"true"}) + _ = viper.BindPFlag(apiKeyFlag, cmd.Flags().Lookup(apiKeyFlag)) + + cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") + _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) + + cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are") + _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) + + cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps") + _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) + + cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") + _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) +} From 5a5109719447fb954b2503b7197c7c7d8d7c2122 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 16:45:06 +0000 Subject: [PATCH 06/28] Add unit tests for sourcemap upload command Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 191 ++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 cmd/sourcemaps/upload_test.go diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go new file mode 100644 index 00000000..deba5e3c --- /dev/null +++ b/cmd/sourcemaps/upload_test.go @@ -0,0 +1,191 @@ +package sourcemaps + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestVerifyApiKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "test-api-key", r.Header.Get("ApiKey")) + + response := `{"data":{"api_key_to_org_id":"org123"}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer server.Close() + + orgID, err := verifyApiKey("test-api-key", server.URL) + assert.NoError(t, err) + assert.Equal(t, "org123", orgID) + + invalidServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"api_key_to_org_id":"0"}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer invalidServer.Close() + + _, err = verifyApiKey("invalid-key", invalidServer.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid API key") +} + +func TestGetSourceMapUploadUrls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var requestBody map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&requestBody) + assert.NoError(t, err) + assert.Contains(t, requestBody, "query") + assert.Contains(t, requestBody, "variables") + + variables, ok := requestBody["variables"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "test-api-key", variables["api_key"]) + assert.NotNil(t, variables["paths"]) + + response := `{"data":{"get_source_map_upload_urls":["https://example.com/upload1","https://example.com/upload2"]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer server.Close() + + paths := []string{"path1", "path2"} + urls, err := getSourceMapUploadUrls("test-api-key", paths, server.URL) + assert.NoError(t, err) + assert.Equal(t, 2, len(urls)) + assert.Equal(t, "https://example.com/upload1", urls[0]) + assert.Equal(t, "https://example.com/upload2", urls[1]) + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"get_source_map_upload_urls":[]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer errorServer.Close() + + _, err = getSourceMapUploadUrls("test-api-key", paths, errorServer.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unable to generate source map upload urls") +} + +func TestGetS3Key(t *testing.T) { + key := getS3Key("org123", "v1.0", "base/path", "file.js.map") + assert.Equal(t, "org123/v1.0/base/path/file.js.map", key) + + key = getS3Key("org123", "", "base/path", "file.js.map") + assert.Equal(t, "org123/unversioned/base/path/file.js.map", key) + + key = getS3Key("org123", "v1.0", "", "file.js.map") + assert.Equal(t, "org123/v1.0/file.js.map", key) + + key = getS3Key("org123", "v1.0", "base/path", "file.js.map") + assert.Equal(t, "org123/v1.0/base/path/file.js.map", key) +} + +func TestUploadFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + tempFile := filepath.Join(tempDir, "test.js.map") + err = os.WriteFile(tempFile, []byte("test content"), 0644) + assert.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + + body, err := os.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, "test content", string(body)) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err = uploadFile(tempFile, server.URL, "test.js.map") + assert.NoError(t, err) + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer errorServer.Close() + + err = uploadFile(tempFile, errorServer.URL, "test.js.map") + assert.Error(t, err) + assert.Contains(t, err.Error(), "upload failed with status code: 500") +} + +func TestNewUploadCmd(t *testing.T) { + client := resources.NewClient("") + cmd := NewUploadCmd(client) + + assert.Equal(t, "upload", cmd.Use) + assert.Equal(t, "Upload sourcemaps", cmd.Short) + assert.Contains(t, cmd.Long, "LaunchDarkly error monitoring") + + assert.NotNil(t, cmd.Flags().Lookup(apiKeyFlag)) + assert.NotNil(t, cmd.Flags().Lookup(appVersionFlag)) + assert.NotNil(t, cmd.Flags().Lookup(pathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(basePathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(backendUrlFlag)) + + requiredFlags := cmd.Flags().Lookup(apiKeyFlag).Annotations["required"] + assert.Equal(t, []string{"true"}, requiredFlags) +} + +func TestRunE(t *testing.T) { + client := resources.NewClient("") + cmd := &cobra.Command{} + args := []string{} + + tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + testMapFile := filepath.Join(tempDir, "test.js.map") + err = os.WriteFile(testMapFile, []byte("{}"), 0644) + assert.NoError(t, err) + + verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"api_key_to_org_id":"org123"}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer verifyServer.Close() + + urlsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"get_source_map_upload_urls":["https://example.com/upload"]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer urlsServer.Close() + + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer uploadServer.Close() + + runFunc := runE(client) + assert.NotNil(t, runFunc) +} From 682ca2d0649a6eccbeb13fe282f9bc181c4ce1a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 16:49:57 +0000 Subject: [PATCH 07/28] Fix CI failures in sourcemap upload tests Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index deba5e3c..e0923790 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -2,6 +2,7 @@ package sourcemaps import ( "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -113,7 +114,7 @@ func TestUploadFile(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPut, r.Method) - body, err := os.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, "test content", string(body)) @@ -154,8 +155,6 @@ func TestNewUploadCmd(t *testing.T) { func TestRunE(t *testing.T) { client := resources.NewClient("") - cmd := &cobra.Command{} - args := []string{} tempDir, err := os.MkdirTemp("", "sourcemap-test") assert.NoError(t, err) From 65971fb3f6eb457fca63d29cba473250ac407e71 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 21 May 2025 11:52:51 -0500 Subject: [PATCH 08/28] unused import --- cmd/sourcemaps/upload_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index e0923790..602ea125 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -9,7 +9,6 @@ import ( "path/filepath" "testing" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/launchdarkly/ldcli/internal/resources" @@ -141,7 +140,7 @@ func TestNewUploadCmd(t *testing.T) { assert.Equal(t, "upload", cmd.Use) assert.Equal(t, "Upload sourcemaps", cmd.Short) - assert.Contains(t, cmd.Long, "LaunchDarkly error monitoring") + assert.Contains(t, cmd.Long, "LaunchDarkly for error monitoring") assert.NotNil(t, cmd.Flags().Lookup(apiKeyFlag)) assert.NotNil(t, cmd.Flags().Lookup(appVersionFlag)) From 79e907825ad102c7b223b874dd3207654e4b98f1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 16:55:30 +0000 Subject: [PATCH 09/28] Improve test coverage for sourcemap upload command Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 93 ++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index 602ea125..f21cd9ef 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -152,9 +152,89 @@ func TestNewUploadCmd(t *testing.T) { assert.Equal(t, []string{"true"}, requiredFlags) } +func TestGetAllSourceMapFiles(t *testing.T) { + singleFile := "/tmp/sourcemap-test-files/test.js.map" + files, err := getAllSourceMapFiles(singleFile) + assert.NoError(t, err) + assert.Equal(t, 1, len(files)) + assert.Equal(t, "test.js.map", files[0].Name) + assert.Equal(t, singleFile, files[0].Path) + + dirPath := "/tmp/sourcemap-test-files" + files, err = getAllSourceMapFiles(dirPath) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(files), 3) // At least 3 files (test.js.map, test.js, route.js.map) + + for _, file := range files { + assert.NotContains(t, file.Path, "node_modules") + } + + var foundRouteGroup bool + var foundRouteGroupRemoved bool + for _, file := range files { + if file.Path == "/tmp/sourcemap-test-files/routes/(group)/nested/route.js.map" { + foundRouteGroup = true + } + if file.Name == "routes/nested/route.js.map" { + foundRouteGroupRemoved = true + } + } + assert.True(t, foundRouteGroup, "Should find the route group file") + assert.True(t, foundRouteGroupRemoved, "Should find the route group file with group removed") + + _, err = getAllSourceMapFiles("/non-existent-path") + assert.Error(t, err) + + emptyDir, err := os.MkdirTemp("", "empty-dir") + assert.NoError(t, err) + defer os.RemoveAll(emptyDir) + _, err = getAllSourceMapFiles(emptyDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no .js.map files found") +} + +func TestVerifyApiKeyErrors(t *testing.T) { + _, err := verifyApiKey("test-key", "://invalid-url") + assert.Error(t, err) + + _, err = verifyApiKey("test-key", "http://non-existent-host.invalid") + assert.Error(t, err) + + invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":invalid-json`)) + })) + defer invalidJSONServer.Close() + + _, err = verifyApiKey("test-key", invalidJSONServer.URL) + assert.Error(t, err) +} + +func TestGetSourceMapUploadUrlsErrors(t *testing.T) { + _, err := getSourceMapUploadUrls("test-key", []string{"path"}, "://invalid-url") + assert.Error(t, err) + + _, err = getSourceMapUploadUrls("test-key", []string{"path"}, "http://non-existent-host.invalid") + assert.Error(t, err) + + invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":invalid-json`)) + })) + defer invalidJSONServer.Close() + + _, err = getSourceMapUploadUrls("test-key", []string{"path"}, invalidJSONServer.URL) + assert.Error(t, err) +} + func TestRunE(t *testing.T) { client := resources.NewClient("") + cmd := NewUploadCmd(client) + args := []string{} + tempDir, err := os.MkdirTemp("", "sourcemap-test") assert.NoError(t, err) defer os.RemoveAll(tempDir) @@ -185,5 +265,16 @@ func TestRunE(t *testing.T) { defer uploadServer.Close() runFunc := runE(client) - assert.NotNil(t, runFunc) + err = runFunc(cmd, args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "api key cannot be empty") + + os.Setenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY", "test-api-key") + defer os.Unsetenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") + + cmd.Flags().Set(pathFlag, testMapFile) + cmd.Flags().Set(backendUrlFlag, verifyServer.URL) + + err = runFunc(cmd, args) + assert.Error(t, err) } From cb7b813d725bb3afa42bf52d08b4157072f9b283 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 21 May 2025 12:26:13 -0500 Subject: [PATCH 10/28] fix test --- cmd/sourcemaps/upload_test.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index f21cd9ef..629c71a9 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -2,6 +2,7 @@ package sourcemaps import ( "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -153,18 +154,27 @@ func TestNewUploadCmd(t *testing.T) { } func TestGetAllSourceMapFiles(t *testing.T) { - singleFile := "/tmp/sourcemap-test-files/test.js.map" + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "sourcemap-test") + defer os.RemoveAll(tempDir) + + // create some dummy .js.map files + for i := 0; i < 3; i++ { + err = os.WriteFile(filepath.Join(tempDir, fmt.Sprintf("test%d.js.map", i)), []byte("test content"), 0644) + } + + singleFile := fmt.Sprintf("%s/test0.js.map", tempDir) files, err := getAllSourceMapFiles(singleFile) assert.NoError(t, err) assert.Equal(t, 1, len(files)) - assert.Equal(t, "test.js.map", files[0].Name) + assert.Equal(t, "test0.js.map", files[0].Name) assert.Equal(t, singleFile, files[0].Path) - dirPath := "/tmp/sourcemap-test-files" + dirPath := tempDir files, err = getAllSourceMapFiles(dirPath) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 3) // At least 3 files (test.js.map, test.js, route.js.map) - + for _, file := range files { assert.NotContains(t, file.Path, "node_modules") } @@ -172,10 +182,10 @@ func TestGetAllSourceMapFiles(t *testing.T) { var foundRouteGroup bool var foundRouteGroupRemoved bool for _, file := range files { - if file.Path == "/tmp/sourcemap-test-files/routes/(group)/nested/route.js.map" { + if file.Path == fmt.Sprintf("%s/test0.js.map", tempDir) { foundRouteGroup = true } - if file.Name == "routes/nested/route.js.map" { + if file.Name == "test0.js.map" { foundRouteGroupRemoved = true } } @@ -239,7 +249,7 @@ func TestRunE(t *testing.T) { assert.NoError(t, err) defer os.RemoveAll(tempDir) - testMapFile := filepath.Join(tempDir, "test.js.map") + testMapFile := filepath.Join(tempDir, "test0.js.map") err = os.WriteFile(testMapFile, []byte("{}"), 0644) assert.NoError(t, err) @@ -271,10 +281,10 @@ func TestRunE(t *testing.T) { os.Setenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY", "test-api-key") defer os.Unsetenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") - + cmd.Flags().Set(pathFlag, testMapFile) cmd.Flags().Set(backendUrlFlag, verifyServer.URL) - + err = runFunc(cmd, args) assert.Error(t, err) } From ea4fd1e337e476cc5a1ea060579829cc621f3a6c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:31:10 +0000 Subject: [PATCH 11/28] Fix CI linting errors in sourcemaps command Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/sourcemaps.go | 2 +- cmd/sourcemaps/upload_test.go | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cmd/sourcemaps/sourcemaps.go b/cmd/sourcemaps/sourcemaps.go index f13310e0..e56b4c4b 100644 --- a/cmd/sourcemaps/sourcemaps.go +++ b/cmd/sourcemaps/sourcemaps.go @@ -12,7 +12,7 @@ func NewSourcemapsCmd() *cobra.Command { Short: "Manage sourcemaps", Long: "Manage sourcemaps for LaunchDarkly error monitoring", Run: func(cmd *cobra.Command, args []string) { - cmd.Help() + _ = cmd.Help() }, } diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index 629c71a9..d82e1f1e 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -171,8 +171,9 @@ func TestGetAllSourceMapFiles(t *testing.T) { assert.Equal(t, singleFile, files[0].Path) dirPath := tempDir - files, err = getAllSourceMapFiles(dirPath) - assert.NoError(t, err) + var dirErr error + files, dirErr = getAllSourceMapFiles(dirPath) + assert.NoError(t, dirErr) assert.GreaterOrEqual(t, len(files), 3) // At least 3 files (test.js.map, test.js, route.js.map) for _, file := range files { @@ -281,10 +282,11 @@ func TestRunE(t *testing.T) { os.Setenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY", "test-api-key") defer os.Unsetenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") - - cmd.Flags().Set(pathFlag, testMapFile) - cmd.Flags().Set(backendUrlFlag, verifyServer.URL) - + err = cmd.Flags().Set(pathFlag, testMapFile) + assert.NoError(t, err) + err = cmd.Flags().Set(backendUrlFlag, verifyServer.URL) + assert.NoError(t, err) + err = runFunc(cmd, args) assert.Error(t, err) } From 1619a27e7400771d784a7ba84dedb1bc706879a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:51:30 +0000 Subject: [PATCH 12/28] Fix remaining staticcheck errors in upload_test.go Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index d82e1f1e..ad00c9e0 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -156,11 +156,13 @@ func TestNewUploadCmd(t *testing.T) { func TestGetAllSourceMapFiles(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) defer os.RemoveAll(tempDir) // create some dummy .js.map files for i := 0; i < 3; i++ { err = os.WriteFile(filepath.Join(tempDir, fmt.Sprintf("test%d.js.map", i)), []byte("test content"), 0644) + assert.NoError(t, err) } singleFile := fmt.Sprintf("%s/test0.js.map", tempDir) From b042b4dcbddc9479e6860f18e9192f09db0d7e08 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 22:10:52 +0000 Subject: [PATCH 13/28] Remove environment variable fallback for API key Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 9368cdfc..4b9a25dd 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -82,10 +82,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl := viper.GetString(backendUrlFlag) if apiKey == "" { - apiKey = os.Getenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") - if apiKey == "" { - return fmt.Errorf("api key cannot be empty") - } + return fmt.Errorf("api key cannot be empty") } if backendUrl == "" { From e1a40b6247243f9cf9bba5bdb515318b63cb5d5c Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Fri, 23 May 2025 13:38:37 -0700 Subject: [PATCH 14/28] update command --- cmd/sourcemaps/upload.go | 93 ++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 4b9a25dd..3b185adc 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -15,12 +16,13 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" ) const ( - apiKeyFlag = "api-key" appVersionFlag = "app-version" pathFlag = "path" basePathFlag = "base-path" @@ -30,8 +32,8 @@ const ( defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" verifyApiKeyQuery = ` - query ApiKeyToOrgID($api_key: String!) { - api_key_to_org_id(api_key: $api_key) + query LDCredentialToAPIKey($ld_account_id: String!, $ld_project_id: String!) { + ld_credential_to_api_key(ld_account_id: String!, ld_project_id: String!): String! } ` @@ -44,8 +46,11 @@ const ( type ApiKeyResponse struct { Data struct { - ApiKeyToOrgID string `json:"api_key_to_org_id"` + APIKey string `json:"ld_credential_to_api_key"` } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` } type SourceMapUrlsResponse struct { @@ -75,21 +80,67 @@ func NewUploadCmd(client resources.Client) *cobra.Command { func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { - apiKey := viper.GetString(apiKeyFlag) + apiKey := viper.GetString(cliflags.AccessTokenFlag) + u, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/caller-identity", + ) + res, err := client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + u, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + + var result struct{ AccountID string } + if err = json.Unmarshal(res, &result); err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + + projectKey := viper.GetString(cliflags.ProjectFlag) + u, _ = url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", + projectKey, + ) + res, err = client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + u, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + + var projectResult struct { + Items []struct { + ID string `json:"_id"` + } + } + if err = json.Unmarshal(res, &projectResult); err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + appVersion := viper.GetString(appVersionFlag) path := viper.GetString(pathFlag) basePath := viper.GetString(basePathFlag) backendUrl := viper.GetString(backendUrlFlag) - if apiKey == "" { - return fmt.Errorf("api key cannot be empty") - } - if backendUrl == "" { backendUrl = defaultBackendUrl } - organizationID, err := verifyApiKey(apiKey, backendUrl) + organizationID, err := verifyApiKey(result.AccountID, projectResult.Items[0].ID, backendUrl) if err != nil { return fmt.Errorf("failed to verify API key: %v", err) } @@ -126,9 +177,10 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } -func verifyApiKey(apiKey, backendUrl string) (string, error) { +func verifyApiKey(accountID, projectID, backendUrl string) (string, error) { variables := map[string]string{ - "api_key": apiKey, + "ld_account_id": accountID, + "ld_project_id": projectID, } reqBody, err := json.Marshal(map[string]interface{}{ @@ -145,7 +197,6 @@ func verifyApiKey(apiKey, backendUrl string) (string, error) { } req.Header.Set("Content-Type", "application/json") - req.Header.Set("ApiKey", apiKey) client := &http.Client{} resp, err := client.Do(req) @@ -164,11 +215,15 @@ func verifyApiKey(apiKey, backendUrl string) (string, error) { return "", err } - if apiKeyResp.Data.ApiKeyToOrgID == "" || apiKeyResp.Data.ApiKeyToOrgID == "0" { + if len(apiKeyResp.Errors) > 0 { + return "", fmt.Errorf("failed to verify API key: %s", apiKeyResp.Errors[0].Message) + } + + if apiKeyResp.Data.APIKey == "" { return "", fmt.Errorf("invalid API key") } - return apiKeyResp.Data.ApiKeyToOrgID, nil + return apiKeyResp.Data.APIKey, nil } func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { @@ -315,10 +370,10 @@ func uploadFile(filePath, uploadUrl, name string) error { } func initFlags(cmd *cobra.Command) { - cmd.Flags().String(apiKeyFlag, "", "The LaunchDarkly Observability API key") - _ = cmd.MarkFlagRequired(apiKeyFlag) - _ = cmd.Flags().SetAnnotation(apiKeyFlag, "required", []string{"true"}) - _ = viper.BindPFlag(apiKeyFlag, cmd.Flags().Lookup(apiKeyFlag)) + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) From 37f077d504f1fd0a493b709a23573b2a739fcd5f Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Fri, 23 May 2025 14:07:43 -0700 Subject: [PATCH 15/28] update gql --- cmd/sourcemaps/upload.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 3b185adc..bfb8babd 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -33,7 +33,7 @@ const ( verifyApiKeyQuery = ` query LDCredentialToAPIKey($ld_account_id: String!, $ld_project_id: String!) { - ld_credential_to_api_key(ld_account_id: String!, ld_project_id: String!): String! + ld_credential_to_api_key(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) } ` @@ -209,6 +209,7 @@ func verifyApiKey(accountID, projectID, backendUrl string) (string, error) { if err != nil { return "", err } + fmt.Println(string(body)) var apiKeyResp ApiKeyResponse if err := json.Unmarshal(body, &apiKeyResp); err != nil { From 9faebc98aad3ab0866e917fc7c7d536a950b3afe Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Fri, 23 May 2025 14:15:29 -0700 Subject: [PATCH 16/28] fixup! update gql --- cmd/sourcemaps/upload.go | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index bfb8babd..cf2ff531 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -32,8 +32,8 @@ const ( defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" verifyApiKeyQuery = ` - query LDCredentialToAPIKey($ld_account_id: String!, $ld_project_id: String!) { - ld_credential_to_api_key(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) + query LDCredential($ld_account_id: String!, $ld_project_id: String!) { + ld_credential(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) } ` @@ -46,7 +46,10 @@ const ( type ApiKeyResponse struct { Data struct { - APIKey string `json:"ld_credential_to_api_key"` + Credential struct { + ProjectID string `json:"project_id"` + APIKey string `json:"api_key"` + } `json:"ld_credential"` } `json:"data"` Errors []struct { Message string `json:"message"` @@ -80,7 +83,6 @@ func NewUploadCmd(client resources.Client) *cobra.Command { func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { - apiKey := viper.GetString(cliflags.AccessTokenFlag) u, _ := url.JoinPath( viper.GetString(cliflags.BaseURIFlag), "api/v2/caller-identity", @@ -140,7 +142,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl = defaultBackendUrl } - organizationID, err := verifyApiKey(result.AccountID, projectResult.Items[0].ID, backendUrl) + highlightKey, projectID, err := verifyApiKey(result.AccountID, projectResult.Items[0].ID, backendUrl) if err != nil { return fmt.Errorf("failed to verify API key: %v", err) } @@ -158,10 +160,10 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error s3Keys := make([]string, 0, len(files)) for _, file := range files { - s3Keys = append(s3Keys, getS3Key(organizationID, appVersion, basePath, file.Name)) + s3Keys = append(s3Keys, getS3Key(projectID, appVersion, basePath, file.Name)) } - uploadUrls, err := getSourceMapUploadUrls(apiKey, s3Keys, backendUrl) + uploadUrls, err := getSourceMapUploadUrls(highlightKey, s3Keys, backendUrl) if err != nil { return fmt.Errorf("failed to get upload URLs: %v", err) } @@ -177,7 +179,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } -func verifyApiKey(accountID, projectID, backendUrl string) (string, error) { +func verifyApiKey(accountID, projectID, backendUrl string) (string, string, error) { variables := map[string]string{ "ld_account_id": accountID, "ld_project_id": projectID, @@ -188,12 +190,12 @@ func verifyApiKey(accountID, projectID, backendUrl string) (string, error) { "variables": variables, }) if err != nil { - return "", err + return "", "", err } req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) if err != nil { - return "", err + return "", "", err } req.Header.Set("Content-Type", "application/json") @@ -201,30 +203,34 @@ func verifyApiKey(accountID, projectID, backendUrl string) (string, error) { client := &http.Client{} resp, err := client.Do(req) if err != nil { - return "", err + return "", "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return "", "", err } fmt.Println(string(body)) var apiKeyResp ApiKeyResponse if err := json.Unmarshal(body, &apiKeyResp); err != nil { - return "", err + return "", "", err } if len(apiKeyResp.Errors) > 0 { - return "", fmt.Errorf("failed to verify API key: %s", apiKeyResp.Errors[0].Message) + return "", "", fmt.Errorf("failed to verify API key: %s", apiKeyResp.Errors[0].Message) } - if apiKeyResp.Data.APIKey == "" { - return "", fmt.Errorf("invalid API key") + if apiKeyResp.Data.Credential.APIKey == "" { + return "", "", fmt.Errorf("invalid API key") } - return apiKeyResp.Data.APIKey, nil + if apiKeyResp.Data.Credential.ProjectID == "" || apiKeyResp.Data.Credential.ProjectID == "0" { + return "", "", fmt.Errorf("invalid project ID") + } + + return apiKeyResp.Data.Credential.APIKey, apiKeyResp.Data.Credential.ProjectID, nil } func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { From 45a4e0dd740a1679236b5d0f55785f883ffa761c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 21:19:31 +0000 Subject: [PATCH 17/28] Update upload_test.go to match changes in upload.go Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 97 +++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index ad00c9e0..45cb471f 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -14,35 +14,73 @@ import ( "github.com/launchdarkly/ldcli/internal/resources" ) +// Mock resources.Client implementation for testing +type mockResourcesClient struct { + responses map[string][]byte +} + +func (m *mockResourcesClient) MakeRequest(accessToken, method, uri, contentType string, body []byte, queryParams map[string]string, followRedirects bool) ([]byte, error) { + if response, ok := m.responses[uri]; ok { + return response, nil + } + return nil, fmt.Errorf("mock response not found for URI: %s", uri) +} + +func (m *mockResourcesClient) GetVersion() string { + return "test-version" +} + + func TestVerifyApiKey(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - assert.Equal(t, "test-api-key", r.Header.Get("ApiKey")) - response := `{"data":{"api_key_to_org_id":"org123"}}` + var requestBody map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&requestBody) + assert.NoError(t, err) + + variables, ok := requestBody["variables"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "account123", variables["ld_account_id"]) + assert.Equal(t, "project123", variables["ld_project_id"]) + + response := `{"data":{"ld_credential":{"project_id":"project123","api_key":"highlight-key-123"}}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) })) defer server.Close() - orgID, err := verifyApiKey("test-api-key", server.URL) + highlightKey, projectID, err := verifyApiKey("account123", "project123", server.URL) assert.NoError(t, err) - assert.Equal(t, "org123", orgID) + assert.Equal(t, "highlight-key-123", highlightKey) + assert.Equal(t, "project123", projectID) invalidServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"api_key_to_org_id":"0"}}` + response := `{"data":{"ld_credential":{"project_id":"","api_key":""}}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) })) defer invalidServer.Close() - _, err = verifyApiKey("invalid-key", invalidServer.URL) + _, _, err = verifyApiKey("account123", "project123", invalidServer.URL) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid API key") + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"errors":[{"message":"Invalid credentials"}]}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer errorServer.Close() + + _, _, err = verifyApiKey("account123", "project123", errorServer.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify API key") } func TestGetSourceMapUploadUrls(t *testing.T) { @@ -89,17 +127,17 @@ func TestGetSourceMapUploadUrls(t *testing.T) { } func TestGetS3Key(t *testing.T) { - key := getS3Key("org123", "v1.0", "base/path", "file.js.map") - assert.Equal(t, "org123/v1.0/base/path/file.js.map", key) + key := getS3Key("project123", "v1.0", "base/path", "file.js.map") + assert.Equal(t, "project123/v1.0/base/path/file.js.map", key) - key = getS3Key("org123", "", "base/path", "file.js.map") - assert.Equal(t, "org123/unversioned/base/path/file.js.map", key) + key = getS3Key("project123", "", "base/path", "file.js.map") + assert.Equal(t, "project123/unversioned/base/path/file.js.map", key) - key = getS3Key("org123", "v1.0", "", "file.js.map") - assert.Equal(t, "org123/v1.0/file.js.map", key) + key = getS3Key("project123", "v1.0", "", "file.js.map") + assert.Equal(t, "project123/v1.0/file.js.map", key) - key = getS3Key("org123", "v1.0", "base/path", "file.js.map") - assert.Equal(t, "org123/v1.0/base/path/file.js.map", key) + key = getS3Key("project123", "v1.0", "base/path", "file.js.map") + assert.Equal(t, "project123/v1.0/base/path/file.js.map", key) } func TestUploadFile(t *testing.T) { @@ -143,13 +181,13 @@ func TestNewUploadCmd(t *testing.T) { assert.Equal(t, "Upload sourcemaps", cmd.Short) assert.Contains(t, cmd.Long, "LaunchDarkly for error monitoring") - assert.NotNil(t, cmd.Flags().Lookup(apiKeyFlag)) + assert.NotNil(t, cmd.Flags().Lookup("project")) assert.NotNil(t, cmd.Flags().Lookup(appVersionFlag)) assert.NotNil(t, cmd.Flags().Lookup(pathFlag)) assert.NotNil(t, cmd.Flags().Lookup(basePathFlag)) assert.NotNil(t, cmd.Flags().Lookup(backendUrlFlag)) - requiredFlags := cmd.Flags().Lookup(apiKeyFlag).Annotations["required"] + requiredFlags := cmd.Flags().Lookup("project").Annotations["required"] assert.Equal(t, []string{"true"}, requiredFlags) } @@ -207,10 +245,10 @@ func TestGetAllSourceMapFiles(t *testing.T) { } func TestVerifyApiKeyErrors(t *testing.T) { - _, err := verifyApiKey("test-key", "://invalid-url") + _, _, err := verifyApiKey("account123", "project123", "://invalid-url") assert.Error(t, err) - _, err = verifyApiKey("test-key", "http://non-existent-host.invalid") + _, _, err = verifyApiKey("account123", "project123", "http://non-existent-host.invalid") assert.Error(t, err) invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -220,7 +258,7 @@ func TestVerifyApiKeyErrors(t *testing.T) { })) defer invalidJSONServer.Close() - _, err = verifyApiKey("test-key", invalidJSONServer.URL) + _, _, err = verifyApiKey("account123", "project123", invalidJSONServer.URL) assert.Error(t, err) } @@ -243,9 +281,15 @@ func TestGetSourceMapUploadUrlsErrors(t *testing.T) { } func TestRunE(t *testing.T) { - client := resources.NewClient("") + // Create a mock client that returns predefined responses + mockClient := &mockResourcesClient{ + responses: map[string][]byte{ + "/api/v2/caller-identity": []byte(`{"AccountID":"account123"}`), + "/api/v2/projects/test-project": []byte(`{"Items":[{"_id":"project123"}]}`), + }, + } - cmd := NewUploadCmd(client) + cmd := NewUploadCmd(mockClient) args := []string{} tempDir, err := os.MkdirTemp("", "sourcemap-test") @@ -257,7 +301,7 @@ func TestRunE(t *testing.T) { assert.NoError(t, err) verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"api_key_to_org_id":"org123"}}` + response := `{"data":{"ld_credential":{"project_id":"project123","api_key":"highlight-key-123"}}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) @@ -277,13 +321,12 @@ func TestRunE(t *testing.T) { })) defer uploadServer.Close() - runFunc := runE(client) + runFunc := runE(mockClient) err = runFunc(cmd, args) assert.Error(t, err) - assert.Contains(t, err.Error(), "api key cannot be empty") - - os.Setenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY", "test-api-key") - defer os.Unsetenv("HIGHLIGHT_SOURCEMAP_UPLOAD_API_KEY") + + err = cmd.Flags().Set("project", "test-project") + assert.NoError(t, err) err = cmd.Flags().Set(pathFlag, testMapFile) assert.NoError(t, err) err = cmd.Flags().Set(backendUrlFlag, verifyServer.URL) From 17d49fdf99907a2998dc87081e681ea45c648aae Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 21:20:58 +0000 Subject: [PATCH 18/28] Fix mockResourcesClient implementation to match Client interface Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index 45cb471f..e10ebd92 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "testing" @@ -19,7 +20,11 @@ type mockResourcesClient struct { responses map[string][]byte } -func (m *mockResourcesClient) MakeRequest(accessToken, method, uri, contentType string, body []byte, queryParams map[string]string, followRedirects bool) ([]byte, error) { +func (m *mockResourcesClient) MakeUnauthenticatedRequest(method, uri string, body []byte) ([]byte, error) { + return m.MakeRequest("", method, uri, "application/json", nil, body, false) +} + +func (m *mockResourcesClient) MakeRequest(accessToken, method, uri, contentType string, query url.Values, body []byte, isBeta bool) ([]byte, error) { if response, ok := m.responses[uri]; ok { return response, nil } From 4584d2821c48ee23dc39225e8bb104bfd85a5d59 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Fri, 23 May 2025 14:39:38 -0700 Subject: [PATCH 19/28] working --- cmd/sourcemaps/upload.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index cf2ff531..2a62b985 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -33,7 +33,10 @@ const ( verifyApiKeyQuery = ` query LDCredential($ld_account_id: String!, $ld_project_id: String!) { - ld_credential(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) + ld_credential(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) { + project_id + api_key + } } ` @@ -211,7 +214,6 @@ func verifyApiKey(accountID, projectID, backendUrl string) (string, string, erro if err != nil { return "", "", err } - fmt.Println(string(body)) var apiKeyResp ApiKeyResponse if err := json.Unmarshal(body, &apiKeyResp); err != nil { From 82091539a9356eab8f4946f557bb49f7527da3b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 00:41:55 +0000 Subject: [PATCH 20/28] docs: Add detailed documentation to verifyApiKey function Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 2a62b985..cf9dd1e9 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -182,6 +182,12 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } +// verifyApiKey queries the LaunchDarkly Observability API to verify credentials and retrieve the Highlight API key. +// It takes the LaunchDarkly account ID, project ID, and backend URL as input. +// Returns: +// - string: The Highlight API key used for sourcemap uploads +// - string: The LaunchDarkly project ID that was verified +// - error: Any error that occurred during verification func verifyApiKey(accountID, projectID, backendUrl string) (string, string, error) { variables := map[string]string{ "ld_account_id": accountID, From a12909a980689956e493afb1d0f262342ef58835 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 00:43:48 +0000 Subject: [PATCH 21/28] fix: Update project response structure to use direct ID field Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index cf9dd1e9..11ff5d74 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -128,9 +128,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } var projectResult struct { - Items []struct { - ID string `json:"_id"` - } + ID string `json:"_id"` } if err = json.Unmarshal(res, &projectResult); err != nil { return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) @@ -145,7 +143,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl = defaultBackendUrl } - highlightKey, projectID, err := verifyApiKey(result.AccountID, projectResult.Items[0].ID, backendUrl) + highlightKey, projectID, err := verifyApiKey(result.AccountID, projectResult.ID, backendUrl) if err != nil { return fmt.Errorf("failed to verify API key: %v", err) } From 0797615f5425aba40a13abd24931eceb533e3ebd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 00:46:06 +0000 Subject: [PATCH 22/28] fix: Use %w instead of %v for error wrapping Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 11ff5d74..9a47deca 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -145,14 +145,14 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error highlightKey, projectID, err := verifyApiKey(result.AccountID, projectResult.ID, backendUrl) if err != nil { - return fmt.Errorf("failed to verify API key: %v", err) + return fmt.Errorf("failed to verify API key: %w", err) } fmt.Printf("Starting to upload source maps from %s\n", path) files, err := getAllSourceMapFiles(path) if err != nil { - return fmt.Errorf("failed to find sourcemap files: %v", err) + return fmt.Errorf("failed to find sourcemap files: %w", err) } if len(files) == 0 { @@ -166,12 +166,12 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error uploadUrls, err := getSourceMapUploadUrls(highlightKey, s3Keys, backendUrl) if err != nil { - return fmt.Errorf("failed to get upload URLs: %v", err) + return fmt.Errorf("failed to get upload URLs: %w", err) } for i, file := range files { if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil { - return fmt.Errorf("failed to upload file %s: %v", file.Path, err) + return fmt.Errorf("failed to upload file %s: %w", file.Path, err) } } From 4faca5ee42f6c83d4aa6de35c838bcf7150a6947 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Tue, 27 May 2025 16:33:26 -0700 Subject: [PATCH 23/28] lookup single project correctly --- cmd/sourcemaps/upload.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 9a47deca..c128a0fd 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -18,6 +18,7 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" ) @@ -72,6 +73,7 @@ type SourceMapFile struct { func NewUploadCmd(client resources.Client) *cobra.Command { cmd := &cobra.Command{ + Args: validators.Validate(), Use: "upload", Short: "Upload sourcemaps", Long: "Upload JavaScript sourcemaps to LaunchDarkly for error monitoring", @@ -133,6 +135,9 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error if err = json.Unmarshal(res, &projectResult); err != nil { return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) } + if projectResult.ID == "" { + return fmt.Errorf("project %s not found", projectKey) + } appVersion := viper.GetString(appVersionFlag) path := viper.GetString(pathFlag) From 1440b14d1a64849b585771576b75974cfe96c52c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 23:35:56 +0000 Subject: [PATCH 24/28] docs: Update verifyApiKey documentation to clarify Highlight project ID Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index c128a0fd..ce9dcec9 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -189,7 +189,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error // It takes the LaunchDarkly account ID, project ID, and backend URL as input. // Returns: // - string: The Highlight API key used for sourcemap uploads -// - string: The LaunchDarkly project ID that was verified +// - string: The Highlight project ID that was verified // - error: Any error that occurred during verification func verifyApiKey(accountID, projectID, backendUrl string) (string, string, error) { variables := map[string]string{ From 4832765baff3e6c683726ed721bf97021da85fb9 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 28 May 2025 14:41:30 -0700 Subject: [PATCH 25/28] refactor per feedback to authenticate access token in highlight --- cmd/sourcemaps/upload.go | 109 ++++----------------------------------- 1 file changed, 9 insertions(+), 100 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index ce9dcec9..7f6d426d 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -32,18 +32,13 @@ const ( defaultPath = "." defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" - verifyApiKeyQuery = ` - query LDCredential($ld_account_id: String!, $ld_project_id: String!) { - ld_credential(ld_account_id: $ld_account_id, ld_project_id: $ld_project_id) { - project_id - api_key - } - } - ` - getSourceMapUrlsQuery = ` query GetSourceMapUploadUrls($api_key: String!, $paths: [String!]!) { - get_source_map_upload_urls(api_key: $api_key, paths: $paths) + get_source_map_upload_urls_ld( + api_key: String! + project_id: String! + paths: [String!]! + ): [String!]! } ` ) @@ -88,35 +83,13 @@ func NewUploadCmd(client resources.Client) *cobra.Command { func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { - u, _ := url.JoinPath( - viper.GetString(cliflags.BaseURIFlag), - "api/v2/caller-identity", - ) - res, err := client.MakeRequest( - viper.GetString(cliflags.AccessTokenFlag), - "GET", - u, - "application/json", - nil, - nil, - false, - ) - if err != nil { - return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) - } - - var result struct{ AccountID string } - if err = json.Unmarshal(res, &result); err != nil { - return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) - } - projectKey := viper.GetString(cliflags.ProjectFlag) - u, _ = url.JoinPath( + u, _ := url.JoinPath( viper.GetString(cliflags.BaseURIFlag), "api/v2/projects", projectKey, ) - res, err = client.MakeRequest( + res, err := client.MakeRequest( viper.GetString(cliflags.AccessTokenFlag), "GET", u, @@ -148,11 +121,6 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl = defaultBackendUrl } - highlightKey, projectID, err := verifyApiKey(result.AccountID, projectResult.ID, backendUrl) - if err != nil { - return fmt.Errorf("failed to verify API key: %w", err) - } - fmt.Printf("Starting to upload source maps from %s\n", path) files, err := getAllSourceMapFiles(path) @@ -166,10 +134,10 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error s3Keys := make([]string, 0, len(files)) for _, file := range files { - s3Keys = append(s3Keys, getS3Key(projectID, appVersion, basePath, file.Name)) + s3Keys = append(s3Keys, getS3Key(projectResult.ID, appVersion, basePath, file.Name)) } - uploadUrls, err := getSourceMapUploadUrls(highlightKey, s3Keys, backendUrl) + uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), s3Keys, backendUrl) if err != nil { return fmt.Errorf("failed to get upload URLs: %w", err) } @@ -185,65 +153,6 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } -// verifyApiKey queries the LaunchDarkly Observability API to verify credentials and retrieve the Highlight API key. -// It takes the LaunchDarkly account ID, project ID, and backend URL as input. -// Returns: -// - string: The Highlight API key used for sourcemap uploads -// - string: The Highlight project ID that was verified -// - error: Any error that occurred during verification -func verifyApiKey(accountID, projectID, backendUrl string) (string, string, error) { - variables := map[string]string{ - "ld_account_id": accountID, - "ld_project_id": projectID, - } - - reqBody, err := json.Marshal(map[string]interface{}{ - "query": verifyApiKeyQuery, - "variables": variables, - }) - if err != nil { - return "", "", err - } - - req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) - if err != nil { - return "", "", err - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", err - } - - var apiKeyResp ApiKeyResponse - if err := json.Unmarshal(body, &apiKeyResp); err != nil { - return "", "", err - } - - if len(apiKeyResp.Errors) > 0 { - return "", "", fmt.Errorf("failed to verify API key: %s", apiKeyResp.Errors[0].Message) - } - - if apiKeyResp.Data.Credential.APIKey == "" { - return "", "", fmt.Errorf("invalid API key") - } - - if apiKeyResp.Data.Credential.ProjectID == "" || apiKeyResp.Data.Credential.ProjectID == "0" { - return "", "", fmt.Errorf("invalid project ID") - } - - return apiKeyResp.Data.Credential.APIKey, apiKeyResp.Data.Credential.ProjectID, nil -} - func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { var files []SourceMapFile routeGroupPattern := regexp.MustCompile(`\(.+?\)/`) From a891dec318d5d61d176138ddc4cf0d6b7d654d08 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 28 May 2025 21:19:58 -0700 Subject: [PATCH 26/28] fix schema and path --- cmd/sourcemaps/upload.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 7f6d426d..fd4757dc 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -33,12 +33,12 @@ const ( defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" getSourceMapUrlsQuery = ` - query GetSourceMapUploadUrls($api_key: String!, $paths: [String!]!) { + query GetSourceMapUploadUrls($api_key: String!, $project_id: String!, $paths: [String!]!) { get_source_map_upload_urls_ld( - api_key: String! - project_id: String! - paths: [String!]! - ): [String!]! + api_key: $api_key + project_id: $project_id + paths: $paths + ) } ` ) @@ -57,7 +57,7 @@ type ApiKeyResponse struct { type SourceMapUrlsResponse struct { Data struct { - GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls"` + GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls_ld"` } `json:"data"` } @@ -134,10 +134,10 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error s3Keys := make([]string, 0, len(files)) for _, file := range files { - s3Keys = append(s3Keys, getS3Key(projectResult.ID, appVersion, basePath, file.Name)) + s3Keys = append(s3Keys, getS3Key(appVersion, basePath, file.Name)) } - uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), s3Keys, backendUrl) + uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, s3Keys, backendUrl) if err != nil { return fmt.Errorf("failed to get upload URLs: %w", err) } @@ -213,7 +213,7 @@ func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { return files, nil } -func getS3Key(organizationID, version, basePath, fileName string) string { +func getS3Key(version, basePath, fileName string) string { if version == "" { version = "unversioned" } @@ -222,13 +222,14 @@ func getS3Key(organizationID, version, basePath, fileName string) string { basePath = basePath + "/" } - return fmt.Sprintf("%s/%s/%s%s", organizationID, version, basePath, fileName) + return fmt.Sprintf("%s/%s%s", version, basePath, fileName) } -func getSourceMapUploadUrls(apiKey string, paths []string, backendUrl string) ([]string, error) { +func getSourceMapUploadUrls(apiKey, projectID string, paths []string, backendUrl string) ([]string, error) { variables := map[string]interface{}{ - "api_key": apiKey, - "paths": paths, + "api_key": apiKey, + "project_id": projectID, + "paths": paths, } reqBody, err := json.Marshal(map[string]interface{}{ From 5feb69577975331afcb14baf9aa620503011eef5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 04:25:32 +0000 Subject: [PATCH 27/28] fix: Update upload_test.go to match latest changes in upload.go Co-Authored-By: vkorolik@launchdarkly.com --- cmd/sourcemaps/upload_test.go | 113 ++++++---------------------------- 1 file changed, 19 insertions(+), 94 deletions(-) diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index e10ebd92..bed12489 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -37,56 +37,6 @@ func (m *mockResourcesClient) GetVersion() string { -func TestVerifyApiKey(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - var requestBody map[string]interface{} - err := json.NewDecoder(r.Body).Decode(&requestBody) - assert.NoError(t, err) - - variables, ok := requestBody["variables"].(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, "account123", variables["ld_account_id"]) - assert.Equal(t, "project123", variables["ld_project_id"]) - - response := `{"data":{"ld_credential":{"project_id":"project123","api_key":"highlight-key-123"}}}` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(response)) - })) - defer server.Close() - - highlightKey, projectID, err := verifyApiKey("account123", "project123", server.URL) - assert.NoError(t, err) - assert.Equal(t, "highlight-key-123", highlightKey) - assert.Equal(t, "project123", projectID) - - invalidServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"ld_credential":{"project_id":"","api_key":""}}}` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(response)) - })) - defer invalidServer.Close() - - _, _, err = verifyApiKey("account123", "project123", invalidServer.URL) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid API key") - - errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"errors":[{"message":"Invalid credentials"}]}` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(response)) - })) - defer errorServer.Close() - - _, _, err = verifyApiKey("account123", "project123", errorServer.URL) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to verify API key") -} func TestGetSourceMapUploadUrls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -102,9 +52,10 @@ func TestGetSourceMapUploadUrls(t *testing.T) { variables, ok := requestBody["variables"].(map[string]interface{}) assert.True(t, ok) assert.Equal(t, "test-api-key", variables["api_key"]) + assert.Equal(t, "project123", variables["project_id"]) assert.NotNil(t, variables["paths"]) - response := `{"data":{"get_source_map_upload_urls":["https://example.com/upload1","https://example.com/upload2"]}}` + response := `{"data":{"get_source_map_upload_urls_ld":["https://example.com/upload1","https://example.com/upload2"]}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) @@ -112,37 +63,37 @@ func TestGetSourceMapUploadUrls(t *testing.T) { defer server.Close() paths := []string{"path1", "path2"} - urls, err := getSourceMapUploadUrls("test-api-key", paths, server.URL) + urls, err := getSourceMapUploadUrls("test-api-key", "project123", paths, server.URL) assert.NoError(t, err) assert.Equal(t, 2, len(urls)) assert.Equal(t, "https://example.com/upload1", urls[0]) assert.Equal(t, "https://example.com/upload2", urls[1]) errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"get_source_map_upload_urls":[]}}` + response := `{"data":{"get_source_map_upload_urls_ld":[]}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) })) defer errorServer.Close() - _, err = getSourceMapUploadUrls("test-api-key", paths, errorServer.URL) + _, err = getSourceMapUploadUrls("test-api-key", "project123", paths, errorServer.URL) assert.Error(t, err) assert.Contains(t, err.Error(), "unable to generate source map upload urls") } func TestGetS3Key(t *testing.T) { - key := getS3Key("project123", "v1.0", "base/path", "file.js.map") - assert.Equal(t, "project123/v1.0/base/path/file.js.map", key) + key := getS3Key("v1.0", "base/path", "file.js.map") + assert.Equal(t, "v1.0/base/path/file.js.map", key) - key = getS3Key("project123", "", "base/path", "file.js.map") - assert.Equal(t, "project123/unversioned/base/path/file.js.map", key) + key = getS3Key("", "base/path", "file.js.map") + assert.Equal(t, "unversioned/base/path/file.js.map", key) - key = getS3Key("project123", "v1.0", "", "file.js.map") - assert.Equal(t, "project123/v1.0/file.js.map", key) + key = getS3Key("v1.0", "", "file.js.map") + assert.Equal(t, "v1.0/file.js.map", key) - key = getS3Key("project123", "v1.0", "base/path", "file.js.map") - assert.Equal(t, "project123/v1.0/base/path/file.js.map", key) + key = getS3Key("v1.0", "base/path", "file.js.map") + assert.Equal(t, "v1.0/base/path/file.js.map", key) } func TestUploadFile(t *testing.T) { @@ -249,29 +200,12 @@ func TestGetAllSourceMapFiles(t *testing.T) { assert.Contains(t, err.Error(), "no .js.map files found") } -func TestVerifyApiKeyErrors(t *testing.T) { - _, _, err := verifyApiKey("account123", "project123", "://invalid-url") - assert.Error(t, err) - - _, _, err = verifyApiKey("account123", "project123", "http://non-existent-host.invalid") - assert.Error(t, err) - - invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":invalid-json`)) - })) - defer invalidJSONServer.Close() - - _, _, err = verifyApiKey("account123", "project123", invalidJSONServer.URL) - assert.Error(t, err) -} func TestGetSourceMapUploadUrlsErrors(t *testing.T) { - _, err := getSourceMapUploadUrls("test-key", []string{"path"}, "://invalid-url") + _, err := getSourceMapUploadUrls("test-key", "project123", []string{"path"}, "://invalid-url") assert.Error(t, err) - _, err = getSourceMapUploadUrls("test-key", []string{"path"}, "http://non-existent-host.invalid") + _, err = getSourceMapUploadUrls("test-key", "project123", []string{"path"}, "http://non-existent-host.invalid") assert.Error(t, err) invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -281,7 +215,7 @@ func TestGetSourceMapUploadUrlsErrors(t *testing.T) { })) defer invalidJSONServer.Close() - _, err = getSourceMapUploadUrls("test-key", []string{"path"}, invalidJSONServer.URL) + _, err = getSourceMapUploadUrls("test-key", "project123", []string{"path"}, invalidJSONServer.URL) assert.Error(t, err) } @@ -289,8 +223,7 @@ func TestRunE(t *testing.T) { // Create a mock client that returns predefined responses mockClient := &mockResourcesClient{ responses: map[string][]byte{ - "/api/v2/caller-identity": []byte(`{"AccountID":"account123"}`), - "/api/v2/projects/test-project": []byte(`{"Items":[{"_id":"project123"}]}`), + "/api/v2/projects/test-project": []byte(`{"_id":"project123"}`), }, } @@ -305,16 +238,8 @@ func TestRunE(t *testing.T) { err = os.WriteFile(testMapFile, []byte("{}"), 0644) assert.NoError(t, err) - verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"ld_credential":{"project_id":"project123","api_key":"highlight-key-123"}}}` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(response)) - })) - defer verifyServer.Close() - urlsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{"data":{"get_source_map_upload_urls":["https://example.com/upload"]}}` + response := `{"data":{"get_source_map_upload_urls_ld":["https://example.com/upload"]}}` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(response)) @@ -334,7 +259,7 @@ func TestRunE(t *testing.T) { assert.NoError(t, err) err = cmd.Flags().Set(pathFlag, testMapFile) assert.NoError(t, err) - err = cmd.Flags().Set(backendUrlFlag, verifyServer.URL) + err = cmd.Flags().Set(backendUrlFlag, urlsServer.URL) assert.NoError(t, err) err = runFunc(cmd, args) From 8580b6f429d0edac42799e1577ea2af4916764fd Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Wed, 28 May 2025 21:33:51 -0700 Subject: [PATCH 28/28] error message --- cmd/sourcemaps/upload.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index fd4757dc..06256c0d 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -265,7 +265,7 @@ func getSourceMapUploadUrls(apiKey, projectID string, paths []string, backendUrl } if len(urlsResp.Data.GetSourceMapUploadUrls) == 0 { - return nil, fmt.Errorf("unable to generate source map upload urls") + return nil, fmt.Errorf("unable to generate source map upload urls %w", err) } return urlsResp.Data.GetSourceMapUploadUrls, nil