Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow hostname specific dependency mirrors #322

Merged
merged 8 commits into from
Apr 12, 2024
Merged
107 changes: 68 additions & 39 deletions dependency_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,28 +69,29 @@ type DependencyCache struct {
// httpClientTimeouts contains the timeout values used by HTTP client
HttpClientTimeouts HttpClientTimeouts

// Alternative source used for downloading dependencies.
DependencyMirror string
// Alternative sources used for downloading dependencies.
DependencyMirrors map[string]string
}

// NewDependencyCache creates a new instance setting the default cache path (<BUILDPACK_PATH>/dependencies) and user
// agent (<BUILDPACK_ID>/<BUILDPACK_VERSION>).
// Mappings will be read from any libcnb.Binding in the context with type "dependency-mappings".
//
// In some air-gapped environments, dependencies might not be download directly but need to be pulled from a local mirror registry.
// In such cases, an alternative URI can either be provided as environment variable "BP_DEPENDENCY_MIRROR", or by a binding of type "dependency-mirror"
// where a file named "uri" holds the desired location.
// The two schemes https:// and file:// are supported in mirror URIs where the expected format is (optional parts in "[]"):
// <scheme>://[<username>:<password>@]<hostname>[:<port>][/<prefix>]
// The optional path part of the provided URI is used as a prefix that might be necessary in some setups.
// This (prefix) path may also include a placeholder of "{originalHost}" at any level (in sub-paths or at top-level) and is replaced with the
// hostname of the original download URI at build time. A sample mirror URI might look like this: https://local-mirror.example.com/buildpacks-dependencies/{originalHost}
// In some environments, many dependencies might need to be downloaded from a (local) mirror registry or filesystem.
// Such alternative locations can be configured using bindings of type "dependency-mirror", avoiding too many "dependency-mapping" bindings.
// Environment variables named "BP_DEPENDENCY_MIRROR" (default) or "BP_DEPENDENCY_MIRROR_<HOSTNAME>" (hostname-specific mirror)
// can also be used for the same purpose.
func NewDependencyCache(context libcnb.BuildContext) (DependencyCache, error) {
cache := DependencyCache{
CachePath: filepath.Join(context.Buildpack.Path, "dependencies"),
DownloadPath: os.TempDir(),
UserAgent: fmt.Sprintf("%s/%s", context.Buildpack.Info.ID, context.Buildpack.Info.Version),
Mappings: map[string]string{},
CachePath: filepath.Join(context.Buildpack.Path, "dependencies"),
DownloadPath: os.TempDir(),
UserAgent: fmt.Sprintf("%s/%s", context.Buildpack.Info.ID, context.Buildpack.Info.Version),
Mappings: map[string]string{},
DependencyMirrors: map[string]string{},
// We create the logger here because the initialization process may log some warnings that should be visible to users.
// This goes against the usual pattern, which has the user supply the Logger after initialization.
// There's no choice though, if we want the warning messages to be visible to users. We should clean this up in v2.
Logger: bard.NewLogger(os.Stdout),
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
}
mappings, err := filterBindingsByType(context.Platform.Bindings, "dependency-mapping")
if err != nil {
Expand All @@ -104,11 +105,11 @@ func NewDependencyCache(context libcnb.BuildContext) (DependencyCache, error) {
}
cache.HttpClientTimeouts = *clientTimeouts

dependencyMirror, err := getDependencyMirror(context.Platform.Bindings)
bindingMirrors, err := filterBindingsByType(context.Platform.Bindings, "dependency-mirror")
if err != nil {
return DependencyCache{}, err
return DependencyCache{}, fmt.Errorf("unable to process dependency-mirror bindings\n%w", err)
}
cache.DependencyMirror = dependencyMirror
cache.setDependencyMirrors(bindingMirrors)

return cache, nil
}
Expand Down Expand Up @@ -153,22 +154,44 @@ func customizeHttpClientTimeouts() (*HttpClientTimeouts, error) {
}, nil
}

