diff --git a/cli/cmd/add/component-version/cmd.go b/cli/cmd/add/component-version/cmd.go index f516a4452..74d3991fa 100644 --- a/cli/cmd/add/component-version/cmd.go +++ b/cli/cmd/add/component-version/cmd.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log/slog" + "os" "path/filepath" "strings" "time" @@ -28,6 +29,7 @@ import ( "ocm.software/open-component-model/cli/internal/flags/enum" "ocm.software/open-component-model/cli/internal/flags/file" "ocm.software/open-component-model/cli/internal/flags/log" + "ocm.software/open-component-model/cli/internal/reference/compref" ocmsync "ocm.software/open-component-model/cli/internal/sync" ) @@ -35,7 +37,6 @@ const ( FlagConcurrencyLimit = "concurrency-limit" FlagRepositoryRef = "repository" FlagComponentConstructorPath = "constructor" - FlagCopyResources = "copy-resources" FlagBlobCacheDirectory = "blob-cache-directory" FlagComponentVersionConflictPolicy = "component-version-conflict-policy" FlagSkipReferenceDigestProcessing = "skip-reference-digest-processing" @@ -106,7 +107,7 @@ add component-version --%[1]s ./path/to/%[2]s --%[3]s ./path/to/%[4]s.yaml } cmd.Flags().Int(FlagConcurrencyLimit, 4, "maximum number of component versions that can be constructed concurrently.") - file.VarP(cmd.Flags(), FlagRepositoryRef, string(FlagRepositoryRef[0]), LegacyDefaultArchiveName, "path to the repository") + cmd.Flags().StringP(FlagRepositoryRef, string(FlagRepositoryRef[0]), LegacyDefaultArchiveName, "repository specification") file.VarP(cmd.Flags(), FlagComponentConstructorPath, string(FlagComponentConstructorPath[0]), DefaultComponentConstructorBaseName+".yaml", "path to the component constructor file") cmd.Flags().String(FlagBlobCacheDirectory, filepath.Join(".ocm", "cache"), "path to the blob cache directory") enum.Var(cmd.Flags(), FlagComponentVersionConflictPolicy, ComponentVersionConflictPolicies(), "policy to apply when a component version already exists in the repository") @@ -220,19 +221,27 @@ func AddComponentVersion(cmd *cobra.Command, _ []string) error { } func GetRepositorySpec(cmd *cobra.Command) (runtime.Typed, error) { - repoRef, err := file.Get(cmd.Flags(), FlagRepositoryRef) + repositorySpec, err := cmd.Flags().GetString(FlagRepositoryRef) if err != nil { return nil, fmt.Errorf("getting repository reference flag failed: %w", err) } - var accessMode ctfv1.AccessMode = ctfv1.AccessModeReadWrite - if !repoRef.Exists() { - accessMode += "|" + ctfv1.AccessModeCreate + + typed, err := compref.ParseRepository(repositorySpec) + if err != nil { + return nil, fmt.Errorf("failed to parse repository: %w", err) } - repoSpec := ctfv1.Repository{ - Path: repoRef.String(), - AccessMode: accessMode, + + // Handle CTF-specific access mode configuration + if ctfRepo, ok := typed.(*ctfv1.Repository); ok { + var accessMode ctfv1.AccessMode = ctfv1.AccessModeReadWrite + // For CTF repositories, check if path exists to determine access mode + if _, err := os.Stat(ctfRepo.Path); os.IsNotExist(err) { + accessMode += "|" + ctfv1.AccessModeCreate + } + ctfRepo.AccessMode = accessMode } - return &repoSpec, nil + + return typed, nil } func GetComponentConstructor(file *file.Flag) (*constructorruntime.ComponentConstructor, error) { diff --git a/cli/docs/reference/ocm_add_component-version.md b/cli/docs/reference/ocm_add_component-version.md index f668dc96e..cec5d201d 100644 --- a/cli/docs/reference/ocm_add_component-version.md +++ b/cli/docs/reference/ocm_add_component-version.md @@ -50,7 +50,7 @@ add component-version --repository ./path/to/transport-archive --constructor ./ --concurrency-limit int maximum number of component versions that can be constructed concurrently. (default 4) -c, --constructor path path to the component constructor file (default component-constructor.yaml) -h, --help help for component-version - -r, --repository path path to the repository (default transport-archive) + -r, --repository string repository specification (default "transport-archive") --skip-reference-digest-processing skip digest processing for resources and sources. Any resource referenced via access type will not have their digest updated. ``` diff --git a/cli/integration/add_component_version_integration_test.go b/cli/integration/add_component_version_integration_test.go new file mode 100644 index 000000000..9a2279fd2 --- /dev/null +++ b/cli/integration/add_component_version_integration_test.go @@ -0,0 +1,160 @@ +package integration + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "ocm.software/open-component-model/cli/cmd" +) + +func Test_Integration_AddComponentVersion_OCIRepository(t *testing.T) { + suite := SetupTestSuite(t) + + t.Run("add component-version with plain OCI registry reference", func(t *testing.T) { + r := require.New(t) + + componentName := "ocm.software/test-component" + componentVersion := "v1.0.0" + + // Create constructor file using suite helper + constructorPath := suite.CreateComponentConstructor(t, componentName, componentVersion) + + // Test the add component-version command with plain OCI registry reference + addCMD := cmd.New() + addCMD.SetArgs([]string{ + "add", + "component-version", + "--repository", suite.GetRepositoryURL(), + "--constructor", constructorPath, + "--config", suite.ConfigPath, + }) + + r.NoError(addCMD.ExecuteContext(t.Context()), "add component-version should succeed with OCI registry") + + // Verify the component version was added by attempting to retrieve it + desc, err := suite.Repository.GetComponentVersion(t.Context(), componentName, componentVersion) + r.NoError(err, "should be able to retrieve the added component version") + r.Equal(componentName, desc.Component.Name) + r.Equal(componentVersion, desc.Component.Version) + r.Equal("ocm.software", desc.Component.Provider.Name) + r.Len(desc.Component.Resources, 1) + r.Equal("test-resource", desc.Component.Resources[0].Name) + r.Equal(componentVersion, desc.Component.Resources[0].Version) + }) + + t.Run("add component-version with explicit OCI type prefix", func(t *testing.T) { + r := require.New(t) + + componentName := "ocm.software/explicit-oci-component" + componentVersion := "v2.0.0" + + // Create constructor file using suite helper + constructorPath := suite.CreateComponentConstructor(t, componentName, componentVersion) + + // Test with explicit oci:: prefix + addCMD := cmd.New() + addCMD.SetArgs([]string{ + "add", + "component-version", + "--repository", suite.GetRepositoryURLWithPrefix("oci"), + "--constructor", constructorPath, + "--config", suite.ConfigPath, + }) + + r.NoError(addCMD.ExecuteContext(t.Context()), "add component-version should succeed with explicit OCI type") + + // Verify the component version was added + desc, err := suite.Repository.GetComponentVersion(t.Context(), componentName, componentVersion) + r.NoError(err, "should be able to retrieve the component version added with explicit OCI type") + r.Equal(componentName, desc.Component.Name) + r.Equal(componentVersion, desc.Component.Version) + }) + + t.Run("add component-version with HTTP URL format", func(t *testing.T) { + r := require.New(t) + + componentName := "ocm.software/http-component" + componentVersion := "v3.0.0" + + // Create constructor file using suite helper + constructorPath := suite.CreateComponentConstructor(t, componentName, componentVersion) + + // Test with HTTP URL format + addCMD := cmd.New() + addCMD.SetArgs([]string{ + "add", + "component-version", + "--repository", suite.GetRepositoryURL(), + "--constructor", constructorPath, + "--config", suite.ConfigPath, + }) + + r.NoError(addCMD.ExecuteContext(t.Context()), "add component-version should succeed with HTTP URL format") + + // Verify the component version was added + desc, err := suite.Repository.GetComponentVersion(t.Context(), componentName, componentVersion) + r.NoError(err, "should be able to retrieve the component version added with HTTP URL") + r.Equal(componentName, desc.Component.Name) + r.Equal(componentVersion, desc.Component.Version) + }) +} + +func Test_Integration_AddComponentVersion_CTFRepository(t *testing.T) { + t.Run("add component-version with CTF archive path", func(t *testing.T) { + r := require.New(t) + + componentName := "ocm.software/ctf-component" + componentVersion := "v1.0.0" + + // Create temporary test suite for this test (no shared registry needed for CTF) + suite := &TestSuite{} + constructorPath := suite.CreateComponentConstructor(t, componentName, componentVersion) + + // Test with CTF archive path + ctfArchivePath := filepath.Join(t.TempDir(), "test-archive") + addCMD := cmd.New() + addCMD.SetArgs([]string{ + "add", + "component-version", + "--repository", ctfArchivePath, + "--constructor", constructorPath, + }) + + r.NoError(addCMD.ExecuteContext(t.Context()), "add component-version should succeed with CTF archive") + + // Verify the archive was created + _, err := os.Stat(ctfArchivePath) + r.NoError(err, "CTF archive should be created") + }) + + t.Run("add component-version with explicit CTF type prefix", func(t *testing.T) { + r := require.New(t) + + componentName := "ocm.software/explicit-ctf-component" + componentVersion := "v2.0.0" + + // Create temporary test suite for this test (no shared registry needed for CTF) + suite := &TestSuite{} + constructorPath := suite.CreateComponentConstructor(t, componentName, componentVersion) + + // Test with explicit ctf:: prefix + ctfArchivePath := filepath.Join(t.TempDir(), "explicit-archive") + addCMD := cmd.New() + addCMD.SetArgs([]string{ + "add", + "component-version", + "--repository", fmt.Sprintf("ctf::%s", ctfArchivePath), + "--constructor", constructorPath, + }) + + r.NoError(addCMD.ExecuteContext(t.Context()), "add component-version should succeed with explicit CTF type") + + // Verify the archive was created + _, err := os.Stat(ctfArchivePath) + r.NoError(err, "CTF archive should be created with explicit type") + }) +} \ No newline at end of file diff --git a/cli/integration/download_resource_integration_test.go b/cli/integration/download_resource_integration_test.go index b6299def6..7d0dea7c3 100644 --- a/cli/integration/download_resource_integration_test.go +++ b/cli/integration/download_resource_integration_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "net" "os" "os/exec" "path/filepath" @@ -23,8 +22,6 @@ import ( "ocm.software/open-component-model/bindings/go/blob/filesystem" descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" v2 "ocm.software/open-component-model/bindings/go/descriptor/v2" - "ocm.software/open-component-model/bindings/go/oci" - urlresolver "ocm.software/open-component-model/bindings/go/oci/resolver/url" "ocm.software/open-component-model/bindings/go/oci/spec/layout" "ocm.software/open-component-model/bindings/go/repository" "ocm.software/open-component-model/cli/cmd" @@ -33,51 +30,7 @@ import ( ) func Test_Integration_OCIRepository(t *testing.T) { - r := require.New(t) - t.Parallel() - - t.Logf("Starting OCI based integration test") - user := "ocm" - - // Setup credentials and htpasswd - password := internal.GenerateRandomPassword(t, 20) - htpasswd := internal.GenerateHtpasswd(t, user, password) - - // Start containerized registry - registryAddress := internal.StartDockerContainerRegistry(t, htpasswd) - host, port, err := net.SplitHostPort(registryAddress) - r.NoError(err) - - cfg := fmt.Sprintf(` -type: generic.config.ocm.software/v1 -configurations: -- type: credentials.config.ocm.software - consumers: - - identity: - type: OCIRepository/v1 - hostname: %[1]q - port: %[2]q - scheme: http - credentials: - - type: Credentials/v1 - properties: - username: %[3]q - password: %[4]q -`, host, port, user, password) - cfgPath := filepath.Join(t.TempDir(), "ocmconfig.yaml") - r.NoError(os.WriteFile(cfgPath, []byte(cfg), os.ModePerm)) - - client := internal.CreateAuthClient(registryAddress, user, password) - - resolver, err := urlresolver.New( - urlresolver.WithBaseURL(registryAddress), - urlresolver.WithPlainHTTP(true), - urlresolver.WithBaseClient(client), - ) - r.NoError(err) - - repo, err := oci.NewRepository(oci.WithResolver(resolver), oci.WithTempDir(t.TempDir())) - r.NoError(err) + suite := SetupTestSuite(t) t.Run("download resource with arbitrary byte stream data", func(t *testing.T) { r := require.New(t) @@ -99,7 +52,7 @@ configurations: name, version := "ocm.software/test-component", "v1.0.0" - uploadComponentVersion(t, repo, name, version, localResource) + uploadComponentVersion(t, suite.Repository, name, version, localResource) downloadCMD := cmd.New() @@ -108,13 +61,13 @@ configurations: downloadCMD.SetArgs([]string{ "download", "resource", - fmt.Sprintf("http://%s//%s:%s", registryAddress, name, version), + fmt.Sprintf("%s//%s:%s", suite.GetRepositoryURL(), name, version), "--identity", fmt.Sprintf("name=%s,version=%s", localResource.Resource.Name, localResource.Resource.Version), "--output", output, "--config", - cfgPath, + suite.ConfigPath, }) r.NoError(downloadCMD.ExecuteContext(t.Context())) @@ -153,9 +106,9 @@ configurations: true), } - name, version := "ocm.software/test-component", "v1.0.0" + name, version := "ocm.software/test-component-oci-layout", "v1.0.0" - uploadComponentVersion(t, repo, name, version, localResource) + uploadComponentVersion(t, suite.Repository, name, version, localResource) t.Run("download with disabled extract", func(t *testing.T) { r := require.New(t) @@ -164,13 +117,13 @@ configurations: downloadCMD.SetArgs([]string{ "download", "resource", - fmt.Sprintf("http://%s//%s:%s", registryAddress, name, version), + fmt.Sprintf("%s//%s:%s", suite.GetRepositoryURL(), name, version), "--identity", fmt.Sprintf("name=%s,version=%s", localResource.Resource.Name, localResource.Resource.Version), "--output", output, "--config", - cfgPath, + suite.ConfigPath, "--extraction-policy", resourceCMD.ExtractionPolicyDisable, }) @@ -188,13 +141,13 @@ configurations: downloadCMD.SetArgs([]string{ "download", "resource", - fmt.Sprintf("http://%s//%s:%s", registryAddress, name, version), + fmt.Sprintf("%s//%s:%s", suite.GetRepositoryURL(), name, version), "--identity", fmt.Sprintf("name=%s,version=%s", localResource.Resource.Name, localResource.Resource.Version), "--output", output, "--config", - cfgPath, + suite.ConfigPath, }) r.NoError(downloadCMD.ExecuteContext(t.Context())) diff --git a/cli/integration/internal/credentials.go b/cli/integration/internal/credentials.go index 49a601357..4ce78fbb6 100644 --- a/cli/integration/internal/credentials.go +++ b/cli/integration/internal/credentials.go @@ -8,9 +8,6 @@ import ( "testing" "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/log" - "github.com/testcontainers/testcontainers-go/modules/registry" "golang.org/x/crypto/bcrypt" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/retry" @@ -54,30 +51,3 @@ func CreateAuthClient(address, username, password string) *auth.Client { }), } } - -const distributionRegistryImage = "registry:3.0.0" - -func StartDockerContainerRegistry(t *testing.T, htpasswd string) string { - t.Helper() - // Start containerized registry - t.Logf("Launching test registry (%s)...", distributionRegistryImage) - registryContainer, err := registry.Run(t.Context(), distributionRegistryImage, - registry.WithHtpasswd(htpasswd), - testcontainers.WithEnv(map[string]string{ - "REGISTRY_VALIDATION_DISABLED": "true", - "REGISTRY_LOG_LEVEL": "debug", - }), - testcontainers.WithLogger(log.TestLogger(t)), - ) - r := require.New(t) - r.NoError(err) - t.Cleanup(func() { - r.NoError(testcontainers.TerminateContainer(registryContainer)) - }) - t.Logf("Test registry started") - - registryAddress, err := registryContainer.HostAddress(t.Context()) - r.NoError(err) - - return registryAddress -} diff --git a/cli/integration/suite_test.go b/cli/integration/suite_test.go new file mode 100644 index 000000000..a03301ed1 --- /dev/null +++ b/cli/integration/suite_test.go @@ -0,0 +1,181 @@ +package integration + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/log" + "github.com/testcontainers/testcontainers-go/modules/registry" + + "ocm.software/open-component-model/bindings/go/oci" + urlresolver "ocm.software/open-component-model/bindings/go/oci/resolver/url" + "ocm.software/open-component-model/cli/integration/internal" +) + +// TestSuite holds shared test infrastructure +type TestSuite struct { + RegistryAddress string + Username string + Password string + ConfigPath string + Repository *oci.Repository +} + +var ( + globalTestSuite *TestSuite +) + +// TestMain sets up and tears down the test suite +func TestMain(m *testing.M) { + var exitCode int + + // Setup + globalTestSuite = &TestSuite{} + err := globalTestSuite.setup() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to setup test suite: %v\n", err) + os.Exit(1) + } + + // Run tests + exitCode = m.Run() + + // Teardown + globalTestSuite.teardown() + + os.Exit(exitCode) +} + +// SetupTestSuite returns the global test suite instance +func SetupTestSuite(t *testing.T) *TestSuite { + if globalTestSuite == nil { + t.Fatal("Test suite not initialized. TestMain should handle this.") + } + return globalTestSuite +} + +// setup initializes the test suite +func (ts *TestSuite) setup() error { + ctx := context.Background() + + ts.Username = "ocm" + + // Generate password and credentials + ts.Password = internal.GenerateRandomPassword(&testing.T{}, 20) + htpasswd := internal.GenerateHtpasswd(&testing.T{}, ts.Username, ts.Password) + + // Start registry container + registryContainer, err := registry.Run(ctx, "registry:3.0.0", + registry.WithHtpasswd(htpasswd), + testcontainers.WithEnv(map[string]string{ + "REGISTRY_VALIDATION_DISABLED": "true", + "REGISTRY_LOG_LEVEL": "debug", + }), + testcontainers.WithLogger(log.Default()), + ) + if err != nil { + return fmt.Errorf("failed to start registry: %w", err) + } + + ts.RegistryAddress, err = registryContainer.HostAddress(ctx) + if err != nil { + return fmt.Errorf("failed to get registry address: %w", err) + } + + host, port, err := net.SplitHostPort(ts.RegistryAddress) + if err != nil { + return fmt.Errorf("failed to parse registry address: %w", err) + } + + // Generate OCM config + cfg := fmt.Sprintf(` +type: generic.config.ocm.software/v1 +configurations: +- type: credentials.config.ocm.software + consumers: + - identity: + type: OCIRepository/v1 + hostname: %[1]q + port: %[2]q + scheme: http + credentials: + - type: Credentials/v1 + properties: + username: %[3]q + password: %[4]q +`, host, port, ts.Username, ts.Password) + + globalTempDir := os.TempDir() + ts.ConfigPath = filepath.Join(globalTempDir, fmt.Sprintf("ocmconfig-suite-%d.yaml", os.Getpid())) + if err := os.WriteFile(ts.ConfigPath, []byte(cfg), os.ModePerm); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + // Setup OCI client and repository + client := internal.CreateAuthClient(ts.RegistryAddress, ts.Username, ts.Password) + + resolver, err := urlresolver.New( + urlresolver.WithBaseURL(ts.RegistryAddress), + urlresolver.WithPlainHTTP(true), + urlresolver.WithBaseClient(client), + ) + if err != nil { + return fmt.Errorf("failed to create resolver: %w", err) + } + + ts.Repository, err = oci.NewRepository(oci.WithResolver(resolver), oci.WithTempDir(os.TempDir())) + if err != nil { + return fmt.Errorf("failed to create repository: %w", err) + } + + fmt.Printf("Shared test registry setup complete: %s\n", ts.RegistryAddress) + return nil +} + +// teardown cleans up the test suite +func (ts *TestSuite) teardown() { + if ts.ConfigPath != "" { + if err := os.Remove(ts.ConfigPath); err != nil { + fmt.Printf("Warning: failed to cleanup config file %s: %v\n", ts.ConfigPath, err) + } + } + fmt.Println("Suite teardown complete") +} + +// CreateComponentConstructor creates a component constructor file for testing +func (ts *TestSuite) CreateComponentConstructor(t *testing.T, componentName, componentVersion string) string { + constructorContent := fmt.Sprintf(` +components: +- name: %s + version: %s + provider: + name: ocm.software + resources: + - name: test-resource + version: %s + type: plainText + input: + type: utf8 + text: "Hello, World from %s!" +`, componentName, componentVersion, componentVersion, componentName) + + constructorPath := filepath.Join(t.TempDir(), "constructor.yaml") + require.NoError(t, os.WriteFile(constructorPath, []byte(constructorContent), os.ModePerm)) + return constructorPath +} + +// GetRepositoryURL returns the base repository URL for the shared registry +func (ts *TestSuite) GetRepositoryURL() string { + return fmt.Sprintf("http://%s", ts.RegistryAddress) +} + +// GetRepositoryURLWithPrefix returns the repository URL with the specified type prefix +func (ts *TestSuite) GetRepositoryURLWithPrefix(prefix string) string { + return fmt.Sprintf("%s::%s", prefix, ts.GetRepositoryURL()) +} diff --git a/cli/internal/reference/compref/compref.go b/cli/internal/reference/compref/compref.go index 876dfa6f0..74c965057 100644 --- a/cli/internal/reference/compref/compref.go +++ b/cli/internal/reference/compref/compref.go @@ -185,25 +185,70 @@ func Parse(input string) (*Ref, error) { return nil, fmt.Errorf("invalid component name %q in %q, must match %q", ref.Component, originalInput, ComponentRegex) } - // Step 6: Resolve type if not explicitly given + // Step 6: Build repository object using ParseRepository + var repositorySpec string + if ref.Type != "" { + repositorySpec = ref.Type + "::" + input + } else { + repositorySpec = input + } + + repository, err := ParseRepository(repositorySpec) + if err != nil { + return nil, fmt.Errorf("failed to parse repository: %w", err) + } + + ref.Repository = repository + + // Extract the type from the parsed repository for consistency if ref.Type == "" { - t, err := GuessType(input) + switch repository.(type) { + case *ociv1.Repository: + ref.Type = runtime.NewVersionedType(ociv1.Type, ociv1.Version).String() + case *ctfv1.Repository: + ref.Type = runtime.NewVersionedType(ctfv1.Type, ctfv1.Version).String() + } + } + + return ref, nil +} + +// ParseRepository parses a repository specification string and returns a typed repository object. +// It accepts repository strings in the format: +// - [::] +// +// Where type can be "ctf" or "oci", and repository-spec is the actual repository location. +// If no type is specified, it will be guessed using heuristics. +func ParseRepository(repositorySpec string) (runtime.Typed, error) { + originalInput := repositorySpec + input := repositorySpec + + // Extract optional type + var repoType string + if idx := strings.Index(input, "::"); idx != -1 { + repoType = input[:idx] + input = input[idx+2:] + } + + // Resolve type if isn't explicitly given + if repoType == "" { + t, err := guessType(input) if err != nil { return nil, fmt.Errorf("failed to detect repository type from %q: %w", input, err) } Base.Debug("ocm had to guess your repository type", "type", t, "input", input) - ref.Type = t + repoType = t } - // Step 7: Build repository object - rtyp, err := runtime.TypeFromString(ref.Type) + // Build repository object + rtyp, err := runtime.TypeFromString(repoType) if err != nil { - return nil, fmt.Errorf("unknown type %q in %q: %w", ref.Type, originalInput, err) + return nil, fmt.Errorf("unknown type %q in %q: %w", repoType, originalInput, err) } typed, err := RepositoryScheme.NewObject(rtyp) if err != nil { - return nil, fmt.Errorf("failed to create repository of type %q: %w", ref.Type, err) + return nil, fmt.Errorf("failed to create repository of type %q: %w", repoType, err) } switch t := typed.(type) { @@ -216,15 +261,13 @@ func Parse(input string) (*Ref, error) { case *ctfv1.Repository: t.Path = input default: - return nil, fmt.Errorf("unsupported repository type: %q", ref.Type) + return nil, fmt.Errorf("unsupported repository type: %q", repoType) } - ref.Repository = typed - - return ref, nil + return typed, nil } -// GuessType tries to guess the repository type ("ctf" or "oci") +// guessType tries to guess the repository type ("ctf" or "oci") // from an untyped repository specification string. // // You may ask yourself why this is needed. @@ -239,7 +282,7 @@ func Parse(input string) (*Ref, error) { // - If it looks like a domain (contains dots like ".com", ".io", etc.), assume OCI // - If it contains a colon (e.g., "localhost:5000"), assume OCI // - Otherwise fallback to CTF -func GuessType(repository string) (string, error) { +func guessType(repository string) (string, error) { // Try parsing as URL first if u, err := url.Parse(repository); err == nil { if u.Scheme == "file" { diff --git a/cli/internal/reference/compref/compref_test.go b/cli/internal/reference/compref/compref_test.go index ece040a51..792d2b1d7 100644 --- a/cli/internal/reference/compref/compref_test.go +++ b/cli/internal/reference/compref/compref_test.go @@ -382,3 +382,148 @@ func Test_ComponentReference_Permutations(t *testing.T) { } } } + +func TestParseRepository(t *testing.T) { + tests := []struct { + name string + repoSpec string + expectedType string + validateResult func(t *testing.T, result runtime.Typed, repoSpec string) + }{ + { + name: "OCI Registry - GitHub Container Registry", + repoSpec: "ghcr.io/my-org/my-repo", + expectedType: "*oci.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ociv1.Repository) + require.True(t, ok, "expected *ociv1.Repository") + require.Equal(t, repoSpec, repo.BaseUrl) + }, + }, + { + name: "OCI Registry - localhost with port", + repoSpec: "localhost:5000/my-repo", + expectedType: "*oci.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ociv1.Repository) + require.True(t, ok, "expected *ociv1.Repository") + require.Equal(t, repoSpec, repo.BaseUrl) + }, + }, + { + name: "OCI Registry - HTTPS URL", + repoSpec: "https://registry.example.com/my-repo", + expectedType: "*oci.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ociv1.Repository) + require.True(t, ok, "expected *ociv1.Repository") + require.Equal(t, repoSpec, repo.BaseUrl) + }, + }, + { + name: "CTF Archive - relative path", + repoSpec: "./non-existing-archive", + expectedType: "*ctf.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ctfv1.Repository) + require.True(t, ok, "expected *ctfv1.Repository") + require.Equal(t, repoSpec, repo.Path) + }, + }, + { + name: "CTF Archive - absolute path", + repoSpec: "/tmp/test-archive", + expectedType: "*ctf.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ctfv1.Repository) + require.True(t, ok, "expected *ctfv1.Repository") + require.Equal(t, repoSpec, repo.Path) + }, + }, + { + name: "CTF Archive - file URL", + repoSpec: "file://./local/transport-archive", + expectedType: "*ctf.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ctfv1.Repository) + require.True(t, ok, "expected *ctfv1.Repository") + require.Equal(t, repoSpec, repo.Path) + }, + }, + { + name: "OCI Registry with explicit type", + repoSpec: "oci::ghcr.io/my-org/my-repo", + expectedType: "*oci.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ociv1.Repository) + require.True(t, ok, "expected *ociv1.Repository") + require.Equal(t, "ghcr.io/my-org/my-repo", repo.BaseUrl) + }, + }, + { + name: "CTF Archive with explicit type", + repoSpec: "ctf::./local/archive", + expectedType: "*ctf.Repository", + validateResult: func(t *testing.T, result runtime.Typed, repoSpec string) { + repo, ok := result.(*ctfv1.Repository) + require.True(t, ok, "expected *ctfv1.Repository") + require.Equal(t, "./local/archive", repo.Path) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := require.New(t) + + result, err := ParseRepository(tt.repoSpec) + r.NoError(err, "unexpected error: %v", err) + r.NotNil(result, "expected non-nil result") + + resultType := getRepositoryTypeName(result) + r.Equal(tt.expectedType, resultType, "unexpected repository type") + tt.validateResult(t, result, tt.repoSpec) + }) + } +} + +func TestParseRepositoryErrorCases(t *testing.T) { + tests := []struct { + name string + repoSpec string + expectedError string + }{ + { + name: "unknown type", + repoSpec: "unknown::some-repo", + expectedError: "unsupported repository type", + }, + { + name: "invalid type format", + repoSpec: "invalid-type-format::some-repo", + expectedError: "unsupported repository type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := require.New(t) + + result, err := ParseRepository(tt.repoSpec) + r.Error(err, "expected error but got none") + r.Nil(result, "expected nil result on error") + r.Contains(err.Error(), tt.expectedError, "unexpected error message") + }) + } +} + +func getRepositoryTypeName(v runtime.Typed) string { + switch v.(type) { + case *ociv1.Repository: + return "*oci.Repository" + case *ctfv1.Repository: + return "*ctf.Repository" + default: + return "unknown" + } +}