diff --git a/docs/reference/ocm.md b/docs/reference/ocm.md index 863d813065..77ee2b50e7 100644 --- a/docs/reference/ocm.md +++ b/docs/reference/ocm.md @@ -50,9 +50,12 @@ location a default configuration is composed according to known type specific configuration files. The following configuration sources are used: - - The docker configuration file at ~/.docker/config.jaon is + - The docker configuration file at ~/.docker/config.json is read to feed in the configured credentials for OCI registries. + - The npm configuration file at ~/.npmrc is + read to feed in the configured credentials for NPM registries. + With the option --cred it is possible to specify arbitrary credentials diff --git a/docs/reference/ocm_credential-handling.md b/docs/reference/ocm_credential-handling.md index 049c39a3dc..fa503e5457 100644 --- a/docs/reference/ocm_credential-handling.md +++ b/docs/reference/ocm_credential-handling.md @@ -170,6 +170,19 @@ The following credential consumer types are used/supported: - certificateAuthority: the certificate authority certificate used to verify certificates + - Registry.npmjs.com: NPM repository + + It matches the Registry.npmjs.com consumer type and additionally acts like + the hostpath type. + + Credential consumers of the consumer type Registry.npmjs.com evaluate the following credential properties: + + - username: the basic auth user name + - password: the basic auth password + - email: NPM registry, require an email address + - token: the token attribute. May exist after login at any npm registry. Check your .npmrc file! + + - S3: S3 credential matcher This matcher is a hostpath matcher. @@ -306,6 +319,21 @@ behaviours are described in the following list: is read. +- Credential provider NPMConfig + + This repository type can be used to access credentials stored in a file + following the NPM npmrc format (~/.npmrc). It take into account the + credentials helper section, also. If enabled, the described + credentials will be automatically assigned to appropriate consumer ids. + + The following versions are supported: + - Version v1 + + The repository specification supports the following fields: + - npmrcFile: *string*: the file path to a NPM npmrc file + - propagateConsumerIdentity: *bool*(optional): enable consumer id propagation + + ### SEE ALSO diff --git a/docs/reference/ocm_get_credentials.md b/docs/reference/ocm_get_credentials.md index 8d56179552..e6b09c0373 100644 --- a/docs/reference/ocm_get_credentials.md +++ b/docs/reference/ocm_get_credentials.md @@ -96,6 +96,19 @@ Matchers exist for the following usage contexts or consumer types: - certificateAuthority: the certificate authority certificate used to verify certificates + - Registry.npmjs.com: NPM repository + + It matches the Registry.npmjs.com consumer type and additionally acts like + the hostpath type. + + Credential consumers of the consumer type Registry.npmjs.com evaluate the following credential properties: + + - username: the basic auth user name + - password: the basic auth password + - email: NPM registry, require an email address + - token: the token attribute. May exist after login at any npm registry. Check your .npmrc file! + + - S3: S3 credential matcher This matcher is a hostpath matcher. diff --git a/docs/reference/ocm_logging.md b/docs/reference/ocm_logging.md index 78950872ce..e1de158350 100644 --- a/docs/reference/ocm_logging.md +++ b/docs/reference/ocm_logging.md @@ -17,6 +17,7 @@ The following *tags* are used by the command line tool: The following *realms* are used by the command line tool: - ocm: general realm used for the ocm go library. + - ocm/NPM: NPM registry - ocm/accessmethod/ociartifact: access method ociArtifact - ocm/compdesc: component descriptor handling - ocm/config: configuration management diff --git a/docs/reference/ocm_ocm-uploadhandlers.md b/docs/reference/ocm_ocm-uploadhandlers.md index 161043fd17..767da82a6d 100644 --- a/docs/reference/ocm_ocm-uploadhandlers.md +++ b/docs/reference/ocm_ocm-uploadhandlers.md @@ -36,10 +36,6 @@ resource blob), it is possible to pass a target configuration controlling the exact behaviour of the handler for selected artifacts. The following handler names are possible: - - plugin: [downloaders provided by plugins] - - sub namespace of the form <plugin name>/<handler> - - ocm/ociArtifacts: downloading OCI artifacts The ociArtifacts downloader is able to download OCI artifacts @@ -64,6 +60,19 @@ The following handler names are possible: Alternatively, a single string value can be given representing an OCI repository reference. + - plugin: [downloaders provided by plugins] + + sub namespace of the form <plugin name>/<handler> + + - ocm/npmPackage: uploading npm artifacts + + The ocm/npmPackage uploader is able to upload npm artifacts + as artifact archive according to the npm package spec. + If registered the default mime type is: application/x-tgz + + It accepts a plain string for the URL or a config with the following field: + 'url': the URL of the npm repository. + See [ocm ocm-uploadhandlers](ocm_ocm-uploadhandlers.md) for further details on using diff --git a/docs/reference/ocm_transfer_commontransportarchive.md b/docs/reference/ocm_transfer_commontransportarchive.md index 43a638641f..faa7bfe6a1 100644 --- a/docs/reference/ocm_transfer_commontransportarchive.md +++ b/docs/reference/ocm_transfer_commontransportarchive.md @@ -110,10 +110,6 @@ are configured for the operation. It has the following format The uploader name may be a path expression with the following possibilities: - - plugin: [downloaders provided by plugins] - - sub namespace of the form <plugin name>/<handler> - - ocm/ociArtifacts: downloading OCI artifacts The ociArtifacts downloader is able to download OCI artifacts @@ -138,6 +134,19 @@ The uploader name may be a path expression with the following possibilities: Alternatively, a single string value can be given representing an OCI repository reference. + - plugin: [downloaders provided by plugins] + + sub namespace of the form <plugin name>/<handler> + + - ocm/npmPackage: uploading npm artifacts + + The ocm/npmPackage uploader is able to upload npm artifacts + as artifact archive according to the npm package spec. + If registered the default mime type is: application/x-tgz + + It accepts a plain string for the URL or a config with the following field: + 'url': the URL of the npm repository. + See [ocm ocm-uploadhandlers](ocm_ocm-uploadhandlers.md) for further details on using diff --git a/docs/reference/ocm_transfer_componentversions.md b/docs/reference/ocm_transfer_componentversions.md index 31bea4db5d..cbbae63fe8 100644 --- a/docs/reference/ocm_transfer_componentversions.md +++ b/docs/reference/ocm_transfer_componentversions.md @@ -167,10 +167,6 @@ are configured for the operation. It has the following format The uploader name may be a path expression with the following possibilities: - - plugin: [downloaders provided by plugins] - - sub namespace of the form <plugin name>/<handler> - - ocm/ociArtifacts: downloading OCI artifacts The ociArtifacts downloader is able to download OCI artifacts @@ -195,6 +191,19 @@ The uploader name may be a path expression with the following possibilities: Alternatively, a single string value can be given representing an OCI repository reference. + - plugin: [downloaders provided by plugins] + + sub namespace of the form <plugin name>/<handler> + + - ocm/npmPackage: uploading npm artifacts + + The ocm/npmPackage uploader is able to upload npm artifacts + as artifact archive according to the npm package spec. + If registered the default mime type is: application/x-tgz + + It accepts a plain string for the URL or a config with the following field: + 'url': the URL of the npm repository. + See [ocm ocm-uploadhandlers](ocm_ocm-uploadhandlers.md) for further details on using diff --git a/pkg/contexts/config/configutils/configure.go b/pkg/contexts/config/configutils/configure.go index 9cb7068f06..b28c2a0920 100644 --- a/pkg/contexts/config/configutils/configure.go +++ b/pkg/contexts/config/configutils/configure.go @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Open Component Model contributors. -// -// SPDX-License-Identifier: Apache-2.0 - package configutils import ( @@ -27,7 +23,7 @@ func Configure(path string) error { func ConfigureContext(ctxp config.ContextProvider, path string) error { ctx := config.FromProvider(ctxp) - h := os.Getenv("HOME") + h, _ := os.UserHomeDir() if path == "" { if h != "" { cfg := h + "/.ocmconfig" diff --git a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/identity.go b/pkg/contexts/credentials/builtin/npm/identity/identity.go similarity index 63% rename from pkg/contexts/ocm/blobhandler/handlers/generic/npm/identity.go rename to pkg/contexts/credentials/builtin/npm/identity/identity.go index d6b5bb02e1..32f9a16e8c 100644 --- a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/identity.go +++ b/pkg/contexts/credentials/builtin/npm/identity/identity.go @@ -1,4 +1,4 @@ -package npm +package identity import ( "path" @@ -9,13 +9,32 @@ import ( "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" "github.com/open-component-model/ocm/pkg/contexts/credentials/identity/hostpath" "github.com/open-component-model/ocm/pkg/listformat" + "github.com/open-component-model/ocm/pkg/logging" ) +const ( + // CONSUMER_TYPE is the npm repository type. + CONSUMER_TYPE = "Registry.npmjs.com" + + // ATTR_USERNAME is the username attribute. Required for login at any npm registry. + ATTR_USERNAME = cpi.ATTR_USERNAME + // ATTR_PASSWORD is the password attribute. Required for login at any npm registry. + ATTR_PASSWORD = cpi.ATTR_PASSWORD + // ATTR_EMAIL is the email attribute. Required for login at any npm registry. + ATTR_EMAIL = cpi.ATTR_EMAIL + // ATTR_TOKEN is the token attribute. May exist after login at any npm registry. + ATTR_TOKEN = cpi.ATTR_TOKEN +) + +// Logging Realm. +var REALM = logging.DefineSubRealm("NPM registry", "NPM") + func init() { attrs := listformat.FormatListElements("", listformat.StringElementDescriptionList{ ATTR_USERNAME, "the basic auth user name", ATTR_PASSWORD, "the basic auth password", ATTR_EMAIL, "NPM registry, require an email address", + ATTR_TOKEN, "the token attribute. May exist after login at any npm registry. Check your .npmrc file!", }) cpi.RegisterStandardIdentity(CONSUMER_TYPE, hostpath.IdentityMatcher(CONSUMER_TYPE), `NPM repository diff --git a/pkg/contexts/credentials/repositories/dockerconfig/type.go b/pkg/contexts/credentials/repositories/dockerconfig/type.go index bba00f371b..0d01494890 100644 --- a/pkg/contexts/credentials/repositories/dockerconfig/type.go +++ b/pkg/contexts/credentials/repositories/dockerconfig/type.go @@ -9,7 +9,9 @@ import ( "fmt" "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + "github.com/open-component-model/ocm/pkg/generics" "github.com/open-component-model/ocm/pkg/runtime" + "github.com/open-component-model/ocm/pkg/utils" ) const ( @@ -27,19 +29,19 @@ type RepositorySpec struct { runtime.ObjectVersionedType `json:",inline"` DockerConfigFile string `json:"dockerConfigFile,omitempty"` DockerConfig json.RawMessage `json:"dockerConfig,omitempty"` - PropgateConsumerIdentity bool `json:"propagateConsumerIdentity,omitempty"` + PropgateConsumerIdentity *bool `json:"propagateConsumerIdentity,omitempty"` } func (s RepositorySpec) WithConsumerPropagation(propagate bool) *RepositorySpec { - s.PropgateConsumerIdentity = propagate + s.PropgateConsumerIdentity = &propagate return &s } // NewRepositorySpec creates a new memory RepositorySpec. func NewRepositorySpec(path string, prop ...bool) *RepositorySpec { - p := false - for _, e := range prop { - p = p || e + var p *bool + if len(prop) > 0 { + p = generics.Pointer(utils.Optional(prop...)) } if path == "" { path = "~/.docker/config.json" @@ -52,9 +54,9 @@ func NewRepositorySpec(path string, prop ...bool) *RepositorySpec { } func NewRepositorySpecForConfig(data []byte, prop ...bool) *RepositorySpec { - p := false - for _, e := range prop { - p = p || e + var p *bool + if len(prop) > 0 { + p = generics.Pointer(utils.Optional(prop...)) } return &RepositorySpec{ ObjectVersionedType: runtime.NewVersionedTypedObject(Type), @@ -73,5 +75,5 @@ func (a *RepositorySpec) Repository(ctx cpi.Context, creds cpi.Credentials) (cpi if !ok { return nil, fmt.Errorf("failed to assert type %T to Repositories", r) } - return repos.GetRepository(ctx, a.DockerConfigFile, a.DockerConfig, a.PropgateConsumerIdentity) + return repos.GetRepository(ctx, a.DockerConfigFile, a.DockerConfig, utils.AsBool(a.PropgateConsumerIdentity, true)) } diff --git a/pkg/contexts/credentials/repositories/gardenerconfig/type.go b/pkg/contexts/credentials/repositories/gardenerconfig/type.go index e57fd9f4d0..b7421da486 100644 --- a/pkg/contexts/credentials/repositories/gardenerconfig/type.go +++ b/pkg/contexts/credentials/repositories/gardenerconfig/type.go @@ -11,7 +11,9 @@ import ( "github.com/open-component-model/ocm/pkg/contexts/credentials/internal" gardenercfgcpi "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/gardenerconfig/cpi" "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/gardenerconfig/identity" + "github.com/open-component-model/ocm/pkg/generics" "github.com/open-component-model/ocm/pkg/runtime" + "github.com/open-component-model/ocm/pkg/utils" ) const ( @@ -30,19 +32,19 @@ type RepositorySpec struct { URL string `json:"url"` ConfigType gardenercfgcpi.ConfigType `json:"configType"` Cipher Cipher `json:"cipher"` - PropagateConsumerIdentity bool `json:"propagateConsumerIdentity"` + PropagateConsumerIdentity *bool `json:"propagateConsumerIdentity,omitempty"` } var _ cpi.ConsumerIdentityProvider = (*RepositorySpec)(nil) // NewRepositorySpec creates a new memory RepositorySpec. -func NewRepositorySpec(url string, configType gardenercfgcpi.ConfigType, cipher Cipher, propagateConsumerIdentity bool) *RepositorySpec { +func NewRepositorySpec(url string, configType gardenercfgcpi.ConfigType, cipher Cipher, propagateConsumerIdentity ...bool) *RepositorySpec { return &RepositorySpec{ ObjectVersionedType: runtime.NewVersionedTypedObject(Type), URL: url, ConfigType: configType, Cipher: cipher, - PropagateConsumerIdentity: propagateConsumerIdentity, + PropagateConsumerIdentity: generics.Pointer(utils.OptionalDefaultedBool(true, propagateConsumerIdentity...)), } } @@ -62,7 +64,7 @@ func (a *RepositorySpec) Repository(ctx cpi.Context, creds cpi.Credentials) (cpi return nil, fmt.Errorf("unable to get key from context: %w", err) } - return repos.GetRepository(ctx, a.URL, a.ConfigType, a.Cipher, key, a.PropagateConsumerIdentity) + return repos.GetRepository(ctx, a.URL, a.ConfigType, a.Cipher, key, utils.AsBool(a.PropagateConsumerIdentity, true)) } func (a *RepositorySpec) GetConsumerId(uctx ...internal.UsageContext) internal.ConsumerIdentity { diff --git a/pkg/contexts/credentials/repositories/init.go b/pkg/contexts/credentials/repositories/init.go index 84d8ad4bc8..fbdbb91bc1 100644 --- a/pkg/contexts/credentials/repositories/init.go +++ b/pkg/contexts/credentials/repositories/init.go @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Open Component Model contributors. -// -// SPDX-License-Identifier: Apache-2.0 - package repositories import ( @@ -11,5 +7,6 @@ import ( _ "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/gardenerconfig" _ "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/memory" _ "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/memory/config" + _ "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/npm" _ "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/vault" ) diff --git a/pkg/contexts/credentials/repositories/npm/a_usage.go b/pkg/contexts/credentials/repositories/npm/a_usage.go new file mode 100644 index 0000000000..a8b77fb9d7 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/a_usage.go @@ -0,0 +1,18 @@ +package npm + +import ( + "github.com/open-component-model/ocm/pkg/listformat" +) + +var usage = ` +This repository type can be used to access credentials stored in a file +following the NPM npmrc format (~/.npmrc). It take into account the +credentials helper section, also. If enabled, the described +credentials will be automatically assigned to appropriate consumer ids. +` + +var format = `The repository specification supports the following fields: +` + listformat.FormatListElements("", listformat.StringElementDescriptionList{ + "npmrcFile", "*string*: the file path to a NPM npmrc file", + "propagateConsumerIdentity", "*bool*(optional): enable consumer id propagation", +}) diff --git a/pkg/contexts/credentials/repositories/npm/cache.go b/pkg/contexts/credentials/repositories/npm/cache.go new file mode 100644 index 0000000000..15b2f0e74b --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/cache.go @@ -0,0 +1,33 @@ +package npm + +import ( + "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + "github.com/open-component-model/ocm/pkg/contexts/datacontext" +) + +type Cache struct { + repos map[string]*Repository +} + +func createCache(_ datacontext.Context) interface{} { + return &Cache{ + repos: map[string]*Repository{}, + } +} + +func (r *Cache) GetRepository(ctx cpi.Context, name string, prop bool) (*Repository, error) { + var ( + err error = nil + repo *Repository + ) + if name != "" { + repo = r.repos[name] + } + if repo == nil { + repo, err = NewRepository(ctx, name, prop) + if err == nil { + r.repos[name] = repo + } + } + return repo, err +} diff --git a/pkg/contexts/credentials/repositories/npm/config.go b/pkg/contexts/credentials/repositories/npm/config.go new file mode 100644 index 0000000000..b6b750b9ea --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/config.go @@ -0,0 +1,55 @@ +package npm + +import ( + "bufio" + "os" + "strings" + + "github.com/open-component-model/ocm/pkg/errors" + "github.com/open-component-model/ocm/pkg/utils" +) + +type npmConfig map[string]string + +// readNpmConfigFile reads "~/.npmrc" file line by line, parse it and return the result as a npmConfig. +func readNpmConfigFile(path string) (npmConfig, string, error) { + path, err := utils.ResolvePath(path) + if err != nil { + return nil, path, errors.Wrapf(err, "cannot resolve path %q", path) + } + + // Open the file + file, err := os.Open(path) + if err != nil { + return nil, path, err + } + defer file.Close() + + // Create a new scanner and read the file line by line + scanner := bufio.NewScanner(file) + cfg := make(map[string]string) + for scanner.Scan() { + line := scanner.Text() + line, authFound := strings.CutPrefix(line, "//") + if !authFound { + // e.g. 'global=false' + continue + } + // Split the line into key and value + parts := strings.SplitN(line, ":_authToken=", 2) + if len(parts) == 2 { + if strings.HasSuffix(parts[0], "/") { + cfg[parts[0][:len(parts[0])-1]] = parts[1] + } else { + cfg[parts[0]] = parts[1] + } + } + } + + // Check for errors + if err = scanner.Err(); err != nil { + return nil, path, err + } + + return cfg, path, nil +} diff --git a/pkg/contexts/credentials/repositories/npm/config_test.go b/pkg/contexts/credentials/repositories/npm/config_test.go new file mode 100644 index 0000000000..e8cc8e28b3 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/config_test.go @@ -0,0 +1,43 @@ +package npm_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/open-component-model/ocm/pkg/common" + "github.com/open-component-model/ocm/pkg/contexts/credentials" + "github.com/open-component-model/ocm/pkg/contexts/credentials/builtin/npm/identity" + "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/npm" + . "github.com/open-component-model/ocm/pkg/testutils" +) + +var _ = Describe("Config deserialization Test Environment", func() { + It("read .npmrc", func() { + ctx := credentials.New() + + repo := Must(npm.NewRepository(ctx, "testdata/.npmrc")) + Expect(Must(repo.LookupCredentials("registry.npmjs.org")).Properties()).To(Equal(common.Properties{identity.ATTR_TOKEN: "npm_TOKEN"})) + Expect(Must(repo.LookupCredentials("npm.registry.acme.com/api/npm")).Properties()).To(Equal(common.Properties{identity.ATTR_TOKEN: "bearer_TOKEN"})) + }) + + It("propagates credentials", func() { + ctx := credentials.New() + + spec := npm.NewRepositorySpec("testdata/.npmrc") + + _ = Must(ctx.RepositoryForSpec(spec)) + id := identity.GetConsumerId("registry.npmjs.org", "pkg") + + creds := Must(credentials.CredentialsForConsumer(ctx, id)) + Expect(creds).NotTo(BeNil()) + Expect(creds.GetProperty(identity.ATTR_TOKEN)).To(Equal("npm_TOKEN")) + }) + + It("has description", func() { + ctx := credentials.New() + t := ctx.RepositoryTypes().GetType(npm.TypeV1) + Expect(t).NotTo(BeNil()) + Expect(t.Description()).NotTo(Equal("")) + }) + +}) diff --git a/pkg/contexts/credentials/repositories/npm/default.go b/pkg/contexts/credentials/repositories/npm/default.go new file mode 100644 index 0000000000..14c569f842 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/default.go @@ -0,0 +1,53 @@ +package npm + +import ( + "fmt" + "os" + + "github.com/mandelsoft/filepath/pkg/filepath" + "github.com/mandelsoft/vfs/pkg/osfs" + "github.com/mandelsoft/vfs/pkg/vfs" + + "github.com/open-component-model/ocm/pkg/contexts/config" + credcfg "github.com/open-component-model/ocm/pkg/contexts/credentials/config" + "github.com/open-component-model/ocm/pkg/contexts/ocm/utils/defaultconfigregistry" + "github.com/open-component-model/ocm/pkg/errors" +) + +const ( + ConfigFileName = ".npmrc" +) + +func init() { + defaultconfigregistry.RegisterDefaultConfigHandler(DefaultConfigHandler, desc) +} + +func DefaultConfig() (string, error) { + d, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(d, ConfigFileName), nil +} + +func DefaultConfigHandler(cfg config.Context) error { + // use docker config as default config for ocm cli + d, err := DefaultConfig() + if err != nil { + return nil + } + if ok, err := vfs.FileExists(osfs.New(), d); ok && err == nil { + ccfg := credcfg.New() + ccfg.AddRepository(NewRepositorySpec(d, true)) + err = cfg.ApplyConfig(ccfg, d) + if err != nil { + return errors.Wrapf(err, "cannot apply npm config %q", d) + } + } + return nil +} + +var desc = fmt.Sprintf(` +The npm configuration file at ~/%s is +read to feed in the configured credentials for NPM registries. +`, ConfigFileName) diff --git a/pkg/contexts/credentials/repositories/npm/provider.go b/pkg/contexts/credentials/repositories/npm/provider.go new file mode 100644 index 0000000000..010dd12ac5 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/provider.go @@ -0,0 +1,47 @@ +package npm + +import ( + npm "github.com/open-component-model/ocm/pkg/contexts/credentials/builtin/npm/identity" + "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + "github.com/open-component-model/ocm/pkg/logging" +) + +type ConsumerProvider struct { + npmrcPath string +} + +var _ cpi.ConsumerProvider = (*ConsumerProvider)(nil) + +func (p *ConsumerProvider) Unregister(_ cpi.ProviderIdentity) { +} + +func (p *ConsumerProvider) Match(req cpi.ConsumerIdentity, cur cpi.ConsumerIdentity, m cpi.IdentityMatcher) (cpi.CredentialsSource, cpi.ConsumerIdentity) { + return p.get(req, cur, m) +} + +func (p *ConsumerProvider) Get(req cpi.ConsumerIdentity) (cpi.CredentialsSource, bool) { + creds, _ := p.get(req, nil, cpi.CompleteMatch) + return creds, creds != nil +} + +func (p *ConsumerProvider) get(requested cpi.ConsumerIdentity, currentFound cpi.ConsumerIdentity, m cpi.IdentityMatcher) (cpi.CredentialsSource, cpi.ConsumerIdentity) { + all, path, err := readNpmConfigFile(p.npmrcPath) + if err != nil { + log := logging.Context().Logger(npm.REALM) + log.LogError(err, "Failed to read npmrc file", "path", path) + return nil, nil + } + + var creds cpi.CredentialsSource + + for key, value := range all { + id := npm.GetConsumerId("https://"+key, "") + + if m(requested, currentFound, id) { + creds = newCredentials(value) + currentFound = id + } + } + + return creds, currentFound +} diff --git a/pkg/contexts/credentials/repositories/npm/repository.go b/pkg/contexts/credentials/repositories/npm/repository.go new file mode 100644 index 0000000000..c02d7d5971 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/repository.go @@ -0,0 +1,87 @@ +package npm + +import ( + "fmt" + + "github.com/open-component-model/ocm/pkg/common" + npmCredentials "github.com/open-component-model/ocm/pkg/contexts/credentials/builtin/npm/identity" + "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + "github.com/open-component-model/ocm/pkg/errors" + "github.com/open-component-model/ocm/pkg/utils" +) + +const PROVIDER = "ocm.software/credentialprovider/" + Type + +type Repository struct { + ctx cpi.Context + path string + propagate bool + npmrc npmConfig +} + +func NewRepository(ctx cpi.Context, path string, prop ...bool) (*Repository, error) { + return newRepository(ctx, path, utils.OptionalDefaultedBool(true, prop...)) +} + +func newRepository(ctx cpi.Context, path string, prop bool) (*Repository, error) { + r := &Repository{ + ctx: ctx, + path: path, + propagate: prop, + } + err := r.Read(true) + return r, err +} + +var _ cpi.Repository = &Repository{} + +func (r *Repository) ExistsCredentials(name string) (bool, error) { + err := r.Read(false) + if err != nil { + return false, err + } + return r.npmrc[name] != "", nil +} + +func (r *Repository) LookupCredentials(name string) (cpi.Credentials, error) { + exists, err := r.ExistsCredentials(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.ErrNotFound("credentials", name, Type) + } + return newCredentials(r.npmrc[name]), nil +} + +func (r *Repository) WriteCredentials(_ string, _ cpi.Credentials) (cpi.Credentials, error) { + return nil, errors.ErrNotSupported("write", "credentials", Type) +} + +func (r *Repository) Read(force bool) error { + if !force && r.npmrc != nil { + return nil + } + + if r.path == "" { + return fmt.Errorf("npmrc path not provided") + } + cfg, path, err := readNpmConfigFile(r.path) + if err != nil { + return fmt.Errorf("failed to load npmrc: %w", err) + } + id := cpi.ProviderIdentity(PROVIDER + "/" + path) + + if r.propagate { + r.ctx.RegisterConsumerProvider(id, &ConsumerProvider{r.path}) + } + r.npmrc = cfg + return nil +} + +func newCredentials(token string) cpi.Credentials { + props := common.Properties{ + npmCredentials.ATTR_TOKEN: token, + } + return cpi.NewCredentials(props) +} diff --git a/pkg/contexts/credentials/repositories/npm/repository_test.go b/pkg/contexts/credentials/repositories/npm/repository_test.go new file mode 100644 index 0000000000..8de30de2b9 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/repository_test.go @@ -0,0 +1,78 @@ +package npm_test + +import ( + "encoding/json" + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/open-component-model/ocm/pkg/common" + "github.com/open-component-model/ocm/pkg/contexts/credentials" + npmCredentials "github.com/open-component-model/ocm/pkg/contexts/credentials/builtin/npm/identity" + "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + local "github.com/open-component-model/ocm/pkg/contexts/credentials/repositories/npm" + "github.com/open-component-model/ocm/pkg/finalizer" + . "github.com/open-component-model/ocm/pkg/testutils" +) + +var _ = Describe("NPM config - .npmrc", func() { + + props := common.Properties{ + npmCredentials.ATTR_TOKEN: "npm_TOKEN", + } + + props2 := common.Properties{ + npmCredentials.ATTR_TOKEN: "bearer_TOKEN", + } + + var DefaultContext credentials.Context + + BeforeEach(func() { + DefaultContext = credentials.New() + }) + + specdata := "{\"type\":\"NPMConfig\",\"npmrcFile\":\"testdata/.npmrc\"}" + + It("serializes repo spec", func() { + spec := local.NewRepositorySpec("testdata/.npmrc") + data := Must(json.Marshal(spec)) + Expect(data).To(Equal([]byte(specdata))) + }) + + It("deserializes repo spec", func() { + spec := Must(DefaultContext.RepositorySpecForConfig([]byte(specdata), nil)) + Expect(reflect.TypeOf(spec).String()).To(Equal("*npm.RepositorySpec")) + Expect(spec.(*local.RepositorySpec).NpmrcFile).To(Equal("testdata/.npmrc")) + }) + + It("resolves repository", func() { + repo := Must(DefaultContext.RepositoryForConfig([]byte(specdata), nil)) + Expect(reflect.TypeOf(repo).String()).To(Equal("*npm.Repository")) + }) + + It("retrieves credentials", func() { + repo := Must(DefaultContext.RepositoryForConfig([]byte(specdata), nil)) + + creds := Must(repo.LookupCredentials("registry.npmjs.org")) + Expect(creds.Properties()).To(Equal(props)) + + creds = Must(repo.LookupCredentials("npm.registry.acme.com/api/npm")) + Expect(creds.Properties()).To(Equal(props2)) + }) + + It("can access the default context", func() { + ctx := credentials.New() + + r := finalizer.GetRuntimeFinalizationRecorder(ctx) + Expect(r).NotTo(BeNil()) + + Must(ctx.RepositoryForConfig([]byte(specdata), nil)) + + ci := cpi.NewConsumerIdentity(npmCredentials.CONSUMER_TYPE) + Expect(ci).NotTo(BeNil()) + credentials := Must(cpi.CredentialsForConsumer(ctx.CredentialsContext(), ci)) + Expect(credentials).NotTo(BeNil()) + Expect(credentials.Properties()).To(Equal(props)) + }) +}) diff --git a/pkg/contexts/credentials/repositories/npm/suite_test.go b/pkg/contexts/credentials/repositories/npm/suite_test.go new file mode 100644 index 0000000000..e4947738d0 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/suite_test.go @@ -0,0 +1,13 @@ +package npm_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "NPM Repository tests") +} diff --git a/pkg/contexts/credentials/repositories/npm/testdata/.npmrc b/pkg/contexts/credentials/repositories/npm/testdata/.npmrc new file mode 100644 index 0000000000..00ea1baa8f --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/testdata/.npmrc @@ -0,0 +1,3 @@ +global=false +//npm.registry.acme.com/api/npm/:_authToken=bearer_TOKEN +//registry.npmjs.org/:_authToken=npm_TOKEN diff --git a/pkg/contexts/credentials/repositories/npm/type.go b/pkg/contexts/credentials/repositories/npm/type.go new file mode 100644 index 0000000000..32cf05b2f6 --- /dev/null +++ b/pkg/contexts/credentials/repositories/npm/type.go @@ -0,0 +1,61 @@ +package npm + +import ( + "fmt" + + "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" + "github.com/open-component-model/ocm/pkg/generics" + "github.com/open-component-model/ocm/pkg/runtime" + "github.com/open-component-model/ocm/pkg/utils" +) + +const ( + // Type is the type of the NPMConfig. + Type = "NPMConfig" + TypeV1 = Type + runtime.VersionSeparator + "v1" +) + +func init() { + cpi.RegisterRepositoryType(cpi.NewRepositoryType[*RepositorySpec](Type)) + cpi.RegisterRepositoryType(cpi.NewRepositoryType[*RepositorySpec](TypeV1, cpi.WithDescription(usage), cpi.WithFormatSpec(format))) +} + +// RepositorySpec describes a docker npmrc based credential repository interface. +type RepositorySpec struct { + runtime.ObjectVersionedType `json:",inline"` + NpmrcFile string `json:"npmrcFile,omitempty"` + PropgateConsumerIdentity *bool `json:"propagateConsumerIdentity,omitempty"` +} + +// NewRepositorySpec creates a new memory RepositorySpec. +func NewRepositorySpec(path string, propagate ...bool) *RepositorySpec { + var p *bool + if path == "" { + d, err := DefaultConfig() + if err == nil { + path = d + } + } + if len(propagate) > 0 { + p = generics.Pointer(utils.OptionalDefaultedBool(true, propagate...)) + } + + return &RepositorySpec{ + ObjectVersionedType: runtime.NewVersionedTypedObject(Type), + NpmrcFile: path, + PropgateConsumerIdentity: p, + } +} + +func (rs *RepositorySpec) GetType() string { + return Type +} + +func (rs *RepositorySpec) Repository(ctx cpi.Context, _ cpi.Credentials) (cpi.Repository, error) { + r := ctx.GetAttributes().GetOrCreateAttribute(".npmrc", createCache) + cache, ok := r.(*Cache) + if !ok { + return nil, fmt.Errorf("failed to assert type %T to Cache", r) + } + return cache.GetRepository(ctx, rs.NpmrcFile, utils.AsBool(rs.PropgateConsumerIdentity, true)) +} diff --git a/pkg/contexts/ocm/attrs/plugindirattr/attr.go b/pkg/contexts/ocm/attrs/plugindirattr/attr.go index faab56d98f..d33a758993 100644 --- a/pkg/contexts/ocm/attrs/plugindirattr/attr.go +++ b/pkg/contexts/ocm/attrs/plugindirattr/attr.go @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Open Component Model contributors. -// -// SPDX-License-Identifier: Apache-2.0 - package plugindirattr import ( @@ -30,7 +26,7 @@ func init() { } func DefaultDir(fs vfs.FileSystem) string { - home := os.Getenv("HOME") + home, _ := os.UserHomeDir() if home != "" { dir := filepath.Join(home, DEFAULT_PLUGIN_DIR) if ok, err := vfs.DirExists(fs, dir); ok && err == nil { diff --git a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/blobhandler.go b/pkg/contexts/ocm/blobhandler/handlers/generic/npm/blobhandler.go index 2c244cdd1f..5e69bab218 100644 --- a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/blobhandler.go +++ b/pkg/contexts/ocm/blobhandler/handlers/generic/npm/blobhandler.go @@ -9,12 +9,15 @@ import ( "net/http" "net/url" + npmCredentials "github.com/open-component-model/ocm/pkg/contexts/credentials/builtin/npm/identity" "github.com/open-component-model/ocm/pkg/contexts/ocm/accessmethods/npm" "github.com/open-component-model/ocm/pkg/contexts/ocm/cpi" "github.com/open-component-model/ocm/pkg/logging" "github.com/open-component-model/ocm/pkg/mime" ) +const BLOB_HANDLER_NAME = "ocm/npmPackage" + type artifactHandler struct { spec *Config } @@ -49,7 +52,7 @@ func (b *artifactHandler) StoreBlob(blob cpi.BlobAccess, _ string, _ string, _ c } // read package.json from tarball to get name, version, etc. - log := logging.Context().Logger(REALM) + log := logging.Context().Logger(npmCredentials.REALM) log.Debug("reading package.json from tarball") var pkg *Package pkg, err = prepare(data) @@ -61,19 +64,33 @@ func (b *artifactHandler) StoreBlob(blob cpi.BlobAccess, _ string, _ string, _ c log = log.WithValues("package", pkg.Name, "version", pkg.Version) log.Debug("identified") - // use user+pass+mail from credentials to login and retrieve bearer token - cred := GetCredentials(ctx.GetContext(), b.spec.Url, pkg.Name) - username := cred[ATTR_USERNAME] - password := cred[ATTR_PASSWORD] - email := cred[ATTR_EMAIL] - if username == "" || password == "" || email == "" { - return nil, fmt.Errorf("username, password or email missing") - } - log = log.WithValues("user", username, "repo", b.spec.Url) - log.Debug("login") - token, err := login(b.spec.Url, username, password, email) - if err != nil { - return nil, err + // get credentials and TODO cache it + cred := npmCredentials.GetCredentials(ctx.GetContext(), b.spec.Url, pkg.Name) + if cred == nil { + return nil, fmt.Errorf("No credentials found for %s. Couldn't upload '%s'.", b.spec.Url, pkg.Name) + } + log.Debug("found credentials") + + // check if token exists, if not login and retrieve token + token := cred[npmCredentials.ATTR_TOKEN] + if token == "" { + // use user+pass+mail from credentials to login and retrieve bearer token + username := cred[npmCredentials.ATTR_USERNAME] + password := cred[npmCredentials.ATTR_PASSWORD] + email := cred[npmCredentials.ATTR_EMAIL] + if username == "" || password == "" || email == "" { + return nil, fmt.Errorf("No credentials for %s are invalid. Username, password or email missing! Couldn't upload '%s'.", b.spec.Url, pkg.Name) + } + log = log.WithValues("user", username, "repo", b.spec.Url) + log.Debug("login") + + // TODO: check different kinds of .npmrc content + token, err = login(b.spec.Url, username, password, email) + if err != nil { + return nil, err + } + } else { + log.Debug("token found, skipping login") } // check if package exists diff --git a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/const.go b/pkg/contexts/ocm/blobhandler/handlers/generic/npm/const.go deleted file mode 100644 index e8f1271879..0000000000 --- a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/const.go +++ /dev/null @@ -1,22 +0,0 @@ -package npm - -import ( - "github.com/open-component-model/ocm/pkg/contexts/credentials/cpi" - "github.com/open-component-model/ocm/pkg/logging" -) - -const ( - // CONSUMER_TYPE is the npm repository type. - CONSUMER_TYPE = "Registry.npmjs.com" - BLOB_HANDLER_NAME = "ocm/npmPackage" - - // ATTR_USERNAME is the username attribute. Required for login at any npm registry. - ATTR_USERNAME = cpi.ATTR_USERNAME - // ATTR_PASSWORD is the password attribute. Required for login at any npm registry. - ATTR_PASSWORD = cpi.ATTR_PASSWORD - // ATTR_EMAIL is the email attribute. Required for login at any npm registry. - ATTR_EMAIL = cpi.ATTR_EMAIL -) - -// Logging Realm. -var REALM = logging.DefineSubRealm("NPM registry", "NPM") diff --git a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/config_test.go b/pkg/contexts/ocm/blobhandler/handlers/generic/npm/registration_test.go similarity index 85% rename from pkg/contexts/ocm/blobhandler/handlers/generic/npm/config_test.go rename to pkg/contexts/ocm/blobhandler/handlers/generic/npm/registration_test.go index e6b891198a..d97f412c3a 100644 --- a/pkg/contexts/ocm/blobhandler/handlers/generic/npm/config_test.go +++ b/pkg/contexts/ocm/blobhandler/handlers/generic/npm/registration_test.go @@ -13,12 +13,11 @@ var _ = Describe("Config deserialization Test Environment", func() { It("deserializes string", func() { cfg := Must(registrations.DecodeConfig[npm.Config]("test")) - Expect(cfg).To(Equal(&npm.Config{"test"})) + Expect(cfg).To(Equal(&npm.Config{Url: "test"})) }) It("deserializes struct", func() { cfg := Must(registrations.DecodeConfig[npm.Config](`{"Url":"test"}`)) - Expect(cfg).To(Equal(&npm.Config{"test"})) + Expect(cfg).To(Equal(&npm.Config{Url: "test"})) }) - }) diff --git a/pkg/contexts/ocm/utils/configure.go b/pkg/contexts/ocm/utils/configure.go index 467f9ff718..6a6cf297db 100644 --- a/pkg/contexts/ocm/utils/configure.go +++ b/pkg/contexts/ocm/utils/configure.go @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Open Component Model contributors. -// -// SPDX-License-Identifier: Apache-2.0 - package utils import ( @@ -28,7 +24,7 @@ func Configure(ctx ocm.Context, path string, fss ...vfs.FileSystem) (ocm.Context if ctx == nil { ctx = ocm.DefaultContext() } - h := os.Getenv("HOME") + h, _ := os.UserHomeDir() if path == "" { if h != "" { cfg := h + "/" + DEFAULT_OCM_CONFIG diff --git a/pkg/contexts/ocm/utils/defaultconfigregistry/configure.go b/pkg/contexts/ocm/utils/defaultconfigregistry/configure.go index 30fed05799..67f51b24c7 100644 --- a/pkg/contexts/ocm/utils/defaultconfigregistry/configure.go +++ b/pkg/contexts/ocm/utils/defaultconfigregistry/configure.go @@ -43,6 +43,20 @@ func (r *defaultConfigurationRegistry) Get() []DefaultConfigHandler { return result } +func (r *defaultConfigurationRegistry) Description() string { + var result []string + + r.lock.Lock() + defer r.lock.Unlock() + + for _, h := range defaultConfigRegistry.list { + if h.desc != "" { + result = append(result, strings.TrimSpace(h.desc)) + } + } + return listformat.FormatDescriptionList("", result...) +} + var defaultConfigRegistry = &defaultConfigurationRegistry{} func RegisterDefaultConfigHandler(h DefaultConfigHandler, desc string) { @@ -54,12 +68,5 @@ func Get() []DefaultConfigHandler { } func Description() string { - var result []string - - for _, h := range defaultConfigRegistry.list { - if h.desc != "" { - result = append(result, strings.TrimSpace(h.desc)) - } - } - return listformat.FormatDescriptionList("", result...) + return defaultConfigRegistry.Description() } diff --git a/pkg/utils/path.go b/pkg/utils/path.go index f56227a72a..1f1c14f724 100644 --- a/pkg/utils/path.go +++ b/pkg/utils/path.go @@ -1,7 +1,3 @@ -// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and Open Component Model contributors. -// -// SPDX-License-Identifier: Apache-2.0 - package utils import ( @@ -19,8 +15,8 @@ import ( // ResolvePath handles the ~ notation for the home directory. func ResolvePath(path string) (string, error) { if strings.HasPrefix(path, "~"+string(os.PathSeparator)) { - home := os.Getenv("HOME") - if home == "" { + home, err := os.UserHomeDir() + if home == "" || err != nil { return path, fmt.Errorf("HOME not set") } path = home + path[1:]