diff --git a/artifactory/commands/conan/artifacts.go b/artifactory/commands/conan/artifacts.go new file mode 100644 index 00000000..5ccd8a48 --- /dev/null +++ b/artifactory/commands/conan/artifacts.go @@ -0,0 +1,280 @@ +package conan + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/io/content" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ConanPackageInfo holds parsed Conan package reference information. +// Supports both Conan 2.x (name/version) and 1.x (name/version@user/channel) formats. +type ConanPackageInfo struct { + Name string + Version string + User string + Channel string +} + +// ArtifactCollector collects Conan artifacts from Artifactory. +type ArtifactCollector struct { + serverDetails *config.ServerDetails + targetRepo string +} + +// NewArtifactCollector creates a new artifact collector. +func NewArtifactCollector(serverDetails *config.ServerDetails, targetRepo string) *ArtifactCollector { + return &ArtifactCollector{ + serverDetails: serverDetails, + targetRepo: targetRepo, + } +} + +// CollectArtifacts searches Artifactory for Conan artifacts matching the package reference. +func (ac *ArtifactCollector) CollectArtifacts(packageRef string) ([]entities.Artifact, error) { + if ac.serverDetails == nil { + return nil, fmt.Errorf("server details not initialized") + } + + pkgInfo, err := ParsePackageReference(packageRef) + if err != nil { + return nil, err + } + + return ac.searchArtifacts(buildArtifactQuery(ac.targetRepo, pkgInfo)) +} + +// CollectArtifactsForPath collects artifacts from a specific path. +// Used to collect only artifacts that were uploaded in the current build. +// The path should be exact (e.g., "_/multideps/1.0.0/_/revision/export") +func (ac *ArtifactCollector) CollectArtifactsForPath(exactPath string) ([]entities.Artifact, error) { + if ac.serverDetails == nil { + return nil, fmt.Errorf("server details not initialized") + } + + // Use exact path match - artifacts are directly in the path, not subfolders + query := fmt.Sprintf(`{"repo": "%s", "path": "%s"}`, ac.targetRepo, exactPath) + return ac.searchArtifacts(query) +} + +// searchArtifacts executes an AQL query and returns matching artifacts. +func (ac *ArtifactCollector) searchArtifacts(aqlQuery string) ([]entities.Artifact, error) { + servicesManager, err := utils.CreateServiceManager(ac.serverDetails, -1, 0, false) + if err != nil { + return nil, fmt.Errorf("create services manager: %w", err) + } + + searchParams := services.SearchParams{ + CommonParams: &specutils.CommonParams{ + Aql: specutils.Aql{ItemsFind: aqlQuery}, + }, + } + + reader, err := servicesManager.SearchFiles(searchParams) + if err != nil { + return nil, fmt.Errorf("search files: %w", err) + } + defer closeReader(reader) + + return parseSearchResults(reader), nil +} + +// parseSearchResults converts AQL search results to artifacts. +func parseSearchResults(reader *content.ContentReader) []entities.Artifact { + var artifacts []entities.Artifact + + for item := new(specutils.ResultItem); reader.NextRecord(item) == nil; item = new(specutils.ResultItem) { + artifact := entities.Artifact{ + Name: item.Name, + Path: item.Path, + Checksum: entities.Checksum{ + Sha1: item.Actual_Sha1, + Sha256: item.Sha256, + Md5: item.Actual_Md5, + }, + } + artifacts = append(artifacts, artifact) + } + + return artifacts +} + +// ParsePackageReference parses a Conan package reference string into structured info. +// Supports both formats: +// - Conan 2.x: name/version (e.g., "zlib/1.2.13") +// - Conan 1.x: name/version@user/channel (e.g., "zlib/1.2.13@_/_") +func ParsePackageReference(ref string) (*ConanPackageInfo, error) { + ref = strings.TrimSpace(ref) + + // Check for @user/channel format (Conan 1.x style) + if idx := strings.Index(ref, "@"); idx != -1 { + nameVersion := ref[:idx] + userChannel := ref[idx+1:] + + nameParts := strings.SplitN(nameVersion, "/", 2) + channelParts := strings.SplitN(userChannel, "/", 2) + + if len(nameParts) != 2 || len(channelParts) != 2 { + return nil, fmt.Errorf("invalid package reference: %s", ref) + } + + return &ConanPackageInfo{ + Name: nameParts[0], + Version: nameParts[1], + User: channelParts[0], + Channel: channelParts[1], + }, nil + } + + // Simple name/version format (Conan 2.x style) + parts := strings.SplitN(ref, "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid package reference: %s", ref) + } + + return &ConanPackageInfo{ + Name: parts[0], + Version: parts[1], + User: "_", + Channel: "_", + }, nil +} + +// buildArtifactQuery creates an AQL query for Conan artifacts. +// Conan stores artifacts in different path formats depending on version: +// - Conan 2.x: _/name/version/_/revision/... +// - Conan 1.x: user/name/version/channel/revision/... +func buildArtifactQuery(repo string, pkg *ConanPackageInfo) string { + if pkg.User == "_" && pkg.Channel == "_" { + return fmt.Sprintf(`{"repo": "%s", "path": {"$match": "_/%s/%s/_/*"}}`, + repo, pkg.Name, pkg.Version) + } + return fmt.Sprintf(`{"repo": "%s", "path": {"$match": "%s/%s/%s/%s/*"}}`, + repo, pkg.User, pkg.Name, pkg.Version, pkg.Channel) +} + +// BuildPropertySetter sets build properties on Conan artifacts in Artifactory. +// This is required to link artifacts to build info in Artifactory UI. +type BuildPropertySetter struct { + serverDetails *config.ServerDetails + targetRepo string + buildName string + buildNumber string + projectKey string +} + +// NewBuildPropertySetter creates a new build property setter. +func NewBuildPropertySetter(serverDetails *config.ServerDetails, targetRepo, buildName, buildNumber, projectKey string) *BuildPropertySetter { + return &BuildPropertySetter{ + serverDetails: serverDetails, + targetRepo: targetRepo, + buildName: buildName, + buildNumber: buildNumber, + projectKey: projectKey, + } +} + +// SetProperties sets build properties on the given artifacts in a single batch operation. +// This uses the same approach as Docker - writing all items to a temp file and making +// one SetProps call, which is much more efficient than individual calls per artifact. +func (bps *BuildPropertySetter) SetProperties(artifacts []entities.Artifact) error { + if len(artifacts) == 0 || bps.serverDetails == nil { + return nil + } + + servicesManager, err := utils.CreateServiceManager(bps.serverDetails, -1, 0, false) + if err != nil { + return fmt.Errorf("create services manager: %w", err) + } + + // Convert artifacts to ResultItem format for batch processing + resultItems := bps.convertToResultItems(artifacts) + if len(resultItems) == 0 { + return nil + } + + // Write all items to a temp file (like Docker does) + pathToFile, err := bps.writeItemsToFile(resultItems) + if err != nil { + return fmt.Errorf("write items to file: %w", err) + } + + // Create reader and set properties in one batch call + reader := content.NewContentReader(pathToFile, content.DefaultKey) + defer closeReader(reader) + + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + props := bps.formatBuildProperties(timestamp) + + _, err = servicesManager.SetProps(services.PropsParams{Reader: reader, Props: props, UseDebugLogs: true, IsRecursive: true}) + if err != nil { + return fmt.Errorf("set properties: %w", err) + } + + log.Info(fmt.Sprintf("Set build properties on %d Conan artifacts (batch)", len(artifacts))) + return nil +} + +// convertToResultItems converts build-info artifacts to ResultItem format for SetProps. +func (bps *BuildPropertySetter) convertToResultItems(artifacts []entities.Artifact) []specutils.ResultItem { + var items []specutils.ResultItem + for _, artifact := range artifacts { + items = append(items, specutils.ResultItem{ + Repo: bps.targetRepo, + Path: artifact.Path, + Name: artifact.Name, + Actual_Sha1: artifact.Sha1, + Actual_Md5: artifact.Md5, + Sha256: artifact.Sha256, + }) + } + return items +} + +// writeItemsToFile writes result items to a temp file for batch processing. +func (bps *BuildPropertySetter) writeItemsToFile(items []specutils.ResultItem) (string, error) { + writer, err := content.NewContentWriter("results", true, false) + if err != nil { + return "", err + } + defer func() { + if closeErr := writer.Close(); closeErr != nil { + log.Debug(fmt.Sprintf("Failed to close writer: %s", closeErr)) + } + }() + + for _, item := range items { + writer.Write(item) + } + return writer.GetFilePath(), nil +} + +// formatBuildProperties creates the build properties string. +// Only includes build.name, build.number, build.timestamp (and optional build.project). +func (bps *BuildPropertySetter) formatBuildProperties(timestamp string) string { + props := fmt.Sprintf("build.name=%s;build.number=%s;build.timestamp=%s", + bps.buildName, bps.buildNumber, timestamp) + + if bps.projectKey != "" { + props += fmt.Sprintf(";build.project=%s", bps.projectKey) + } + + return props +} + +// closeReader safely closes a content reader. +func closeReader(reader *content.ContentReader) { + if reader != nil { + if err := reader.Close(); err != nil { + log.Debug(fmt.Sprintf("Failed to close reader: %s", err)) + } + } +} diff --git a/artifactory/commands/conan/artifacts_test.go b/artifactory/commands/conan/artifacts_test.go new file mode 100644 index 00000000..97573166 --- /dev/null +++ b/artifactory/commands/conan/artifacts_test.go @@ -0,0 +1,220 @@ +package conan + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParsePackageReference(t *testing.T) { + tests := []struct { + name string + ref string + expected *ConanPackageInfo + expectError bool + }{ + { + name: "Conan 2.x format - name/version", + ref: "zlib/1.3.1", + expected: &ConanPackageInfo{ + Name: "zlib", + Version: "1.3.1", + User: "_", + Channel: "_", + }, + expectError: false, + }, + { + name: "Conan 1.x format - name/version@user/channel", + ref: "boost/1.82.0@myuser/stable", + expected: &ConanPackageInfo{ + Name: "boost", + Version: "1.82.0", + User: "myuser", + Channel: "stable", + }, + expectError: false, + }, + { + name: "Package with underscore in name", + ref: "my_package/2.0.0", + expected: &ConanPackageInfo{ + Name: "my_package", + Version: "2.0.0", + User: "_", + Channel: "_", + }, + expectError: false, + }, + { + name: "Package with complex version", + ref: "openssl/3.1.2", + expected: &ConanPackageInfo{ + Name: "openssl", + Version: "3.1.2", + User: "_", + Channel: "_", + }, + expectError: false, + }, + { + name: "With whitespace - should be trimmed", + ref: " fmt/10.2.1 ", + expected: &ConanPackageInfo{ + Name: "fmt", + Version: "10.2.1", + User: "_", + Channel: "_", + }, + expectError: false, + }, + { + name: "Invalid format - no slash", + ref: "invalid-package", + expectError: true, + }, + { + name: "Invalid format - empty string", + ref: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParsePackageReference(tt.ref) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.expected.Name, result.Name) + assert.Equal(t, tt.expected.Version, result.Version) + assert.Equal(t, tt.expected.User, result.User) + assert.Equal(t, tt.expected.Channel, result.Channel) + } + }) + } +} + +func TestBuildArtifactQuery(t *testing.T) { + tests := []struct { + name string + repo string + pkgInfo *ConanPackageInfo + expected string + }{ + { + name: "Conan 2.x path format", + repo: "conan-local", + pkgInfo: &ConanPackageInfo{ + Name: "zlib", + Version: "1.3.1", + User: "_", + Channel: "_", + }, + expected: `{"repo": "conan-local", "path": {"$match": "_/zlib/1.3.1/_/*"}}`, + }, + { + name: "Conan 1.x path format", + repo: "conan-local", + pkgInfo: &ConanPackageInfo{ + Name: "boost", + Version: "1.82.0", + User: "myuser", + Channel: "stable", + }, + expected: `{"repo": "conan-local", "path": {"$match": "myuser/boost/1.82.0/stable/*"}}`, + }, + { + name: "Different repository name", + repo: "my-conan-repo", + pkgInfo: &ConanPackageInfo{ + Name: "fmt", + Version: "10.2.1", + User: "_", + Channel: "_", + }, + expected: `{"repo": "my-conan-repo", "path": {"$match": "_/fmt/10.2.1/_/*"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildArtifactQuery(tt.repo, tt.pkgInfo) + assert.Equal(t, tt.expected, result) + }) + } +} + + +func TestBuildPropertySetter_FormatBuildProperties(t *testing.T) { + tests := []struct { + name string + buildName string + buildNumber string + projectKey string + timestamp string + expected string + }{ + { + name: "Without project key", + buildName: "my-build", + buildNumber: "123", + projectKey: "", + timestamp: "1234567890", + expected: "build.name=my-build;build.number=123;build.timestamp=1234567890", + }, + { + name: "With project key", + buildName: "my-build", + buildNumber: "456", + projectKey: "myproject", + timestamp: "9876543210", + expected: "build.name=my-build;build.number=456;build.timestamp=9876543210;build.project=myproject", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setter := &BuildPropertySetter{ + buildName: tt.buildName, + buildNumber: tt.buildNumber, + projectKey: tt.projectKey, + } + result := setter.formatBuildProperties(tt.timestamp) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNewArtifactCollector(t *testing.T) { + targetRepo := "conan-local" + + collector := NewArtifactCollector(nil, targetRepo) + + assert.NotNil(t, collector) + assert.Equal(t, targetRepo, collector.targetRepo) + assert.Nil(t, collector.serverDetails) +} + +func TestNewBuildPropertySetter(t *testing.T) { + buildName := "test-build" + buildNumber := "1" + projectKey := "test-project" + targetRepo := "conan-local" + + setter := NewBuildPropertySetter(nil, targetRepo, buildName, buildNumber, projectKey) + + assert.NotNil(t, setter) + assert.Equal(t, buildName, setter.buildName) + assert.Equal(t, buildNumber, setter.buildNumber) + assert.Equal(t, projectKey, setter.projectKey) + assert.Equal(t, targetRepo, setter.targetRepo) +} + + + + diff --git a/artifactory/commands/conan/command.go b/artifactory/commands/conan/command.go new file mode 100644 index 00000000..cdfbf402 --- /dev/null +++ b/artifactory/commands/conan/command.go @@ -0,0 +1,321 @@ +package conan + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/jfrog/build-info-go/build" + "github.com/jfrog/build-info-go/entities" + conanflex "github.com/jfrog/build-info-go/flexpack/conan" + gofrogcmd "github.com/jfrog/gofrog/io" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ConanCommand represents a Conan CLI command with build info support. +type ConanCommand struct { + commandName string + args []string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration + workingDir string +} + +// NewConanCommand creates a new ConanCommand instance. +func NewConanCommand() *ConanCommand { + return &ConanCommand{} +} + +// SetCommandName sets the Conan subcommand name (install, create, upload, etc.). +func (c *ConanCommand) SetCommandName(name string) *ConanCommand { + c.commandName = name + return c +} + +// SetArgs sets the command arguments. +func (c *ConanCommand) SetArgs(args []string) *ConanCommand { + c.args = args + return c +} + +// SetServerDetails sets the Artifactory server configuration. +func (c *ConanCommand) SetServerDetails(details *config.ServerDetails) *ConanCommand { + c.serverDetails = details + return c +} + +// SetBuildConfiguration sets the build configuration for build info collection. +func (c *ConanCommand) SetBuildConfiguration(config *buildUtils.BuildConfiguration) *ConanCommand { + c.buildConfiguration = config + return c +} + +// Commands that may need remote access for downloading dependencies or packages. +// These commands might interact with Conan remotes and require authentication. +var commandsNeedingRemoteAccess = []string{ + "install", + "create", + "build", + "download", + "upload", + "search", + "list", +} + +// needsRemoteAccess checks if a command might need remote access. +func needsRemoteAccess(cmd string) bool { + for _, c := range commandsNeedingRemoteAccess { + if c == cmd { + return true + } + } + return false +} + +// Run executes the Conan command with build info collection. +func (c *ConanCommand) Run() error { + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + c.workingDir = workingDir + + // Perform auto-login for commands that need remote access + if needsRemoteAccess(c.commandName) { + if err := c.autoLoginToRemotes(); err != nil { + log.Debug(fmt.Sprintf("Auto-login warning: %v", err)) + } + } + + // Upload command requires special handling to parse output and collect artifacts + if c.commandName == "upload" { + return c.runUploadCommand() + } + + // Run other Conan commands + return c.runConanCommand() +} + +// autoLoginToRemotes attempts to log into all configured Conan remotes that match JFrog CLI configs. +func (c *ConanCommand) autoLoginToRemotes() error { + // First check if a specific remote is specified in args + remoteName := ExtractRemoteName(c.args) + if remoteName != "" { + matchedServer, err := ValidateAndLogin(remoteName) + if err != nil { + log.Debug(fmt.Sprintf("Could not auto-login to remote '%s': %v", remoteName, err)) + } else { + c.serverDetails = matchedServer + } + return nil + } + + // No specific remote specified, try to login to all Artifactory remotes + return c.loginToAllArtifactoryRemotes() +} + +// loginToAllArtifactoryRemotes attempts to log into all Conan remotes that point to Artifactory. +func (c *ConanCommand) loginToAllArtifactoryRemotes() error { + remotes, err := ListConanRemotes() + if err != nil { + return fmt.Errorf("list conan remotes: %w", err) + } + + loggedInCount := 0 + for _, remote := range remotes { + // Only process remotes with /api/conan/ URL pattern (Artifactory Conan repos) + if !isArtifactoryConanRemote(remote.URL) { + log.Debug(fmt.Sprintf("Skipping remote '%s': not an Artifactory Conan URL", remote.Name)) + continue + } + + log.Debug(fmt.Sprintf("Found Artifactory Conan remote: %s -> %s", remote.Name, remote.URL)) + + matchedServer, err := ValidateAndLogin(remote.Name) + if err != nil { + log.Debug(fmt.Sprintf("Could not auto-login to remote '%s': %v", remote.Name, err)) + continue + } + + loggedInCount++ + log.Debug(fmt.Sprintf("Successfully logged into remote '%s'", remote.Name)) + + // Use the first successfully logged-in server for artifact collection + if c.serverDetails == nil { + c.serverDetails = matchedServer + } + } + + if loggedInCount > 0 { + log.Debug(fmt.Sprintf("Auto-login completed: logged into %d Artifactory remote(s)", loggedInCount)) + } + + return nil +} + +// isArtifactoryConanRemote checks if a URL points to an Artifactory Conan repository. +// Artifactory Conan URLs contain /api/conan/ in the path. +func isArtifactoryConanRemote(url string) bool { + return strings.Contains(url, "/api/conan/") +} + +// runUploadCommand handles the upload command with build info collection. +// Upload requires special handling because: +// 1. We need to capture the output to determine which artifacts were uploaded +// 2. We need to collect artifacts from Artifactory and set build properties +func (c *ConanCommand) runUploadCommand() error { + log.Info(fmt.Sprintf("Running Conan %s", c.commandName)) + + // Execute conan upload and capture output + output, err := c.executeAndCaptureOutput() + if err != nil { + fmt.Print(string(output)) + return fmt.Errorf("conan %s failed: %w", c.commandName, err) + } + fmt.Print(string(output)) + + // Process upload for build info if build configuration is provided + if c.buildConfiguration != nil { + return c.processBuildInfo(string(output)) + } + + return nil +} + +// runConanCommand runs non-upload Conan commands. +func (c *ConanCommand) runConanCommand() error { + log.Info(fmt.Sprintf("Running Conan %s", c.commandName)) + + if err := gofrogcmd.RunCmd(c); err != nil { + return fmt.Errorf("conan %s failed: %w", c.commandName, err) + } + + // Collect build info for dependency commands + if c.buildConfiguration != nil { + return c.collectAndSaveBuildInfo() + } + + return nil +} + +// processBuildInfo processes build info after a successful upload. +func (c *ConanCommand) processBuildInfo(uploadOutput string) error { + buildName, buildNumber, _ := c.getBuildNameAndNumber() + if buildName == "" || buildNumber == "" { + return nil // No build info configured, skip silently + } + + log.Info(fmt.Sprintf("Processing Conan upload with build info: %s/%s", buildName, buildNumber)) + + processor := NewUploadProcessor(c.workingDir, c.buildConfiguration, c.serverDetails) + if err := processor.Process(uploadOutput); err != nil { + log.Warn("Failed to process Conan upload: " + err.Error()) + } + + log.Info(fmt.Sprintf("Conan build info collected. Use 'jf rt bp %s %s' to publish it.", buildName, buildNumber)) + return nil +} + +// collectAndSaveBuildInfo collects dependencies and saves build info locally. +func (c *ConanCommand) collectAndSaveBuildInfo() error { + buildName, buildNumber, _ := c.getBuildNameAndNumber() + if buildName == "" || buildNumber == "" { + return nil // No build info configured, skip silently + } + + log.Info(fmt.Sprintf("Collecting build info for Conan project: %s/%s", buildName, buildNumber)) + + // Create FlexPack collector + conanConfig := conanflex.ConanConfig{ + WorkingDirectory: c.workingDir, + } + + collector, err := conanflex.NewConanFlexPack(conanConfig) + if err != nil { + return fmt.Errorf("failed to create Conan FlexPack: %w", err) + } + buildInfo, err := collector.CollectBuildInfo(buildName, buildNumber) + if err != nil { + return fmt.Errorf("failed to collect Conan build info: %w", err) + } + if err := saveBuildInfoLocally(buildInfo); err != nil { + return fmt.Errorf("failed to save build info: %w", err) + } + + log.Info(fmt.Sprintf("Conan build info collected. Use 'jf rt bp %s %s' to publish it.", buildName, buildNumber)) + return nil +} + +// getBuildNameAndNumber returns build name and number from configuration. +// Returns error if either is missing. +func (c *ConanCommand) getBuildNameAndNumber() (string, string, error) { + buildName, err := c.buildConfiguration.GetBuildName() + if err != nil || buildName == "" { + return "", "", fmt.Errorf("build name not configured") + } + + buildNumber, err := c.buildConfiguration.GetBuildNumber() + if err != nil || buildNumber == "" { + return "", "", fmt.Errorf("build number not configured") + } + + return buildName, buildNumber, nil +} + +// executeAndCaptureOutput runs the command and returns the combined output. +func (c *ConanCommand) executeAndCaptureOutput() ([]byte, error) { + cmd := c.GetCmd() + return cmd.CombinedOutput() +} + +// GetCmd returns the exec.Cmd for the Conan command. +func (c *ConanCommand) GetCmd() *exec.Cmd { + args := append([]string{c.commandName}, c.args...) + return exec.Command("conan", args...) +} + +// GetEnv returns environment variables for the command. +func (c *ConanCommand) GetEnv() map[string]string { + return map[string]string{} +} + +// GetStdWriter returns the stdout writer. +func (c *ConanCommand) GetStdWriter() io.WriteCloser { + return nil +} + +// GetErrWriter returns the stderr writer. +func (c *ConanCommand) GetErrWriter() io.WriteCloser { + return nil +} + +// CommandName returns the command identifier for logging. +func (c *ConanCommand) CommandName() string { + return "rt_conan" +} + +// ServerDetails returns the server configuration. +func (c *ConanCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +// saveBuildInfoLocally saves the build info for later publishing with 'jf rt bp'. +func saveBuildInfoLocally(buildInfo *entities.BuildInfo) error { + service := build.NewBuildInfoService() + + buildInstance, err := service.GetOrCreateBuildWithProject(buildInfo.Name, buildInfo.Number, "") + if err != nil { + return fmt.Errorf("create build: %w", err) + } + + if err := buildInstance.SaveBuildInfo(buildInfo); err != nil { + return fmt.Errorf("save build info: %w", err) + } + + log.Debug("Build info saved locally") + return nil +} diff --git a/artifactory/commands/conan/command_test.go b/artifactory/commands/conan/command_test.go new file mode 100644 index 00000000..b86e7167 --- /dev/null +++ b/artifactory/commands/conan/command_test.go @@ -0,0 +1,127 @@ +package conan + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" +) + +func TestNewConanCommand(t *testing.T) { + cmd := NewConanCommand() + + assert.NotNil(t, cmd) + assert.Empty(t, cmd.commandName) + assert.Nil(t, cmd.args) + assert.Nil(t, cmd.serverDetails) + assert.Nil(t, cmd.buildConfiguration) +} + +func TestConanCommand_SetCommandName(t *testing.T) { + cmd := NewConanCommand() + + result := cmd.SetCommandName("install") + + assert.Equal(t, "install", cmd.commandName) + assert.Same(t, cmd, result, "SetCommandName should return same instance for chaining") +} + +func TestConanCommand_SetArgs(t *testing.T) { + cmd := NewConanCommand() + args := []string{".", "--build=missing"} + + result := cmd.SetArgs(args) + + assert.Equal(t, args, cmd.args) + assert.Same(t, cmd, result, "SetArgs should return same instance for chaining") +} + +func TestConanCommand_SetServerDetails(t *testing.T) { + cmd := NewConanCommand() + serverDetails := &config.ServerDetails{ + ServerId: "test-server", + } + + result := cmd.SetServerDetails(serverDetails) + + assert.Equal(t, serverDetails, cmd.serverDetails) + assert.Same(t, cmd, result, "SetServerDetails should return same instance for chaining") +} + +func TestConanCommand_CommandName(t *testing.T) { + cmd := NewConanCommand() + + result := cmd.CommandName() + + assert.Equal(t, "rt_conan", result) +} + +func TestConanCommand_ServerDetails(t *testing.T) { + cmd := NewConanCommand() + serverDetails := &config.ServerDetails{ + ServerId: "test-server", + } + cmd.serverDetails = serverDetails + + result, err := cmd.ServerDetails() + + assert.NoError(t, err) + assert.Equal(t, serverDetails, result) +} + +func TestConanCommand_GetCmd(t *testing.T) { + cmd := NewConanCommand() + cmd.commandName = "install" + cmd.args = []string{".", "--build=missing"} + + execCmd := cmd.GetCmd() + + assert.NotNil(t, execCmd) + assert.Equal(t, "conan", execCmd.Path[len(execCmd.Path)-5:]) // ends with "conan" + assert.Contains(t, execCmd.Args, "install") + assert.Contains(t, execCmd.Args, ".") + assert.Contains(t, execCmd.Args, "--build=missing") +} + +func TestConanCommand_GetEnv(t *testing.T) { + cmd := NewConanCommand() + + env := cmd.GetEnv() + + assert.NotNil(t, env) + assert.Empty(t, env) +} + +func TestConanCommand_GetStdWriter(t *testing.T) { + cmd := NewConanCommand() + + writer := cmd.GetStdWriter() + + assert.Nil(t, writer) +} + +func TestConanCommand_GetErrWriter(t *testing.T) { + cmd := NewConanCommand() + + writer := cmd.GetErrWriter() + + assert.Nil(t, writer) +} + +func TestConanCommand_ChainedSetters(t *testing.T) { + serverDetails := &config.ServerDetails{ServerId: "test"} + + cmd := NewConanCommand(). + SetCommandName("upload"). + SetArgs([]string{"pkg/1.0", "-r", "remote"}). + SetServerDetails(serverDetails) + + assert.Equal(t, "upload", cmd.commandName) + assert.Equal(t, []string{"pkg/1.0", "-r", "remote"}, cmd.args) + assert.Equal(t, serverDetails, cmd.serverDetails) +} + + + + + diff --git a/artifactory/commands/conan/login.go b/artifactory/commands/conan/login.go new file mode 100644 index 00000000..024a40ad --- /dev/null +++ b/artifactory/commands/conan/login.go @@ -0,0 +1,306 @@ +// Package conan provides Conan package manager integration for JFrog Artifactory. +package conan + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + maxLoginAttempts = 2 +) + +// ConanRemote represents a Conan remote configuration. +type ConanRemote struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// ExtractRemoteName extracts the remote name from conan upload arguments. +// Looks for -r or --remote flag. +func ExtractRemoteName(args []string) string { + for i, arg := range args { + if arg == "-r" || arg == "--remote" { + if i+1 < len(args) { + return args[i+1] + } + } + if strings.HasPrefix(arg, "-r=") { + return strings.TrimPrefix(arg, "-r=") + } + if strings.HasPrefix(arg, "--remote=") { + return strings.TrimPrefix(arg, "--remote=") + } + } + return "" +} + +// ValidateAndLogin validates the remote config exists and performs login. +// Returns the matched server details for artifact collection. +func ValidateAndLogin(remoteName string) (*config.ServerDetails, error) { + // Get the remote URL from Conan + remoteURL, err := getRemoteURL(remoteName) + if err != nil { + return nil, fmt.Errorf("get Conan remote URL: %w", err) + } + log.Debug(fmt.Sprintf("Conan remote '%s' URL: %s", remoteName, remoteURL)) + + // Extract base Artifactory URL + baseURL := extractBaseURL(remoteURL) + log.Debug(fmt.Sprintf("Extracted base URL: %s", baseURL)) + + // Find all matching JFrog CLI server configs + matchingConfigs, err := findMatchingServers(baseURL) + if err != nil { + return nil, err + } + + // Try to login with each matching config + return tryLoginWithConfigs(remoteName, matchingConfigs) +} + +// ListConanRemotes returns all configured Conan remotes. +func ListConanRemotes() ([]ConanRemote, error) { + cmd := exec.Command("conan", "remote", "list", "--format=json") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("conan remote list failed: %w", err) + } + + var remotes []ConanRemote + if err := json.Unmarshal(output, &remotes); err != nil { + return nil, fmt.Errorf("parse conan remote list: %w", err) + } + + return remotes, nil +} + +// getRemoteURL retrieves the URL for a Conan remote. +func getRemoteURL(remoteName string) (string, error) { + remotes, err := ListConanRemotes() + if err != nil { + return "", err + } + + for _, remote := range remotes { + if remote.Name == remoteName { + return remote.URL, nil + } + } + + return "", fmt.Errorf("remote '%s' not found in Conan remotes", remoteName) +} + +// ExtractRepoName extracts the Artifactory repository name from a Conan remote URL. +// Example: "https://myserver.jfrog.io/artifactory/api/conan/repo-name" -> "repo-name" +func ExtractRepoName(remoteURL string) string { + // Format: .../api/conan/ + idx := strings.Index(remoteURL, "/api/conan/") + if idx != -1 { + repoName := remoteURL[idx+len("/api/conan/"):] + // Remove trailing slash if present + repoName = strings.TrimSuffix(repoName, "/") + return repoName + } + return "" +} + +// GetRepoNameForRemote gets the Artifactory repository name for a Conan remote. +func GetRepoNameForRemote(remoteName string) (string, error) { + remoteURL, err := getRemoteURL(remoteName) + if err != nil { + return "", err + } + repoName := ExtractRepoName(remoteURL) + if repoName == "" { + return "", fmt.Errorf("could not extract repo name from URL: %s", remoteURL) + } + return repoName, nil +} + +// extractBaseURL extracts the base Artifactory URL from a Conan remote URL. +// Example: "https://myserver.jfrog.io/artifactory/api/conan/repo" -> "https://myserver.jfrog.io" +func extractBaseURL(remoteURL string) string { + // Find /artifactory/ and extract everything before it + idx := strings.Index(remoteURL, "/artifactory/") + if idx != -1 { + return remoteURL[:idx] + } + idx = strings.Index(remoteURL, "/artifactory") + if idx != -1 { + return remoteURL[:idx] + } + return strings.TrimSuffix(remoteURL, "/") +} + +// findMatchingServers finds all JFrog CLI server configs that match the remote URL. +func findMatchingServers(remoteBaseURL string) ([]*config.ServerDetails, error) { + allConfigs, err := config.GetAllServersConfigs() + if err != nil { + return nil, fmt.Errorf("get JFrog CLI server configs: %w", err) + } + + if len(allConfigs) == 0 { + return nil, fmt.Errorf("no JFrog CLI server configurations found. Please run 'jf c add' to configure a server") + } + + normalizedTarget := normalizeURL(remoteBaseURL) + + var matchingConfigs []*config.ServerDetails + seenServerIDs := make(map[string]bool) + + for _, cfg := range allConfigs { + if seenServerIDs[cfg.ServerId] { + continue + } + + if matchesServer(cfg, normalizedTarget) { + matchingConfigs = append(matchingConfigs, cfg) + seenServerIDs[cfg.ServerId] = true + } + } + + if len(matchingConfigs) == 0 { + return nil, buildNoMatchError(remoteBaseURL, allConfigs) + } + + return matchingConfigs, nil +} + +// matchesServer checks if a server config matches the target URL. +func matchesServer(cfg *config.ServerDetails, normalizedTarget string) bool { + // Check Artifactory URL + if cfg.ArtifactoryUrl != "" { + normalizedArt := normalizeURL(cfg.ArtifactoryUrl) + normalizedArt = strings.TrimSuffix(normalizedArt, "/artifactory") + if normalizedArt == normalizedTarget { + return true + } + } + + // Check platform URL + if cfg.Url != "" { + normalizedPlatform := normalizeURL(cfg.Url) + if normalizedPlatform == normalizedTarget { + return true + } + } + + return false +} + +// normalizeURL normalizes a URL for comparison. +func normalizeURL(u string) string { + return strings.TrimSuffix(strings.ToLower(u), "/") +} + +// buildNoMatchError creates a helpful error message when no matching config is found. +func buildNoMatchError(remoteBaseURL string, allConfigs []*config.ServerDetails) error { + var configuredServers []string + for _, cfg := range allConfigs { + url := cfg.Url + if url == "" { + url = cfg.ArtifactoryUrl + } + if url != "" { + configuredServers = append(configuredServers, fmt.Sprintf(" - %s: %s", cfg.ServerId, url)) + } + } + + return fmt.Errorf(`no matching JFrog CLI server config found for remote URL: %s + +The Conan remote points to an Artifactory instance that is not configured in JFrog CLI. +Please add the server configuration using: jf c add + +Configured servers: +%s`, remoteBaseURL, strings.Join(configuredServers, "\n")) +} + +// tryLoginWithConfigs attempts login with each matching config. +func tryLoginWithConfigs(remoteName string, configs []*config.ServerDetails) (*config.ServerDetails, error) { + var allErrors []string + + for _, serverDetails := range configs { + log.Debug(fmt.Sprintf("Trying to login with config '%s'...", serverDetails.ServerId)) + + var lastErr error + for attempt := 1; attempt <= maxLoginAttempts; attempt++ { + if attempt > 1 { + log.Debug(fmt.Sprintf("Retrying login with '%s' (attempt %d/%d)...", serverDetails.ServerId, attempt, maxLoginAttempts)) + } + + lastErr = loginToRemote(remoteName, serverDetails) + if lastErr == nil { + log.Info(fmt.Sprintf("Successfully logged into Conan remote '%s' using JFrog CLI config '%s'", remoteName, serverDetails.ServerId)) + return serverDetails, nil + } + + log.Debug(fmt.Sprintf("Login attempt %d with '%s' failed: %s", attempt, serverDetails.ServerId, lastErr.Error())) + } + + allErrors = append(allErrors, fmt.Sprintf(" - %s: %s", serverDetails.ServerId, lastErr.Error())) + } + + return nil, fmt.Errorf(`failed to login to Conan remote '%s' with all matching JFrog CLI configs. + +Tried the following configs: +%s + +Please verify your credentials are correct. You can update them using: jf c add --interactive`, remoteName, strings.Join(allErrors, "\n")) +} + +// loginToRemote logs into a Conan remote using JFrog CLI credentials. +func loginToRemote(remoteName string, serverDetails *config.ServerDetails) error { + username, password, err := extractCredentials(serverDetails) + if err != nil { + return err + } + + log.Debug(fmt.Sprintf("Logging into Conan remote '%s' using config '%s'", remoteName, serverDetails.ServerId)) + + cmd := exec.Command("conan", "remote", "login", remoteName, username, "-p", password) + output, err := cmd.CombinedOutput() + if err != nil { + outputStr := strings.TrimSpace(string(output)) + return fmt.Errorf("conan remote login failed: %s", outputStr) + } + + return nil +} + +// extractCredentials extracts username and password/token from server details. +// For Conan authentication with Artifactory +func extractCredentials(serverDetails *config.ServerDetails) (username, password string, err error) { + username = serverDetails.User + if username == "" { + username = "admin" + } + + // Prefer password over access token for Conan (API keys work more reliably) + if serverDetails.Password != "" { + password = serverDetails.Password + return username, password, nil + } + + // Fall back to access token if no password + if serverDetails.AccessToken != "" { + password = serverDetails.AccessToken + return username, password, nil + } + + return "", "", fmt.Errorf("no credentials (password or access token) found in JFrog CLI config for server '%s'", serverDetails.ServerId) +} + +// formatServerIDs returns a comma-separated list of server IDs. +func formatServerIDs(configs []*config.ServerDetails) string { + var ids []string + for _, c := range configs { + ids = append(ids, c.ServerId) + } + return strings.Join(ids, ", ") +} diff --git a/artifactory/commands/conan/login_test.go b/artifactory/commands/conan/login_test.go new file mode 100644 index 00000000..a4c395c9 --- /dev/null +++ b/artifactory/commands/conan/login_test.go @@ -0,0 +1,305 @@ +package conan + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" +) + +func TestExtractRemoteName(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + { + name: "Extract with -r flag", + args: []string{"package/1.0", "-r", "my-remote", "--confirm"}, + expected: "my-remote", + }, + { + name: "Extract with --remote flag", + args: []string{"package/1.0", "--remote", "another-remote"}, + expected: "another-remote", + }, + { + name: "Extract with -r= format", + args: []string{"package/1.0", "-r=inline-remote"}, + expected: "inline-remote", + }, + { + name: "Extract with --remote= format", + args: []string{"package/1.0", "--remote=inline-remote2"}, + expected: "inline-remote2", + }, + { + name: "No remote specified", + args: []string{"package/1.0", "--confirm"}, + expected: "", + }, + { + name: "Empty args", + args: []string{}, + expected: "", + }, + { + name: "-r flag at end without value", + args: []string{"package/1.0", "-r"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractRemoteName(tt.args) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractBaseURL(t *testing.T) { + tests := []struct { + name string + remoteURL string + expected string + }{ + { + name: "Standard Artifactory URL with api/conan path", + remoteURL: "https://myserver.jfrog.io/artifactory/api/conan/conan-local", + expected: "https://myserver.jfrog.io", + }, + { + name: "Artifactory URL without trailing path", + remoteURL: "https://myserver.jfrog.io/artifactory/", + expected: "https://myserver.jfrog.io", + }, + { + name: "Artifactory URL without trailing slash", + remoteURL: "https://myserver.jfrog.io/artifactory", + expected: "https://myserver.jfrog.io", + }, + { + name: "Platform URL only", + remoteURL: "https://myserver.jfrog.io/", + expected: "https://myserver.jfrog.io", + }, + { + name: "Platform URL without slash", + remoteURL: "https://myserver.jfrog.io", + expected: "https://myserver.jfrog.io", + }, + { + name: "Self-hosted Artifactory", + remoteURL: "https://artifactory.company.com/artifactory/api/conan/conan-repo", + expected: "https://artifactory.company.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractBaseURL(tt.remoteURL) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "URL with trailing slash", + input: "https://example.com/", + expected: "https://example.com", + }, + { + name: "URL without trailing slash", + input: "https://example.com", + expected: "https://example.com", + }, + { + name: "URL with uppercase", + input: "HTTPS://EXAMPLE.COM/", + expected: "https://example.com", + }, + { + name: "Mixed case URL", + input: "https://MyServer.JFrog.IO/", + expected: "https://myserver.jfrog.io", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeURL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMatchesServer(t *testing.T) { + tests := []struct { + name string + serverDetails *config.ServerDetails + normalizedTarget string + expected bool + }{ + { + name: "Match by Artifactory URL", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://myserver.jfrog.io/artifactory/", + }, + normalizedTarget: "https://myserver.jfrog.io", + expected: true, + }, + { + name: "Match by Platform URL", + serverDetails: &config.ServerDetails{ + Url: "https://myserver.jfrog.io/", + }, + normalizedTarget: "https://myserver.jfrog.io", + expected: true, + }, + { + name: "No match - different server", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://other-server.jfrog.io/artifactory/", + }, + normalizedTarget: "https://myserver.jfrog.io", + expected: false, + }, + { + name: "Match with both URLs set - Artifactory matches", + serverDetails: &config.ServerDetails{ + Url: "https://other.jfrog.io/", + ArtifactoryUrl: "https://myserver.jfrog.io/artifactory/", + }, + normalizedTarget: "https://myserver.jfrog.io", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesServer(tt.serverDetails, tt.normalizedTarget) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractCredentials(t *testing.T) { + tests := []struct { + name string + serverDetails *config.ServerDetails + expectedUsername string + expectedPassword string + expectError bool + }{ + { + name: "Extract with access token", + serverDetails: &config.ServerDetails{ + User: "myuser", + AccessToken: "my-access-token", + }, + expectedUsername: "myuser", + expectedPassword: "my-access-token", + expectError: false, + }, + { + name: "Extract with access token but no user", + serverDetails: &config.ServerDetails{ + AccessToken: "my-access-token", + }, + expectedUsername: "admin", // Defaults to "admin" when no user specified + expectedPassword: "my-access-token", + expectError: false, + }, + { + name: "Extract with password", + serverDetails: &config.ServerDetails{ + User: "myuser", + Password: "mypassword", + }, + expectedUsername: "myuser", + expectedPassword: "mypassword", + expectError: false, + }, + { + name: "Prefer password over access token", + serverDetails: &config.ServerDetails{ + User: "myuser", + Password: "mypassword", + AccessToken: "my-access-token", + }, + expectedUsername: "myuser", + expectedPassword: "mypassword", // Password is preferred for Conan (API keys work more reliably) + expectError: false, + }, + { + name: "No credentials", + serverDetails: &config.ServerDetails{ + ServerId: "test-server", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + username, password, err := extractCredentials(tt.serverDetails) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "no credentials") + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedUsername, username) + assert.Equal(t, tt.expectedPassword, password) + } + }) + } +} + +func TestFormatServerIDs(t *testing.T) { + tests := []struct { + name string + configs []*config.ServerDetails + expected string + }{ + { + name: "Single server", + configs: []*config.ServerDetails{ + {ServerId: "server1"}, + }, + expected: "server1", + }, + { + name: "Multiple servers", + configs: []*config.ServerDetails{ + {ServerId: "server1"}, + {ServerId: "server2"}, + {ServerId: "server3"}, + }, + expected: "server1, server2, server3", + }, + { + name: "Empty list", + configs: []*config.ServerDetails{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatServerIDs(tt.configs) + assert.Equal(t, tt.expected, result) + }) + } +} + + + + diff --git a/artifactory/commands/conan/upload.go b/artifactory/commands/conan/upload.go new file mode 100644 index 00000000..27625c3c --- /dev/null +++ b/artifactory/commands/conan/upload.go @@ -0,0 +1,403 @@ +// Package conan provides Conan package manager integration for JFrog Artifactory. +package conan + +import ( + "fmt" + "strings" + + "github.com/jfrog/build-info-go/build" + "github.com/jfrog/build-info-go/entities" + conanflex "github.com/jfrog/build-info-go/flexpack/conan" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// UploadProcessor processes Conan upload output and collects build info. +// It parses the upload output to: +// 1. Extract which artifacts were uploaded (to avoid collecting all revisions) +// 2. Collect dependencies from the local project +// 3. Set build properties on uploaded artifacts +type UploadProcessor struct { + workingDir string + buildConfiguration *buildUtils.BuildConfiguration + serverDetails *config.ServerDetails +} + +// NewUploadProcessor creates a new upload processor. +func NewUploadProcessor(workingDir string, buildConfig *buildUtils.BuildConfiguration, serverDetails *config.ServerDetails) *UploadProcessor { + return &UploadProcessor{ + workingDir: workingDir, + buildConfiguration: buildConfig, + serverDetails: serverDetails, + } +} + +// Process processes the upload output and collects build info. +func (up *UploadProcessor) Process(uploadOutput string) error { + // Parse uploaded artifacts from output - only collect what was actually uploaded + uploadedPaths := up.parseUploadedArtifactPaths(uploadOutput) + log.Debug(fmt.Sprintf("Found %d uploaded artifact paths", len(uploadedPaths))) + + // Parse package reference from upload output + packageRef := up.parsePackageReference(uploadOutput) + if packageRef == "" { + log.Debug("No package reference found in upload output") + return nil + } + log.Debug(fmt.Sprintf("Processing upload for package: %s", packageRef)) + + // Collect dependencies using FlexPack + buildInfo, err := up.collectDependencies() + if err != nil { + log.Warn(fmt.Sprintf("Failed to collect dependencies: %v", err)) + buildInfo = up.createEmptyBuildInfo(packageRef) + } + + // Get target repository from upload output + targetRepo, err := up.getTargetRepository(uploadOutput) + if err != nil { + log.Warn(fmt.Sprintf("Could not determine target repository: %v", err)) + log.Warn("Build info will be saved but artifacts may not be linked correctly") + return up.saveBuildInfo(buildInfo) + } + log.Debug(fmt.Sprintf("Using Artifactory repository: %s", targetRepo)) + + // Collect artifacts from Artifactory - only for uploaded paths + if up.serverDetails != nil && len(uploadedPaths) > 0 { + artifacts, collectErr := up.collectUploadedArtifacts(uploadedPaths, targetRepo) + if collectErr != nil { + log.Warn(fmt.Sprintf("Failed to collect artifacts: %v", collectErr)) + } else { + up.addArtifactsToModule(buildInfo, artifacts) + + // Set build properties on artifacts + if len(artifacts) > 0 { + if err := up.setBuildProperties(artifacts, targetRepo); err != nil { + log.Warn(fmt.Sprintf("Failed to set build properties: %v", err)) + } + } + } + } + + return up.saveBuildInfo(buildInfo) +} + +// getTargetRepository extracts the Conan remote from upload output and resolves it to Artifactory repo. +// Returns error if remote cannot be determined or is not an Artifactory repository. +func (up *UploadProcessor) getTargetRepository(uploadOutput string) (string, error) { + remoteName := extractRemoteNameFromOutput(uploadOutput) + if remoteName == "" { + return "", fmt.Errorf("could not extract remote name from upload output") + } + + // Verify this is an Artifactory Conan remote + remoteURL, err := getRemoteURL(remoteName) + if err != nil { + return "", fmt.Errorf("could not get URL for remote '%s': %w", remoteName, err) + } + + if !isArtifactoryConanRemote(remoteURL) { + return "", fmt.Errorf("remote '%s' is not an Artifactory Conan repository (URL: %s)", remoteName, remoteURL) + } + + // Get the Artifactory repository name from the URL + repoName := ExtractRepoName(remoteURL) + if repoName == "" { + return "", fmt.Errorf("could not extract repository name from URL: %s", remoteURL) + } + + return repoName, nil +} + +// parseUploadedArtifactPaths extracts the specific paths that were uploaded from conan upload output. +// Conan 2.x upload summary format: +// +// Upload summary: +// conan-local-testing-reshmi <- Remote name +// multideps/1.0.0 <- Package name/version +// revisions +// 797d134a8590a1bfa06d846768443f48 (Uploaded) <- Recipe revision +// packages +// 594ed0eb2e9dfcc60607438924c35871514e6c2a <- Package ID +// revisions +// ca858ea14c32f931e49241df0b52bec9 (Uploaded) <- Package revision +// +// This method parses this structure and builds Artifactory paths like: +// - _/multideps/1.0.0/_/797d134.../export (for recipe) +// - _/multideps/1.0.0/_/797d134.../package/594ed0.../ca858ea... (for package) +func (up *UploadProcessor) parseUploadedArtifactPaths(output string) []string { + var paths []string + lines := strings.Split(output, "\n") + + // State tracking for hierarchical parsing + var currentPkg string // Current package name/version (e.g., "multideps/1.0.0") + var currentRecipeRev string // Current recipe revision hash (MD5, 32 chars) + var currentPkgId string // Current package ID (SHA1, 40 chars) + inUploadSection := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Start parsing after "Upload summary" or "Uploading to remote" + if strings.Contains(line, "Upload summary") || strings.Contains(line, "Uploading to remote") { + inUploadSection = true + continue + } + + if !inUploadSection { + continue + } + + // Skip empty lines and section markers + if trimmed == "" || trimmed == "revisions" || trimmed == "packages" { + continue + } + + // Match package name/version line: "multideps/1.0.0" + // Must contain "/" but not be a path or special marker + if up.isPackageNameLine(trimmed) { + currentPkg = trimmed + currentRecipeRev = "" + currentPkgId = "" + continue + } + + // Match recipe/package revision with (Uploaded) or (Skipped, already in server) + // Both cases mean the artifact is in Artifactory and should be part of build info + if strings.Contains(trimmed, "(Uploaded)") || strings.Contains(trimmed, "(Skipped") { + // Extract revision hash by removing status suffix + rev := trimmed + if idx := strings.Index(rev, " ("); idx != -1 { + rev = rev[:idx] + } + rev = strings.TrimSpace(rev) + + if len(rev) == 32 { + if currentPkgId == "" { + // This is a recipe revision + currentRecipeRev = rev + if currentPkg != "" { + path := fmt.Sprintf("_/%s/_/%s/export", currentPkg, rev) + paths = append(paths, path) + } + } else if currentRecipeRev != "" { + // This is a package revision + if currentPkg != "" { + path := fmt.Sprintf("_/%s/_/%s/package/%s/%s", currentPkg, currentRecipeRev, currentPkgId, rev) + paths = append(paths, path) + } + currentPkgId = "" // Reset for next package + } + } + continue + } + + // Match package ID line (SHA1, 40 chars, no spaces, no parentheses) + if len(trimmed) == 40 && !strings.Contains(trimmed, " ") && !strings.Contains(trimmed, "(") { + currentPkgId = trimmed + } + } + + return paths +} + +// isPackageNameLine checks if a line represents a package name/version. +func (up *UploadProcessor) isPackageNameLine(line string) bool { + return strings.Contains(line, "/") && + !strings.Contains(line, "#") && + !strings.Contains(line, ":") && + !strings.HasPrefix(line, "_") && + !strings.Contains(line, "Uploading") && + !strings.Contains(line, "Skipped") && + !strings.Contains(line, "(") +} + +// collectUploadedArtifacts collects only artifacts from specific paths that were uploaded. +func (up *UploadProcessor) collectUploadedArtifacts(uploadedPaths []string, targetRepo string) ([]entities.Artifact, error) { + if up.serverDetails == nil { + return nil, fmt.Errorf("server details not initialized") + } + + collector := NewArtifactCollector(up.serverDetails, targetRepo) + var allArtifacts []entities.Artifact + + for _, path := range uploadedPaths { + artifacts, err := collector.CollectArtifactsForPath(path) + if err != nil { + log.Debug(fmt.Sprintf("Failed to collect artifacts for path %s: %v", path, err)) + continue + } + allArtifacts = append(allArtifacts, artifacts...) + } + + log.Info(fmt.Sprintf("Collected %d Conan artifacts", len(allArtifacts))) + return allArtifacts, nil +} + +// parsePackageReference extracts package reference from upload output. +func (up *UploadProcessor) parsePackageReference(output string) string { + lines := strings.Split(output, "\n") + inSummary := false + foundRemote := false + + for _, line := range lines { + if strings.Contains(line, "Upload summary") { + inSummary = true + continue + } + + if !inSummary { + continue + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "-") { + continue + } + + // Skip remote name line + if !foundRemote { + foundRemote = true + continue + } + + // Look for package reference pattern: name/version + if strings.Contains(trimmed, "/") && !strings.Contains(trimmed, ":") { + return trimmed + } + } + + // Fallback: look for "Uploading recipe" pattern (older Conan output format) + return up.parseUploadingRecipePattern(lines) +} + +// parseUploadingRecipePattern extracts package reference from "Uploading recipe" lines. +// Example: "Uploading recipe 'simplelib/1.0.0#86deb56...'" +func (up *UploadProcessor) parseUploadingRecipePattern(lines []string) string { + for _, line := range lines { + if strings.Contains(line, "Uploading recipe") { + start := strings.Index(line, "'") + end := strings.LastIndex(line, "'") + if start != -1 && end > start { + ref := line[start+1 : end] + // Remove revision if present (after #) + if hashIdx := strings.Index(ref, "#"); hashIdx != -1 { + ref = ref[:hashIdx] + } + return ref + } + } + } + return "" +} + +// collectDependencies collects dependencies using FlexPack. +func (up *UploadProcessor) collectDependencies() (*entities.BuildInfo, error) { + buildName, err := up.buildConfiguration.GetBuildName() + if err != nil { + return nil, fmt.Errorf("get build name: %w", err) + } + + buildNumber, err := up.buildConfiguration.GetBuildNumber() + if err != nil { + return nil, fmt.Errorf("get build number: %w", err) + } + + conanConfig := conanflex.ConanConfig{ + WorkingDirectory: up.workingDir, + } + + collector, err := conanflex.NewConanFlexPack(conanConfig) + if err != nil { + return nil, fmt.Errorf("create conan flexpack: %w", err) + } + + buildInfo, err := collector.CollectBuildInfo(buildName, buildNumber) + if err != nil { + return nil, fmt.Errorf("collect build info: %w", err) + } + + log.Debug(fmt.Sprintf("Collected build info with %d modules", len(buildInfo.Modules))) + return buildInfo, nil +} + +// createEmptyBuildInfo creates a minimal build info when dependency collection fails. +func (up *UploadProcessor) createEmptyBuildInfo(packageRef string) *entities.BuildInfo { + buildName, _ := up.buildConfiguration.GetBuildName() + buildNumber, _ := up.buildConfiguration.GetBuildNumber() + + return &entities.BuildInfo{ + Name: buildName, + Number: buildNumber, + Modules: []entities.Module{{Id: packageRef, Type: entities.Conan}}, + } +} + +// addArtifactsToModule adds artifacts to the first module in build info. +func (up *UploadProcessor) addArtifactsToModule(buildInfo *entities.BuildInfo, artifacts []entities.Artifact) { + if len(buildInfo.Modules) == 0 { + return + } + buildInfo.Modules[0].Artifacts = artifacts +} + +// setBuildProperties sets build properties on artifacts in Artifactory. +func (up *UploadProcessor) setBuildProperties(artifacts []entities.Artifact, targetRepo string) error { + buildName, err := up.buildConfiguration.GetBuildName() + if err != nil { + return err + } + + buildNumber, err := up.buildConfiguration.GetBuildNumber() + if err != nil { + return err + } + + projectKey := up.buildConfiguration.GetProject() + + setter := NewBuildPropertySetter(up.serverDetails, targetRepo, buildName, buildNumber, projectKey) + return setter.SetProperties(artifacts) +} + +// saveBuildInfo saves the build info for later publishing. +func (up *UploadProcessor) saveBuildInfo(buildInfo *entities.BuildInfo) error { + service := build.NewBuildInfoService() + + buildInstance, err := service.GetOrCreateBuildWithProject(buildInfo.Name, buildInfo.Number, "") + if err != nil { + return fmt.Errorf("create build: %w", err) + } + + if err := buildInstance.SaveBuildInfo(buildInfo); err != nil { + return fmt.Errorf("save build info: %w", err) + } + + log.Info("Conan build info saved locally") + return nil +} + +// extractRemoteNameFromOutput extracts the remote name from conan upload output. +// Looks in the "Upload summary" section for the remote name. +func extractRemoteNameFromOutput(output string) string { + lines := strings.Split(output, "\n") + inSummary := false + + for _, line := range lines { + if strings.Contains(line, "Upload summary") { + inSummary = true + continue + } + + if !inSummary { + continue + } + + trimmed := strings.TrimSpace(line) + // First non-empty, non-dashed line after summary is the remote name + if trimmed != "" && !strings.HasPrefix(trimmed, "-") && !strings.Contains(trimmed, "/") { + return trimmed + } + } + return "" +} diff --git a/artifactory/commands/conan/upload_test.go b/artifactory/commands/conan/upload_test.go new file mode 100644 index 00000000..915869f6 --- /dev/null +++ b/artifactory/commands/conan/upload_test.go @@ -0,0 +1,132 @@ +package conan + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractRemoteNameFromOutput(t *testing.T) { + tests := []struct { + name string + output string + expected string + }{ + { + name: "Standard upload summary", + output: ` +======== Uploading to remote conan-local ======== + +-------- Checking server for existing packages -------- +simplelib/1.0.0: Checking which revisions exist in the remote server + +-------- Upload summary -------- +conan-local + simplelib/1.0.0 + revisions + 86deb56ab95f8fe27d07debf8a6ee3f9 (Uploaded) +`, + expected: "conan-local", + }, + { + name: "Different remote name", + output: ` +-------- Upload summary -------- +my-remote-repo + mypackage/2.0.0 + revisions + abc123 (Uploaded) +`, + expected: "my-remote-repo", + }, + { + name: "No upload summary", + output: ` +Some other conan output +without upload summary +`, + expected: "", + }, + { + name: "Empty output", + output: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRemoteNameFromOutput(tt.output) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUploadProcessor_ParsePackageReference(t *testing.T) { + tests := []struct { + name string + output string + expected string + }{ + { + name: "Standard upload summary with package reference", + output: ` +-------- Upload summary -------- +conan-local + simplelib/1.0.0 + revisions + 86deb56ab95f8fe27d07debf8a6ee3f9 (Uploaded) +`, + expected: "simplelib/1.0.0", + }, + { + name: "Upload summary with Conan 1.x format", + output: ` +-------- Upload summary -------- +conan-local + boost/1.82.0@myuser/stable + revisions + abc123 (Uploaded) +`, + expected: "boost/1.82.0@myuser/stable", + }, + { + name: "Fallback to Uploading recipe pattern", + output: ` +simplelib/1.0.0: Uploading recipe 'simplelib/1.0.0#86deb56ab95f8fe27d07debf8a6ee3f9' (1.6KB) +Upload completed in 3s +`, + expected: "simplelib/1.0.0", + }, + { + name: "No package reference found", + output: "Some random output", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + processor := &UploadProcessor{} + result := processor.parsePackageReference(tt.output) + assert.Equal(t, tt.expected, result) + }) + } +} + + +func TestNewUploadProcessor(t *testing.T) { + workingDir := "/test/path" + + processor := NewUploadProcessor(workingDir, nil, nil) + + assert.NotNil(t, processor) + assert.Equal(t, workingDir, processor.workingDir) + assert.Nil(t, processor.buildConfiguration) + assert.Nil(t, processor.serverDetails) +} + + + + + diff --git a/go.mod b/go.mod index cb171675..0b8f7674 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,10 @@ require ( github.com/forPelevin/gomoji v1.4.1 github.com/google/go-containerregistry v0.20.7 github.com/jedib0t/go-pretty/v6 v6.7.5 - github.com/jfrog/build-info-go v1.12.5-0.20251209171349-eb030db986f9 + github.com/jfrog/build-info-go v1.8.9-0.20251223092904-9e9460642431 github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251015045218-1a38c9e47097 - github.com/jfrog/jfrog-client-go v1.55.1-0.20251209090954-d6b1c70d3a5e + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251223102649-e659f6937251 + github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7 github.com/pkg/errors v0.9.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -21,7 +21,7 @@ require ( oras.land/oras-go/v2 v2.6.0 ) -require golang.org/x/net v0.45.0 // indirect +require golang.org/x/net v0.47.0 // indirect require ( dario.cat/mergo v1.0.2 // indirect @@ -109,7 +109,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.43.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect @@ -123,10 +123,6 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -//replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251015045218-1a38c9e47097 +//replace github.com/jfrog/build-info-go => github.com/jfrog/build-info-go v1.8.9-0.20251223092904-9e9460642431 -replace github.com/jfrog/jfrog-client-go => github.com/naveenku-jfrog/jfrog-client-go v1.54.2-0.20251212200746-04c08281da50 - -//replace github.com/jfrog/build-info-go => github.com/naveenku-jfrog/build-info-go v1.12.1-0.20251204140510-1d49e7aa4a2b - -replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251026182600-8a8c0428f538 +// replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251026182600-8a8c0428f538 diff --git a/go.sum b/go.sum index 13a6d871..6a4c03dd 100644 --- a/go.sum +++ b/go.sum @@ -162,12 +162,14 @@ github.com/jedib0t/go-pretty/v6 v6.7.5 h1:9dJSWTJnsXJVVAbvxIFxeHf/JxoJd7GUl5o3Uz github.com/jedib0t/go-pretty/v6 v6.7.5/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jfrog/archiver/v3 v3.6.1 h1:LOxnkw9pOn45DzCbZNFV6K0+6dCsQ0L8mR3ZcujO5eI= github.com/jfrog/archiver/v3 v3.6.1/go.mod h1:VgR+3WZS4N+i9FaDwLZbq+jeU4B4zctXL+gL4EMzfLw= -github.com/jfrog/build-info-go v1.12.5-0.20251209171349-eb030db986f9 h1:CL7lp7Y7srwQ1vy1btX66t4wbztzEGQbqi/9tdEz7xk= -github.com/jfrog/build-info-go v1.12.5-0.20251209171349-eb030db986f9/go.mod h1:9W4U440fdTHwW1HiB/R0VQvz/5q8ZHsms9MWcq+JrdY= +github.com/jfrog/build-info-go v1.8.9-0.20251223092904-9e9460642431 h1:HyGqdD957CrW6T1Xst0CxTR0XqJ0baIo/uL4I5lhyuo= +github.com/jfrog/build-info-go v1.8.9-0.20251223092904-9e9460642431/go.mod h1:9W4U440fdTHwW1HiB/R0VQvz/5q8ZHsms9MWcq+JrdY= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251026182600-8a8c0428f538 h1:WgpC3kE2LkgJ+58RFbCx/ivNJzEQ4Ue8Q5BhLWWJ3tA= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251026182600-8a8c0428f538/go.mod h1:gtu43/QouMc0ENk6J/WH5glKH5S7wnPbMhFukN3FH8s= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251223102649-e659f6937251 h1:WTyDOaYJUwY6zQujZuL9JQ9Q9+QWj9p31tLb4bJnu4U= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20251223102649-e659f6937251/go.mod h1:REkU0OfnLYZbQIjD2Cg85DAVP0SRZuV/PxiDfCJiJOc= +github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7 h1:5JUiqmBV9ikFOZEH+ZgvJLHshT1aAuw08bfdJOLHbzQ= +github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7/go.mod h1:USb7bfWSE7bGKsJ4nR0lxGILvmtnCcR5OO4biSUItMs= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= @@ -213,8 +215,6 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/naveenku-jfrog/jfrog-client-go v1.54.2-0.20251212200746-04c08281da50 h1:LXVnlk26YzvgdC7iFQzJNDzWhF1UZF0c+1VI07v0pOk= -github.com/naveenku-jfrog/jfrog-client-go v1.54.2-0.20251212200746-04c08281da50/go.mod h1:WQ5Y+oKYyHFAlCbHN925bWhnShTd2ruxZ6YTpb76fpU= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -371,15 +371,15 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=