// Returns the URI of a dependency mirror (optional).
// Such mirror location can be defined in a binding of type 'dependency-mirror' with filename 'uri'
// or using the environment variable 'BP_DEPENDENCY_MIRROR'. The latter takes precedence in case both are found.
func getDependencyMirror(bindings libcnb.Bindings) (string, error) {
dependencyMirror := sherpa.GetEnvWithDefault("BP_DEPENDENCY_MIRROR", "")
// If no mirror was found in environment variables, try to find one in bindings.
if dependencyMirror == "" {
dependencyMirrorBindings, err := filterBindingsByType(bindings, "dependency-mirror")
if err == nil {
// Use the content of the file named "uri" as the mirror's URI.
dependencyMirror = dependencyMirrorBindings["uri"]
} else {
return "", err
func (d *DependencyCache) setDependencyMirrors(bindingMirrors map[string]string) {
// Initialize with mirrors from bindings.
d.DependencyMirrors = bindingMirrors
// Add mirrors from env variables and override duplicate hostnames set in bindings.
envs := os.Environ()
for _, env := range envs {
envPair := strings.SplitN(env, "=", 2)
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
if len(envPair) != 2 {
continue
}
hostnameSuffix, isMirror := strings.CutPrefix(envPair[0], "BP_DEPENDENCY_MIRROR")
if isMirror {
hostnameEncoded, _ := strings.CutPrefix(hostnameSuffix, "_")
if strings.ToLower(hostnameEncoded) == "default" {
d.Logger.Bodyf("%s with illegal hostname 'default'. Please use BP_DEPENDENCY_MIRROR to set a default.",
color.YellowString("Ignored dependency mirror"))
continue
}
d.DependencyMirrors[decodeHostnameEnv(hostnameEncoded, d)] = envPair[1]
}
}
return dependencyMirror, nil
}

// Takes an encoded hostname (from env key) and returns the decoded version in lower case.
// Replaces double underscores (__) with one dash (-) and single underscores (_) with one period (.).
func decodeHostnameEnv(encodedHostname string, d *DependencyCache) string {
if strings.ContainsAny(encodedHostname, "-.") || encodedHostname != strings.ToUpper(encodedHostname) {
d.Logger.Bodyf("%s These will be allowed but for best results across different shells, you should replace . characters with _ characters "+
"and - characters with __, and use all upper case letters. The buildpack will convert these back before using the mirror.",
color.YellowString("You have invalid characters in your mirror host environment variable."))
}
var decodedHostname string
if encodedHostname == "" {
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
decodedHostname = "default"
} else {
decodedHostname = strings.ReplaceAll(strings.ReplaceAll(encodedHostname, "__", "-"), "_", ".")
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
}
return strings.ToLower(decodedHostname)
}

// Returns a key/value map with all entries for a given binding type.
Expand All @@ -178,10 +201,10 @@ func filterBindingsByType(bindings libcnb.Bindings, bindingType string) (map[str
for _, binding := range bindings {
if strings.ToLower(binding.Type) == bindingType {
for key, value := range binding.Secret {
if _, ok := filteredBindings[key]; ok {
if _, ok := filteredBindings[strings.ToLower(key)]; ok {
return nil, fmt.Errorf("multiple %s bindings found with duplicate keys %s", binding.Type, key)
}
filteredBindings[key] = value
filteredBindings[strings.ToLower(key)] = value
dmikusa marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down Expand Up @@ -225,11 +248,17 @@ func (d *DependencyCache) Artifact(dependency BuildpackDependency, mods ...Reque
return nil, fmt.Errorf("unable to parse URI. see DEBUG log level")
}

if isBinding && d.DependencyMirror != "" {
mirror := d.DependencyMirrors["default"]
mirrorHostSpecific := d.DependencyMirrors[urlP.Hostname()]
if mirrorHostSpecific != "" {
mirror = mirrorHostSpecific
}

if isBinding && mirror != "" {
d.Logger.Bodyf("Both dependency mirror and bindings are present. %s Please remove dependency map bindings if you wish to use the mirror.",
color.YellowString("Mirror is being ignored."))
} else {
d.setDependencyMirror(urlP)
d.setDependencyMirror(urlP, mirror)
}

if dependency.SHA256 == "" {
Expand Down Expand Up @@ -422,18 +451,18 @@ func (DependencyCache) verify(path string, expected string) error {
return nil
}

func (d DependencyCache) setDependencyMirror(urlD *url.URL) {
if d.DependencyMirror != "" {
func (d DependencyCache) setDependencyMirror(urlD *url.URL, mirror string) {
if mirror != "" {
d.Logger.Bodyf("%s Download URIs will be overridden.", color.GreenString("Dependency mirror found."))
urlOverride, err := url.ParseRequestURI(d.DependencyMirror)
urlOverride, err := url.ParseRequestURI(mirror)

if strings.ToLower(urlOverride.Scheme) == "https" || strings.ToLower(urlOverride.Scheme) == "file" {
urlD.Scheme = urlOverride.Scheme
urlD.User = urlOverride.User
urlD.Path = strings.Replace(urlOverride.Path, "{originalHost}", urlD.Hostname(), 1) + urlD.Path
urlD.Host = urlOverride.Host
} else {
d.Logger.Debugf("Dependency mirror URI is invalid: %s\n%w", d.DependencyMirror, err)
d.Logger.Debugf("Dependency mirror URI is invalid: %s\n%w", mirror, err)
d.Logger.Bodyf("%s is ignored. Have you used one of the supported schemes https:// or file://?", color.YellowString("Invalid dependency mirror"))
}
}
Expand Down
43 changes: 35 additions & 8 deletions dependency_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,29 +154,39 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
context("dependency mirror from environment variable", func() {
it.Before(func() {
t.Setenv("BP_DEPENDENCY_MIRROR", "https://env-var-mirror.acme.com")
t.Setenv("BP_DEPENDENCY_MIRROR_EXAMP__LE_COM", "https://examp-le.com")
})

it("uses BP_DEPENDENCY_MIRROR environment variable", func() {
dependencyCache, err := libpak.NewDependencyCache(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(dependencyCache.DependencyMirror).To(Equal("https://env-var-mirror.acme.com"))
Expect(dependencyCache.DependencyMirrors["default"]).To(Equal("https://env-var-mirror.acme.com"))
Expect(dependencyCache.DependencyMirrors["examp-le.com"]).To(Equal("https://examp-le.com"))
})
})

context("dependency mirror from binding", func() {
context("dependency mirror from binding and environment variable", func() {
it.Before(func() {
t.Setenv("BP_DEPENDENCY_MIRROR_EXAMP__LE_COM", "https://examp-le.com")
ctx.Platform.Bindings = append(ctx.Platform.Bindings, libcnb.Binding{
Type: "dependency-mirror",
Secret: map[string]string{
"uri": "https://bindings-mirror.acme.com",
"default": "https://bindings-mirror.acme.com",
"examp-le.com": "https://invalid.com",
},
})
})

it("uses dependency-mirror binding", func() {
dependencyCache, err := libpak.NewDependencyCache(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(dependencyCache.DependencyMirror).To(Equal("https://bindings-mirror.acme.com"))
Expect(dependencyCache.DependencyMirrors["default"]).To(Equal("https://bindings-mirror.acme.com"))
})

it("environment variable overrides binding", func() {
dependencyCache, err := libpak.NewDependencyCache(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(dependencyCache.DependencyMirrors["examp-le.com"]).To(Equal("https://examp-le.com"))
})
})
})
Expand Down Expand Up @@ -332,6 +342,7 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {

it.Before(func() {
mirrorServer = ghttp.NewTLSServer()
dependencyCache.DependencyMirrors = map[string]string{}
})

it.After(func() {
Expand All @@ -347,7 +358,7 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
ghttp.RespondWith(http.StatusOK, "test-fixture"),
))

dependencyCache.DependencyMirror = url.Scheme + "://" + "username:password@" + url.Host + "/foo/bar"
dependencyCache.DependencyMirrors["default"] = url.Scheme + "://" + "username:password@" + url.Host + "/foo/bar"
a, err := dependencyCache.Artifact(dependency)
Expect(err).NotTo(HaveOccurred())

Expand All @@ -362,7 +373,22 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
ghttp.RespondWith(http.StatusOK, "test-fixture"),
))

dependencyCache.DependencyMirror = url.Scheme + "://" + url.Host + "/{originalHost}"
dependencyCache.DependencyMirrors["default"] = url.Scheme + "://" + url.Host + "/{originalHost}"
a, err := dependencyCache.Artifact(dependency)
Expect(err).NotTo(HaveOccurred())

Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture")))
})

it("downloads from https mirror host specific", func() {
url, err := url.Parse(mirrorServer.URL())
Expect(err).NotTo(HaveOccurred())
mirrorServer.AppendHandlers(ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/host-specific/test-path", ""),
ghttp.RespondWith(http.StatusOK, "test-fixture"),
))

dependencyCache.DependencyMirrors["127.0.0.1"] = url.Scheme + "://" + url.Host + "/host-specific"
a, err := dependencyCache.Artifact(dependency)
Expect(err).NotTo(HaveOccurred())

Expand All @@ -384,6 +410,7 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
Expect(err).NotTo(HaveOccurred())
mirrorPathPreservedHost = filepath.Join(mirrorPath, originalUrl.Hostname(), "prefix")
Expect(os.MkdirAll(mirrorPathPreservedHost, os.ModePerm)).NotTo(HaveOccurred())
dependencyCache.DependencyMirrors = map[string]string{}
})

it.After(func() {
Expand All @@ -394,7 +421,7 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
mirrorFile := filepath.Join(mirrorPath, "test-path")
Expect(os.WriteFile(mirrorFile, []byte("test-fixture"), 0644)).ToNot(HaveOccurred())

dependencyCache.DependencyMirror = "file://" + mirrorPath
dependencyCache.DependencyMirrors["default"] = "file://" + mirrorPath
a, err := dependencyCache.Artifact(dependency)
Expect(err).NotTo(HaveOccurred())

Expand All @@ -405,7 +432,7 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) {
mirrorFilePreservedHost := filepath.Join(mirrorPathPreservedHost, "test-path")
Expect(os.WriteFile(mirrorFilePreservedHost, []byte("test-fixture"), 0644)).ToNot(HaveOccurred())

dependencyCache.DependencyMirror = "file://" + mirrorPath + "/{originalHost}" + "/prefix"
dependencyCache.DependencyMirrors["default"] = "file://" + mirrorPath + "/{originalHost}" + "/prefix"
a, err := dependencyCache.Artifact(dependency)
Expect(err).NotTo(HaveOccurred())

Expand Down