diff --git a/app/services/secret/service.go b/app/services/secret/service.go new file mode 100644 index 000000000..301b66452 --- /dev/null +++ b/app/services/secret/service.go @@ -0,0 +1,62 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secret + +import ( + "context" + + secretCtrl "github.com/harness/gitness/app/api/controller/secret" + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/encrypt" + "github.com/harness/gitness/secret" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type service struct { + secretStore store.SecretStore + encrypter encrypt.Encrypter + spacePathStore store.SpacePathStore +} + +func NewService( + secretStore store.SecretStore, encrypter encrypt.Encrypter, spacePathStore store.SpacePathStore, +) secret.Service { + return &service{ + secretStore: secretStore, + encrypter: encrypter, + spacePathStore: spacePathStore, + } +} + +func (s *service) DecryptSecret(ctx context.Context, spacePath string, secretIdentifier string) (string, error) { + path, err := s.spacePathStore.FindByPath(ctx, spacePath) + if err != nil { + log.Error().Msgf("failed to find space path: %v", err) + return "", errors.Wrap(err, "failed to find space path") + } + sec, err := s.secretStore.FindByIdentifier(ctx, path.SpaceID, secretIdentifier) + if err != nil { + log.Error().Msgf("failed to find secret: %v", err) + return "", errors.Wrap(err, "failed to find secret") + } + sec, err = secretCtrl.Dec(s.encrypter, sec) + if err != nil { + log.Error().Msgf("could not decrypt secret: %v", err) + return "", errors.Wrap(err, "failed to decrypt secret") + } + return sec.Data, nil +} diff --git a/app/services/secret/wire.go b/app/services/secret/wire.go new file mode 100644 index 000000000..95669f347 --- /dev/null +++ b/app/services/secret/wire.go @@ -0,0 +1,33 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secret + +import ( + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/encrypt" + "github.com/harness/gitness/secret" + + "github.com/google/wire" +) + +var WireSet = wire.NewSet( + ProvideSecretService, +) + +func ProvideSecretService( + secretStore store.SecretStore, encrypter encrypt.Encrypter, spacePathStore store.SpacePathStore, +) secret.Service { + return NewService(secretStore, encrypter, spacePathStore) +} diff --git a/cmd/gitness/wire.go b/cmd/gitness/wire.go index b1288d055..3e49707ca 100644 --- a/cmd/gitness/wire.go +++ b/cmd/gitness/wire.go @@ -100,6 +100,7 @@ import ( "github.com/harness/gitness/app/services/publickey" pullreqservice "github.com/harness/gitness/app/services/pullreq" reposervice "github.com/harness/gitness/app/services/repo" + secretservice "github.com/harness/gitness/app/services/secret" "github.com/harness/gitness/app/services/settings" "github.com/harness/gitness/app/services/trigger" usergroupservice "github.com/harness/gitness/app/services/usergroup" @@ -262,6 +263,7 @@ func initSystem(ctx context.Context, config *types.Config) (*cliserver.System, e aiagent.WireSet, capabilities.WireSet, capabilitiesservice.WireSet, + secretservice.WireSet, ) return &cliserver.System{}, nil } diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index acc3923ae..b9c518377 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -91,6 +91,7 @@ import ( "github.com/harness/gitness/app/services/publickey" "github.com/harness/gitness/app/services/pullreq" repo2 "github.com/harness/gitness/app/services/repo" + secret3 "github.com/harness/gitness/app/services/secret" "github.com/harness/gitness/app/services/settings" trigger2 "github.com/harness/gitness/app/services/trigger" "github.com/harness/gitness/app/services/usergroup" @@ -429,7 +430,8 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro registryBlobRepository := database2.ProvideRegistryBlobDao(db) localRegistry := docker.LocalRegistryProvider(app, manifestService, blobRepository, registryRepository, manifestRepository, registryBlobRepository, mediaTypesRepository, tagRepository, artifactRepository, artifactStatRepository, gcService, transactor) upstreamProxyConfigRepository := database2.ProvideUpstreamDao(db, registryRepository) - remoteRegistry := docker.RemoteRegistryProvider(localRegistry, app, upstreamProxyConfigRepository, secretStore, encrypter) + secretService := secret3.ProvideSecretService(secretStore, encrypter, spacePathStore) + remoteRegistry := docker.RemoteRegistryProvider(localRegistry, app, upstreamProxyConfigRepository, spacePathStore, secretService) coreController := pkg.CoreControllerProvider(registryRepository) dockerController := docker.ControllerProvider(localRegistry, remoteRegistry, coreController, spaceStore, authorizer) handler := api2.NewHandlerProvider(dockerController, spaceStore, tokenStore, controller, authenticator, provider, authorizer) diff --git a/registry/app/pkg/docker/remote.go b/registry/app/pkg/docker/remote.go index 6a52bfaa1..b7e37c544 100644 --- a/registry/app/pkg/docker/remote.go +++ b/registry/app/pkg/docker/remote.go @@ -25,7 +25,6 @@ import ( "github.com/harness/gitness/app/api/request" store2 "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" "github.com/harness/gitness/registry/app/common/lib/errors" "github.com/harness/gitness/registry/app/manifest" "github.com/harness/gitness/registry/app/pkg" @@ -33,6 +32,7 @@ import ( proxy2 "github.com/harness/gitness/registry/app/remote/controller/proxy" "github.com/harness/gitness/registry/app/storage" "github.com/harness/gitness/registry/app/store" + "github.com/harness/gitness/secret" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/rs/zerolog/log" @@ -48,18 +48,15 @@ const ( ) func NewRemoteRegistry( - local *LocalRegistry, - app *App, - upstreamProxyConfigRepo store.UpstreamProxyConfigRepository, - secretStore store2.SecretStore, - encrypter encrypt.Encrypter, + local *LocalRegistry, app *App, upstreamProxyConfigRepo store.UpstreamProxyConfigRepository, + spacePathStore store2.SpacePathStore, secretService secret.Service, ) Registry { return &RemoteRegistry{ local: local, App: app, upstreamProxyConfigRepo: upstreamProxyConfigRepo, - secretStore: secretStore, - encrypter: encrypter, + spacePathStore: spacePathStore, + secretService: secretService, } } @@ -71,8 +68,8 @@ type RemoteRegistry struct { local *LocalRegistry App *App upstreamProxyConfigRepo store.UpstreamProxyConfigRepository - secretStore store2.SecretStore - encrypter encrypt.Encrypter + spacePathStore store2.SpacePathStore + secretService secret.Service } func (r *RemoteRegistry) Base() error { @@ -150,7 +147,7 @@ func (r *RemoteRegistry) ManifestExist( responseHeaders *commons.ResponseHeaders, descriptor manifest.Descriptor, manifestResult manifest.Manifest, errs []error, ) { - proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms) + proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms, r.secretService, r.spacePathStore) responseHeaders = &commons.ResponseHeaders{ Headers: make(map[string]string), } @@ -180,7 +177,8 @@ func (r *RemoteRegistry) ManifestExist( errs = append(errs, err) return responseHeaders, descriptor, manifestResult, errs } - remoteHelper, err := proxy2.NewRemoteHelper(ctx, r.secretStore, r.encrypter, artInfo.RegIdentifier, *upstreamProxy) + remoteHelper, err := proxy2.NewRemoteHelper(ctx, r.spacePathStore, r.secretService, artInfo.RegIdentifier, + *upstreamProxy) if err != nil { errs = append(errs, errors.New("Proxy is down")) return responseHeaders, descriptor, manifestResult, errs @@ -239,7 +237,7 @@ func (r *RemoteRegistry) PullManifest( responseHeaders *commons.ResponseHeaders, descriptor manifest.Descriptor, manifestResult manifest.Manifest, errs []error, ) { - proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms) + proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms, r.secretService, r.spacePathStore) responseHeaders = &commons.ResponseHeaders{ Headers: make(map[string]string), } @@ -268,7 +266,8 @@ func (r *RemoteRegistry) PullManifest( errs = append(errs, err) return responseHeaders, descriptor, manifestResult, errs } - remoteHelper, err := proxy2.NewRemoteHelper(ctx, r.secretStore, r.encrypter, artInfo.RegIdentifier, *upstreamProxy) + remoteHelper, err := proxy2.NewRemoteHelper(ctx, r.spacePathStore, r.secretService, artInfo.RegIdentifier, + *upstreamProxy) if err != nil { errs = append(errs, errors.New("Proxy is down")) return responseHeaders, descriptor, manifestResult, errs @@ -353,7 +352,7 @@ func (r *RemoteRegistry) fetchBlobInternal( responseHeaders *commons.ResponseHeaders, fr *storage.FileReader, size int64, readCloser io.ReadCloser, redirectURL string, errs []error, ) { - proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms) + proxyCtl := proxy2.ControllerInstance(r.local, r.local.ms, r.secretService, r.spacePathStore) responseHeaders = &commons.ResponseHeaders{ Headers: make(map[string]string), } @@ -399,7 +398,7 @@ func (r *RemoteRegistry) fetchBlobInternal( } // This is start of proxy Code. - size, readCloser, err = proxyCtl.ProxyBlob(ctx, r.secretStore, r.encrypter, registryInfo, repoKey, *upstreamProxy) + size, readCloser, err = proxyCtl.ProxyBlob(ctx, registryInfo, repoKey, *upstreamProxy) if err != nil { errs = append(errs, err) return responseHeaders, fr, size, readCloser, redirectURL, errs diff --git a/registry/app/pkg/docker/wire.go b/registry/app/pkg/docker/wire.go index 1a1a94e97..ce8bc160d 100644 --- a/registry/app/pkg/docker/wire.go +++ b/registry/app/pkg/docker/wire.go @@ -17,12 +17,12 @@ package docker import ( "github.com/harness/gitness/app/auth/authz" corestore "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" storagedriver "github.com/harness/gitness/registry/app/driver" "github.com/harness/gitness/registry/app/pkg" "github.com/harness/gitness/registry/app/storage" "github.com/harness/gitness/registry/app/store" "github.com/harness/gitness/registry/gc" + "github.com/harness/gitness/secret" "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" @@ -58,9 +58,9 @@ func ManifestServiceProvider( func RemoteRegistryProvider( local *LocalRegistry, app *App, upstreamProxyConfigRepo store.UpstreamProxyConfigRepository, - secretStore corestore.SecretStore, encrypter encrypt.Encrypter, + spacePathStore corestore.SpacePathStore, secretService secret.Service, ) *RemoteRegistry { - return NewRemoteRegistry(local, app, upstreamProxyConfigRepo, secretStore, encrypter).(*RemoteRegistry) + return NewRemoteRegistry(local, app, upstreamProxyConfigRepo, spacePathStore, secretService).(*RemoteRegistry) } func ControllerProvider( diff --git a/registry/app/remote/adapter/adapter.go b/registry/app/remote/adapter/adapter.go index cdd412461..cde155b2c 100644 --- a/registry/app/remote/adapter/adapter.go +++ b/registry/app/remote/adapter/adapter.go @@ -23,9 +23,9 @@ import ( "io" store2 "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" "github.com/harness/gitness/registry/app/manifest" "github.com/harness/gitness/registry/types" + "github.com/harness/gitness/secret" ) // const definition. @@ -39,8 +39,7 @@ var registryKeys = []string{} // Factory creates a specific Adapter according to the params. type Factory interface { Create( - ctx context.Context, secretStore store2.SecretStore, encrypter encrypt.Encrypter, - record types.UpstreamProxy, + ctx context.Context, spacePathStore store2.SpacePathStore, record types.UpstreamProxy, service secret.Service, ) (Adapter, error) } diff --git a/registry/app/remote/adapter/dockerhub/adapter.go b/registry/app/remote/adapter/dockerhub/adapter.go index 861d1abb5..8acccbab0 100644 --- a/registry/app/remote/adapter/dockerhub/adapter.go +++ b/registry/app/remote/adapter/dockerhub/adapter.go @@ -20,10 +20,10 @@ import ( "context" store2 "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" adp "github.com/harness/gitness/registry/app/remote/adapter" "github.com/harness/gitness/registry/app/remote/adapter/native" "github.com/harness/gitness/registry/types" + "github.com/harness/gitness/secret" "github.com/rs/zerolog/log" ) @@ -38,10 +38,7 @@ func init() { } func newAdapter( - ctx context.Context, - secretStore store2.SecretStore, - encrypter encrypt.Encrypter, - registry types.UpstreamProxy, + ctx context.Context, spacePathStore store2.SpacePathStore, service secret.Service, registry types.UpstreamProxy, ) (adp.Adapter, error) { client, err := NewClient(registry) if err != nil { @@ -51,7 +48,7 @@ func newAdapter( // TODO: get Upstream Credentials return &adapter{ client: client, - Adapter: native.NewAdapter(ctx, secretStore, encrypter, registry), + Adapter: native.NewAdapter(ctx, spacePathStore, service, registry), }, nil } @@ -60,12 +57,9 @@ type factory struct { // Create ... func (f *factory) Create( - ctx context.Context, - secretStore store2.SecretStore, - encrypter encrypt.Encrypter, - record types.UpstreamProxy, + ctx context.Context, spacePathStore store2.SpacePathStore, record types.UpstreamProxy, service secret.Service, ) (adp.Adapter, error) { - return newAdapter(ctx, secretStore, encrypter, record) + return newAdapter(ctx, spacePathStore, service, record) } var ( diff --git a/registry/app/remote/adapter/native/adapter.go b/registry/app/remote/adapter/native/adapter.go index f7499816c..44d0b2224 100644 --- a/registry/app/remote/adapter/native/adapter.go +++ b/registry/app/remote/adapter/native/adapter.go @@ -19,14 +19,13 @@ package native import ( "context" - s "github.com/harness/gitness/app/api/controller/secret" "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" api "github.com/harness/gitness/registry/app/api/openapi/contracts/artifact" "github.com/harness/gitness/registry/app/common/lib/errors" adp "github.com/harness/gitness/registry/app/remote/adapter" "github.com/harness/gitness/registry/app/remote/clients/registry" "github.com/harness/gitness/registry/types" + "github.com/harness/gitness/secret" "github.com/rs/zerolog/log" ) @@ -47,16 +46,13 @@ type Adapter struct { // NewAdapter returns an instance of the Adapter. func NewAdapter( - ctx context.Context, - secretStore store.SecretStore, - encrypter encrypt.Encrypter, - reg types.UpstreamProxy, + ctx context.Context, spacePathStore store.SpacePathStore, service secret.Service, reg types.UpstreamProxy, ) *Adapter { adapter := &Adapter{ proxy: reg, } // Get the password: lookup secrets.secret_data using secret_identifier & secret_space_id. - password := getPwd(ctx, secretStore, encrypter, reg) + password := getPwd(ctx, spacePathStore, service, reg) username, password, url := reg.UserName, password, reg.RepoURL adapter.Client = registry.NewClient(url, username, password, false) return adapter @@ -64,12 +60,8 @@ func NewAdapter( // getPwd: lookup secrets.secret_data using secret_identifier & secret_space_id. func getPwd( - ctx context.Context, - secretStore store.SecretStore, - encrypter encrypt.Encrypter, - reg types.UpstreamProxy, + ctx context.Context, spacePathStore store.SpacePathStore, secretService secret.Service, reg types.UpstreamProxy, ) string { - password := "" if api.AuthType(reg.RepoAuthType) == api.AuthTypeUserPassword { secretSpaceID := int64(0) if reg.SecretSpaceID.Valid { @@ -80,17 +72,20 @@ func getPwd( if reg.SecretIdentifier.Valid { secretIdentifier = reg.SecretIdentifier.String } - secret, err := secretStore.FindByIdentifier(ctx, secretSpaceID, secretIdentifier) + + spacePath, err := spacePathStore.FindPrimaryBySpaceID(ctx, secretSpaceID) if err != nil { - log.Error().Msgf("failed to find secret: %v", err) + log.Error().Msgf("failed to find space path: %v", err) + return "" } - secret, err = s.Dec(encrypter, secret) + decryptSecret, err := secretService.DecryptSecret(ctx, spacePath.Value, secretIdentifier) if err != nil { - log.Error().Msgf("could not decrypt secret: %v", err) + log.Error().Msgf("failed to decrypt secret: %v", err) + return "" } - password = secret.Data + return decryptSecret } - return password + return "" } // HealthCheck checks health status of a proxy. diff --git a/registry/app/remote/controller/proxy/controller.go b/registry/app/remote/controller/proxy/controller.go index 0792bfd61..c5dc4aa2e 100644 --- a/registry/app/remote/controller/proxy/controller.go +++ b/registry/app/remote/controller/proxy/controller.go @@ -26,13 +26,13 @@ import ( "time" "github.com/harness/gitness/app/api/request" - store2 "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" + "github.com/harness/gitness/app/store" "github.com/harness/gitness/registry/app/common/lib/errors" "github.com/harness/gitness/registry/app/manifest" "github.com/harness/gitness/registry/app/pkg" "github.com/harness/gitness/registry/app/pkg/commons" "github.com/harness/gitness/registry/types" + "github.com/harness/gitness/secret" "github.com/distribution/distribution/v3/registry/api/errcode" "github.com/opencontainers/go-digest" @@ -68,12 +68,7 @@ type Controller interface { // ProxyBlob proxy the blob request to the remote server, p is the proxy project // art is the RegistryInfo which includes the digest of the blob ProxyBlob( - ctx context.Context, - secretStore store2.SecretStore, - encrypter encrypt.Encrypter, - art pkg.RegistryInfo, - repoKey string, - proxy types.UpstreamProxy, + ctx context.Context, art pkg.RegistryInfo, repoKey string, proxy types.UpstreamProxy, ) (int64, io.ReadCloser, error) // ProxyManifest proxy the manifest request to the remote server, p is the proxy project, // art is the RegistryInfo which includes the tag or digest of the manifest @@ -99,21 +94,24 @@ type Controller interface { } type controller struct { - // blobCtl blob.Controller - // artifactCtl artifact.Controller. localRegistry registryInterface localManifestRegistry registryManifestInterface - // cache cache.Cache - // handlerRegistry map[string]ManifestCacheHandler. + secretService secret.Service + spacePathStore store.SpacePathStore } // ControllerInstance -- get the proxy controller instance. -func ControllerInstance(l registryInterface, lm registryManifestInterface) Controller { +func ControllerInstance( + l registryInterface, lm registryManifestInterface, secretService secret.Service, + spacePathStore store.SpacePathStore, +) Controller { once.Do( func() { ctl = &controller{ localRegistry: l, localManifestRegistry: lm, + secretService: secretService, + spacePathStore: spacePathStore, } }, ) @@ -294,17 +292,12 @@ func (c *controller) HeadManifest( } func (c *controller) ProxyBlob( - ctx context.Context, - secretStore store2.SecretStore, - encrypter encrypt.Encrypter, - art pkg.RegistryInfo, - repoKey string, - proxy types.UpstreamProxy, + ctx context.Context, art pkg.RegistryInfo, repoKey string, proxy types.UpstreamProxy, ) (int64, io.ReadCloser, error) { remoteImage := getRemoteRepo(art) log.Debug().Msgf("The blob doesn't exist, proxy the request to the target server, url:%v", remoteImage) - rHelper, err := NewRemoteHelper(ctx, secretStore, encrypter, repoKey, proxy) + rHelper, err := NewRemoteHelper(ctx, c.spacePathStore, c.secretService, repoKey, proxy) if err != nil { return 0, nil, err } diff --git a/registry/app/remote/controller/proxy/remote.go b/registry/app/remote/controller/proxy/remote.go index 8db9241d4..c72feddfc 100644 --- a/registry/app/remote/controller/proxy/remote.go +++ b/registry/app/remote/controller/proxy/remote.go @@ -20,11 +20,11 @@ import ( "io" "github.com/harness/gitness/app/store" - "github.com/harness/gitness/encrypt" api "github.com/harness/gitness/registry/app/api/openapi/contracts/artifact" "github.com/harness/gitness/registry/app/manifest" "github.com/harness/gitness/registry/app/remote/adapter" "github.com/harness/gitness/registry/types" + "github.com/harness/gitness/secret" "github.com/rs/zerolog/log" "golang.org/x/net/context" @@ -52,14 +52,12 @@ type remoteHelper struct { registry adapter.ArtifactRegistry upstreamProxy types.UpstreamProxy URL string + secretService secret.Service } // NewRemoteHelper create a remote interface. func NewRemoteHelper( - ctx context.Context, - secretStore store.SecretStore, - encrypter encrypt.Encrypter, - repoKey string, + ctx context.Context, spacePathStore store.SpacePathStore, secretService secret.Service, repoKey string, proxy types.UpstreamProxy, ) (RemoteInterface, error) { if proxy.Source == string(api.UpstreamConfigSourceDockerhub) { @@ -68,14 +66,15 @@ func NewRemoteHelper( r := &remoteHelper{ repoKey: repoKey, upstreamProxy: proxy, + secretService: secretService, } - if err := r.init(ctx, secretStore, encrypter); err != nil { + if err := r.init(ctx, spacePathStore); err != nil { return nil, err } return r, nil } -func (r *remoteHelper) init(ctx context.Context, secretStore store.SecretStore, encrypter encrypt.Encrypter) error { +func (r *remoteHelper) init(ctx context.Context, spacePathStore store.SpacePathStore) error { if r.registry != nil { return nil } @@ -85,7 +84,7 @@ func (r *remoteHelper) init(ctx context.Context, secretStore store.SecretStore, if err != nil { return err } - adp, err := factory.Create(ctx, secretStore, encrypter, r.upstreamProxy) + adp, err := factory.Create(ctx, spacePathStore, r.upstreamProxy, r.secretService) if err != nil { return err } diff --git a/secret/interface.go b/secret/interface.go new file mode 100644 index 000000000..2d961b307 --- /dev/null +++ b/secret/interface.go @@ -0,0 +1,23 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secret + +import ( + "context" +) + +type Service interface { + DecryptSecret(ctx context.Context, spacePath, secretIdentifier string) (string, error) +}