diff --git a/packages/auctioneer/spec b/packages/auctioneer/spec index 335f84a7cf..79d1fe50bb 100644 --- a/packages/auctioneer/spec +++ b/packages/auctioneer/spec @@ -32,6 +32,7 @@ files: - code.cloudfoundry.org/ecrhelper/*.go # gosub - code.cloudfoundry.org/executor/*.go # gosub - code.cloudfoundry.org/executor/containermetrics/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-diodes/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2/*.go # gosub diff --git a/packages/bbs/spec b/packages/bbs/spec index 2249b51295..0a4bd50acf 100644 --- a/packages/bbs/spec +++ b/packages/bbs/spec @@ -41,6 +41,7 @@ files: - code.cloudfoundry.org/ecrhelper/*.go # gosub - code.cloudfoundry.org/executor/*.go # gosub - code.cloudfoundry.org/executor/containermetrics/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-diodes/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2/*.go # gosub diff --git a/packages/cfdot/spec b/packages/cfdot/spec index 37f5b8598c..08393afc2f 100644 --- a/packages/cfdot/spec +++ b/packages/cfdot/spec @@ -24,6 +24,7 @@ files: - code.cloudfoundry.org/ecrhelper/*.go # gosub - code.cloudfoundry.org/executor/*.go # gosub - code.cloudfoundry.org/executor/containermetrics/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-diodes/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2/*.go # gosub diff --git a/packages/docker_app_lifecycle/spec b/packages/docker_app_lifecycle/spec index 7853073d76..236b3d95e2 100644 --- a/packages/docker_app_lifecycle/spec +++ b/packages/docker_app_lifecycle/spec @@ -27,6 +27,7 @@ files: - code.cloudfoundry.org/dockerapplifecycle/launcher/*.go # gosub - code.cloudfoundry.org/dockerapplifecycle/protocol/*.go # gosub - code.cloudfoundry.org/ecrhelper/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/github.com/BurntSushi/toml/*.go # gosub - code.cloudfoundry.org/vendor/github.com/BurntSushi/toml/internal/*.go # gosub - code.cloudfoundry.org/vendor/github.com/aws/aws-sdk-go-v2/aws/*.go # gosub diff --git a/packages/rep/spec b/packages/rep/spec index 0a4d8337e4..826d7b6d0c 100644 --- a/packages/rep/spec +++ b/packages/rep/spec @@ -53,6 +53,7 @@ files: - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/server/streamer/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/server/timebomb/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/transport/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-diodes/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2/*.go # gosub diff --git a/packages/rep_windows/spec b/packages/rep_windows/spec index 385502bce0..1bb1ac3f0d 100644 --- a/packages/rep_windows/spec +++ b/packages/rep_windows/spec @@ -54,6 +54,7 @@ files: - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/server/streamer/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/server/timebomb/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/garden/transport/*.go # gosub + - code.cloudfoundry.org/gcrhelper/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-diodes/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/*.go # gosub - code.cloudfoundry.org/vendor/code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2/*.go # gosub diff --git a/src/code.cloudfoundry.org/dockerapplifecycle/builder/builder_runner.go b/src/code.cloudfoundry.org/dockerapplifecycle/builder/builder_runner.go index b43070278c..6bd2b7fb5e 100644 --- a/src/code.cloudfoundry.org/dockerapplifecycle/builder/builder_runner.go +++ b/src/code.cloudfoundry.org/dockerapplifecycle/builder/builder_runner.go @@ -11,6 +11,7 @@ import ( "code.cloudfoundry.org/dockerapplifecycle/helpers" "code.cloudfoundry.org/dockerapplifecycle/protocol" "code.cloudfoundry.org/ecrhelper" + "code.cloudfoundry.org/gcrhelper" "github.com/containers/image/v5/types" ) @@ -35,6 +36,7 @@ type Builder struct { DockerPassword string DockerEmail string ECRHelper ecrhelper.ECRHelper + GCRHelper gcrhelper.GCRHelper } func (builder *Builder) Run(signals <-chan os.Signal, ready chan<- struct{}) error { @@ -128,6 +130,19 @@ func (builder Builder) build() <-chan error { } func (builder Builder) getCredentials() (string, string, error) { + if builder.DockerUser == "" && builder.DockerPassword == "" { + isGCRRepo, err := builder.GCRHelper.IsGCRRepo(builder.RegistryURL) + if err != nil { + return "", "", fmt.Errorf( + "failed to check whether the registry URL is a GCR/Artifact Registry repo: %s", + err.Error(), + ) + } + if isGCRRepo { + return builder.GCRHelper.GetGCRCredentials() + } + } + isECRRepo, err := builder.ECRHelper.IsECRRepo(builder.RegistryURL) if err != nil { return "", "", fmt.Errorf( diff --git a/src/code.cloudfoundry.org/dockerapplifecycle/builder/main.go b/src/code.cloudfoundry.org/dockerapplifecycle/builder/main.go index 1989d7ca22..9b76c7b8f1 100644 --- a/src/code.cloudfoundry.org/dockerapplifecycle/builder/main.go +++ b/src/code.cloudfoundry.org/dockerapplifecycle/builder/main.go @@ -10,6 +10,7 @@ import ( "code.cloudfoundry.org/dockerapplifecycle/helpers" "code.cloudfoundry.org/ecrhelper" + "code.cloudfoundry.org/gcrhelper" "github.com/tedsuo/ifrit" "github.com/tedsuo/ifrit/grouper" "github.com/tedsuo/ifrit/sigmon" @@ -141,6 +142,7 @@ func main() { DockerPassword: *dockerPassword, DockerEmail: *dockerEmail, ECRHelper: ecrhelper.NewECRHelper(), + GCRHelper: gcrhelper.NewGCRHelper(), } members := grouper.Members{ diff --git a/src/code.cloudfoundry.org/gcrhelper/fakes/fake_gcrhelper.go b/src/code.cloudfoundry.org/gcrhelper/fakes/fake_gcrhelper.go new file mode 100644 index 0000000000..877d15da91 --- /dev/null +++ b/src/code.cloudfoundry.org/gcrhelper/fakes/fake_gcrhelper.go @@ -0,0 +1,185 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fakes + +import ( + "sync" + + "code.cloudfoundry.org/gcrhelper" +) + +type FakeGCRHelper struct { + GetGCRCredentialsStub func() (string, string, error) + getGCRCredentialsMutex sync.RWMutex + getGCRCredentialsArgsForCall []struct{} + getGCRCredentialsReturns struct { + result1 string + result2 string + result3 error + } + getGCRCredentialsReturnsOnCall map[int]struct { + result1 string + result2 string + result3 error + } + IsGCRRepoStub func(string) (bool, error) + isGCRRepoMutex sync.RWMutex + isGCRRepoArgsForCall []struct { + arg1 string + } + isGCRRepoReturns struct { + result1 bool + result2 error + } + isGCRRepoReturnsOnCall map[int]struct { + result1 bool + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeGCRHelper) GetGCRCredentials() (string, string, error) { + fake.getGCRCredentialsMutex.Lock() + ret, specificReturn := fake.getGCRCredentialsReturnsOnCall[len(fake.getGCRCredentialsArgsForCall)] + fake.getGCRCredentialsArgsForCall = append(fake.getGCRCredentialsArgsForCall, struct{}{}) + stub := fake.GetGCRCredentialsStub + fakeReturns := fake.getGCRCredentialsReturns + fake.recordInvocation("GetGCRCredentials", []interface{}{}) + fake.getGCRCredentialsMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeGCRHelper) GetGCRCredentialsCallCount() int { + fake.getGCRCredentialsMutex.RLock() + defer fake.getGCRCredentialsMutex.RUnlock() + return len(fake.getGCRCredentialsArgsForCall) +} + +func (fake *FakeGCRHelper) GetGCRCredentialsCalls(stub func() (string, string, error)) { + fake.getGCRCredentialsMutex.Lock() + defer fake.getGCRCredentialsMutex.Unlock() + fake.GetGCRCredentialsStub = stub +} + +func (fake *FakeGCRHelper) GetGCRCredentialsReturns(result1 string, result2 string, result3 error) { + fake.getGCRCredentialsMutex.Lock() + defer fake.getGCRCredentialsMutex.Unlock() + fake.GetGCRCredentialsStub = nil + fake.getGCRCredentialsReturns = struct { + result1 string + result2 string + result3 error + }{result1, result2, result3} +} + +func (fake *FakeGCRHelper) GetGCRCredentialsReturnsOnCall(i int, result1 string, result2 string, result3 error) { + fake.getGCRCredentialsMutex.Lock() + defer fake.getGCRCredentialsMutex.Unlock() + fake.GetGCRCredentialsStub = nil + if fake.getGCRCredentialsReturnsOnCall == nil { + fake.getGCRCredentialsReturnsOnCall = make(map[int]struct { + result1 string + result2 string + result3 error + }) + } + fake.getGCRCredentialsReturnsOnCall[i] = struct { + result1 string + result2 string + result3 error + }{result1, result2, result3} +} + +func (fake *FakeGCRHelper) IsGCRRepo(arg1 string) (bool, error) { + fake.isGCRRepoMutex.Lock() + ret, specificReturn := fake.isGCRRepoReturnsOnCall[len(fake.isGCRRepoArgsForCall)] + fake.isGCRRepoArgsForCall = append(fake.isGCRRepoArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.IsGCRRepoStub + fakeReturns := fake.isGCRRepoReturns + fake.recordInvocation("IsGCRRepo", []interface{}{arg1}) + fake.isGCRRepoMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeGCRHelper) IsGCRRepoCallCount() int { + fake.isGCRRepoMutex.RLock() + defer fake.isGCRRepoMutex.RUnlock() + return len(fake.isGCRRepoArgsForCall) +} + +func (fake *FakeGCRHelper) IsGCRRepoCalls(stub func(string) (bool, error)) { + fake.isGCRRepoMutex.Lock() + defer fake.isGCRRepoMutex.Unlock() + fake.IsGCRRepoStub = stub +} + +func (fake *FakeGCRHelper) IsGCRRepoArgsForCall(i int) string { + fake.isGCRRepoMutex.RLock() + defer fake.isGCRRepoMutex.RUnlock() + argsForCall := fake.isGCRRepoArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeGCRHelper) IsGCRRepoReturns(result1 bool, result2 error) { + fake.isGCRRepoMutex.Lock() + defer fake.isGCRRepoMutex.Unlock() + fake.IsGCRRepoStub = nil + fake.isGCRRepoReturns = struct { + result1 bool + result2 error + }{result1, result2} +} + +func (fake *FakeGCRHelper) IsGCRRepoReturnsOnCall(i int, result1 bool, result2 error) { + fake.isGCRRepoMutex.Lock() + defer fake.isGCRRepoMutex.Unlock() + fake.IsGCRRepoStub = nil + if fake.isGCRRepoReturnsOnCall == nil { + fake.isGCRRepoReturnsOnCall = make(map[int]struct { + result1 bool + result2 error + }) + } + fake.isGCRRepoReturnsOnCall[i] = struct { + result1 bool + result2 error + }{result1, result2} +} + +func (fake *FakeGCRHelper) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeGCRHelper) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ gcrhelper.GCRHelper = new(FakeGCRHelper) diff --git a/src/code.cloudfoundry.org/gcrhelper/gcrhelper.go b/src/code.cloudfoundry.org/gcrhelper/gcrhelper.go new file mode 100644 index 0000000000..415802cf49 --- /dev/null +++ b/src/code.cloudfoundry.org/gcrhelper/gcrhelper.go @@ -0,0 +1,123 @@ +// Package gcrhelper provides credential support for Google Container Registry +// (gcr.io) and Artifact Registry (*.pkg.dev). GCR is the legacy service; Google +// now routes gcr.io requests through Artifact Registry. Both URL patterns are +// supported. Authentication uses the GCE instance metadata server to obtain a +// short-lived OAuth2 token from the VM's attached service account. +package gcrhelper + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "sync" + "time" +) + +const ( + GCE_METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" + GCR_USERNAME = "oauth2accesstoken" + GCR_REPO_REGEX = `([a-zA-Z0-9-]+\.)?gcr\.io|[a-zA-Z0-9-]+\.pkg\.dev` + + // metadataTimeout is intentionally short: the GCE metadata server is a + // link-local address (169.254.169.254) that responds in <1ms on GCE and + // typically fails immediately off-GCE due to no route. Since this is only + // attempted once per process lifetime (notOnGCE is set on first failure), + // 1s is a safe upper bound. + metadataTimeout = 1 * time.Second +) + +var gcrRepoRegex = regexp.MustCompile(GCR_REPO_REGEX) + +//go:generate counterfeiter -o fakes/fake_gcrhelper.go . GCRHelper +type GCRHelper interface { + IsGCRRepo(registryURL string) (bool, error) + GetGCRCredentials() (string, string, error) +} + +// TokenFetcher retrieves an OAuth2 access token for use with GCR/Artifact Registry. +// The default implementation calls the GCE instance metadata server, which +// automatically uses the VM's attached service account without any stored credentials. +type TokenFetcher func() (string, error) + +func DefaultTokenFetcher() (string, error) { + req, err := http.NewRequest("GET", GCE_METADATA_TOKEN_URL, nil) + if err != nil { + return "", err + } + req.Header.Set("Metadata-Flavor", "Google") + + client := &http.Client{Timeout: metadataTimeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to reach GCE metadata server: %s", err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GCE metadata server returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var tokenResponse struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", fmt.Errorf("failed to parse GCE metadata token response: %s", err.Error()) + } + if tokenResponse.AccessToken == "" { + return "", fmt.Errorf("empty access_token in GCE metadata response") + } + + return tokenResponse.AccessToken, nil +} + +type gcrHelper struct { + tokenFetcher TokenFetcher + mu sync.Mutex + notOnGCE bool +} + +func NewGCRHelper() GCRHelper { + return &gcrHelper{ + tokenFetcher: DefaultTokenFetcher, + } +} + +func NewGCRHelperWithTokenFetcher(fetcher TokenFetcher) GCRHelper { + if fetcher == nil { + fetcher = DefaultTokenFetcher + } + return &gcrHelper{ + tokenFetcher: fetcher, + } +} + +func (h *gcrHelper) IsGCRRepo(registryURL string) (bool, error) { + return gcrRepoRegex.MatchString(registryURL), nil +} + +func (h *gcrHelper) GetGCRCredentials() (string, string, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.notOnGCE { + return "", "", nil + } + + token, err := h.tokenFetcher() + if err != nil { + // Not running on GCE or metadata unavailable — fall back to unauthenticated + // so that truly public GCR/Artifact Registry images still pull successfully. + // We remember this for the lifetime of the process to avoid a dial attempt + // on every subsequent container start. + h.notOnGCE = true + return "", "", nil + } + return GCR_USERNAME, token, nil +} diff --git a/src/code.cloudfoundry.org/gcrhelper/gcrhelper_suite_test.go b/src/code.cloudfoundry.org/gcrhelper/gcrhelper_suite_test.go new file mode 100644 index 0000000000..6315fc35f7 --- /dev/null +++ b/src/code.cloudfoundry.org/gcrhelper/gcrhelper_suite_test.go @@ -0,0 +1,13 @@ +package gcrhelper_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGcrhelper(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Gcrhelper Suite") +} diff --git a/src/code.cloudfoundry.org/gcrhelper/gcrhelper_test.go b/src/code.cloudfoundry.org/gcrhelper/gcrhelper_test.go new file mode 100644 index 0000000000..57540166a4 --- /dev/null +++ b/src/code.cloudfoundry.org/gcrhelper/gcrhelper_test.go @@ -0,0 +1,108 @@ +package gcrhelper_test + +import ( + "fmt" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gcrhelper" +) + +var _ = Describe("Gcrhelper", func() { + var helper gcrhelper.GCRHelper + + BeforeEach(func() { + helper = gcrhelper.NewGCRHelper() + }) + + Describe("IsGCRRepo", func() { + DescribeTable("returns true for GCR/Artifact Registry URLs", + func(url string) { + isGCR, err := helper.IsGCRRepo(url) + Expect(err).NotTo(HaveOccurred()) + Expect(isGCR).To(BeTrue()) + }, + Entry("gcr.io", "gcr.io/my-project/my-image:tag"), + Entry("gcr.io with docker:// scheme", "docker://gcr.io/my-project/my-image:tag"), + Entry("us.gcr.io", "us.gcr.io/my-project/my-image:tag"), + Entry("eu.gcr.io", "eu.gcr.io/my-project/my-image:tag"), + Entry("asia.gcr.io", "asia.gcr.io/my-project/my-image:tag"), + Entry("Artifact Registry pkg.dev", "europe-west3-docker.pkg.dev/my-project/my-repo/my-image:tag"), + Entry("Artifact Registry with docker:// scheme", "docker://europe-west3-docker.pkg.dev/my-project/my-repo/my-image:tag"), + Entry("us-central1 Artifact Registry", "us-central1-docker.pkg.dev/my-project/my-repo/my-image:latest"), + ) + + DescribeTable("returns false for non-GCR URLs", + func(url string) { + isGCR, err := helper.IsGCRRepo(url) + Expect(err).NotTo(HaveOccurred()) + Expect(isGCR).To(BeFalse()) + }, + Entry("Docker Hub", "docker.io/cloudfoundry/diego-docker-app"), + Entry("Docker Hub with docker:// scheme", "docker://cloudfoundry/diego-docker-app"), + Entry("ECR", "555555555.dkr.ecr.us-east-1.amazonaws.com/my-image"), + Entry("private registry", "internal-registry.example.com:5000/my-repo/my-image:v2"), + Entry("preloaded rootfs", "preloaded:cflinuxfs4"), + ) + }) + + Describe("GetGCRCredentials", func() { + Context("when the token fetcher succeeds", func() { + BeforeEach(func() { + helper = gcrhelper.NewGCRHelperWithTokenFetcher(func() (string, error) { + return "ya29.fake-token", nil + }) + }) + + It("returns oauth2accesstoken and the metadata token", func() { + username, password, err := helper.GetGCRCredentials() + Expect(err).NotTo(HaveOccurred()) + Expect(username).To(Equal("oauth2accesstoken")) + Expect(password).To(Equal("ya29.fake-token")) + }) + + It("fetches a fresh token on every call (tokens are short-lived and metadata calls are <1ms on GCE)", func() { + var calls atomic.Int32 + helper = gcrhelper.NewGCRHelperWithTokenFetcher(func() (string, error) { + calls.Add(1) + return "ya29.fake-token", nil + }) + + _, _, _ = helper.GetGCRCredentials() + _, _, _ = helper.GetGCRCredentials() + _, _, _ = helper.GetGCRCredentials() + Expect(calls.Load()).To(Equal(int32(3))) + }) + }) + + Context("when the token fetcher fails (e.g. not running on GCE)", func() { + BeforeEach(func() { + helper = gcrhelper.NewGCRHelperWithTokenFetcher(func() (string, error) { + return "", fmt.Errorf("metadata server unreachable") + }) + }) + + It("returns empty credentials so public images still pull unauthenticated", func() { + username, password, err := helper.GetGCRCredentials() + Expect(err).NotTo(HaveOccurred()) + Expect(username).To(Equal("")) + Expect(password).To(Equal("")) + }) + + It("does not retry the metadata server after the first failure (one dial attempt per process lifetime)", func() { + var calls atomic.Int32 + helper = gcrhelper.NewGCRHelperWithTokenFetcher(func() (string, error) { + calls.Add(1) + return "", fmt.Errorf("metadata server unreachable") + }) + + _, _, _ = helper.GetGCRCredentials() + _, _, _ = helper.GetGCRCredentials() + _, _, _ = helper.GetGCRCredentials() + Expect(calls.Load()).To(Equal(int32(1))) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gcrhelper/package.go b/src/code.cloudfoundry.org/gcrhelper/package.go new file mode 100644 index 0000000000..d3002eaf26 --- /dev/null +++ b/src/code.cloudfoundry.org/gcrhelper/package.go @@ -0,0 +1 @@ +package gcrhelper // import "code.cloudfoundry.org/gcrhelper"