Repository: buildpacks/lifecycle (go)

Issue:
`CNB_TARGET_ARCH` and `CNB_TARGET_OS` not set correctly when using Platform API 0.9
### Summary

The Buildpack API v0.10 spec says that the `CNB_TARGET_ARCH` and `CNB_TARGET_OS` env vars will always be set:
https://github.com/buildpacks/spec/blob/buildpack/v0.10/buildpack.md#targets

However, this is not the case iff the Platform API is 0.9 or below, even if the buildpack is using Buildpack API 0.10, and the builder is using latest `lifecycle` (which has all the previous fixes for the targets related bugs).

This scenario can occur when a user uses an outdated Pack CLI version that does not support Platform API 0.10, such as Pack CLI v0.27.0 which only supports Platform API <= 0.9. 

This came up in:
https://github.com/heroku/buildpacks-php/issues/121

If Buildpack API 0.10 isn't compatible with older Platform APIs, then `lifecycle` should exit with a suitable error message. Otherwise the Buildpack API 0.10 spec should be honoured.

---

### Reproduction

##### Steps

1. Download Pack CLI 0.27.0 (which only supports Platform API <= 0.9), eg : `curl -fL https://github.com/buildpacks/pack/releases/download/v0.27.0/pack-v0.27.0-macos-arm64.tgz | tar -xz && mv ./pack ./pack-0.27.0`
2. `mkdir -p testcase/bin`
3. `echo -e 'api = "0.10"\n\n[buildpack]\nid = "testcase"\nversion = "0.0.1"\n\n[[stacks]]\nid = "*"' > testcase/buildpack.toml`
4. `echo -e '#!/usr/bin/env bash\n\nprintenv | grep CNB_TARGET | sort && exit 1' > testcase/bin/detect`
5. `./pack-0.27.0 build --builder heroku/builder:24 --trust-builder --buildpack testcase/ --path testcase/ testapp --verbose`

##### Current behavior

When using Pack CLI 0.27.0 (which only supports Platform API <= 0.9), the `CNB_TARGET_ARCH` and `CNB_TARGET_OS` env vars are not set correctly (they are set to the empty string, instead of `arm64` and `linux` respectively):

```
$ ./pack-0.27.0 build --builder heroku/builder:24 --trust-builder --buildpack testcase/ --path testcase/ testapp --verbose
Builder heroku/builder:24 is trusted
Pulling image index.docker.io/heroku/builder:24
...
Running the creator on OS linux with:
Container Settings:
  Args: /cnb/lifecycle/creator -daemon -launch-cache /launch-cache -log-level debug -app /workspace -cache-dir /cache -run-image heroku/heroku:24 testapp
  System Envs: CNB_PLATFORM_API=0.9
  Image: pack.local/builder/697477756b6764727773:latest
  User: root
  Labels: map[author:pack]
Host Settings:
  Binds: pack-cache-library_testapp_latest-64516644bb6c.build:/cache /var/run/docker.sock:/var/run/docker.sock pack-cache-library_testapp_latest-64516644bb6c.launch:/launch-cache pack-layers-vtzzazmhya:/layers pack-app-ogpwkrqjst:/workspace
  Network Mode:
Starting creator...
Parsing inputs...
Ensuring privileges...
Executing command...
===> ANALYZING
Timer: Analyzer started at 2024-07-04T13:51:51Z
Image with name "testapp" not found
Found image with identifier "a35bef8f087d6c0804bc83511d01b01b2d203389168e705d2f61921f7b256bab"
Timer: Analyzer ran for 18.375µs and ended at 2024-07-04T13:51:51Z
Run image info in analyzed metadata is:
{"Reference":"a35bef8f087d6c0804bc83511d01b01b2d203389168e705d2f61921f7b256bab","Image":"","Extend":false}
===> DETECTING
Timer: Detector started at 2024-07-04T13:51:51Z
target distro name/version labels not found, reading /etc/os-release file
======== Output: testcase@0.0.1 ========
CNB_TARGET_ARCH=
CNB_TARGET_ARCH_VARIANT=
CNB_TARGET_DISTRO_NAME=ubuntu
CNB_TARGET_DISTRO_VERSION=24.04
CNB_TARGET_OS=
...
```

Compare this to when latest Pack CLI is used, which supports Platform API 0.10:

```
$ pack build --builder heroku/builder:24 --trust-builder --buildpack testcase/ --path testcase/ testapp --verbose
Builder heroku/builder:24 is trusted
Pulling image index.docker.io/heroku/builder:24
...
Running the creator on OS linux from image pack.local/builder/76726b63726261776e7a:latest with:
Container Settings:
  Args: /cnb/lifecycle/creator -daemon -launch-cache /launch-cache -log-level debug -app /workspace -cache-dir /cache -run-image heroku/heroku:24 testapp
  System Envs: CNB_PLATFORM_API=0.13
  Image: pack.local/builder/76726b63726261776e7a:latest
  User: root
  Labels: map[author:pack]
Host Settings:
  Binds: pack-cache-library_testapp_latest-64516644bb6c.build:/cache /var/run/docker.sock:/var/run/docker.sock pack-cache-library_testapp_latest-64516644bb6c.launch:/launch-cache pack-layers-vzajudrqdn:/layers pack-app-wsqlwyuuoc:/workspace
  Network Mode:
Starting creator...
Parsing inputs...
Ensuring privileges...
Executing command...
===> ANALYZING
Timer: Analyzer started at 2024-07-04T13:51:30Z
Image with name "testapp" not found
Found image with identifier "a35bef8f087d6c0804bc83511d01b01b2d203389168e705d2f61921f7b256bab"
Timer: Analyzer ran for 21.084µs and ended at 2024-07-04T13:51:30Z
Run image info in analyzed metadata is:
{"Reference":"a35bef8f087d6c0804bc83511d01b01b2d203389168e705d2f61921f7b256bab","Image":"heroku/heroku:24","Extend":false,"target":{"os":"linux","arch":"arm64","distro":{"name":"ubuntu","version":"24.04"}}}
===> DETECTING
Timer: Detector started at 2024-07-04T13:51:30Z
Checking for match against descriptor: {   []}
======== Output: testcase@0.0.1 ========
CNB_TARGET_ARCH=arm64
CNB_TARGET_ARCH_VARIANT=
CNB_TARGET_DISTRO_NAME=ubuntu
CNB_TARGET_DISTRO_VERSION=24.04
CNB_TARGET_OS=linux
...
```

##### Expected behavior

Either:
1. Lifecycle should honour the Buildpack API 0.10 specification regardless of the Platform API version - and set `CNB_TARGET_ARCH` and `CNB_TARGET_OS` correctly.
2. Or, lifecycle should fail the build with a clear error message that explains the current combination of Platform API and Buildpack API versions are not compatible, and the user should try upgrading their tools.

---

### Context

##### lifecycle version

```
$ pack builder inspect heroku/builder:24
...
Lifecycle:
  Version: 0.19.7
  Buildpack APIs:
    Deprecated: (none)
    Supported: 0.7, 0.8, 0.9, 0.10, 0.11
  Platform APIs:
    Deprecated: (none)
    Supported: 0.7, 0.8, 0.9, 0.10, 0.11, 0.12, 0.13
```

##### platform version(s)

```
$ ./pack-0.27.0 report
Pack:
  Version:  0.27.0+git-f4f5be1.build-3382
  OS/Arch:  darwin/arm64

Default Lifecycle Version:  0.14.1

Supported Platform APIs:  0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9
...
```


Pull request description:
Fix CNB_TARGET_* env vars on older Platform API
<!-- 🎉🎉🎉 Thank you for the PR!!! 🎉🎉🎉 -->

### Summary
<!-- Please describe your changes at a high level. -->

Fixes https://github.com/buildpacks/lifecycle/issues/1371

#### Release notes
<!-- Please provide 1-2 sentences for release notes. -->
<!-- Example: When using platform API `0.7` or greater, the `creator` logs the expected phase header for the analyze phase -->

When populating target data for older platforms, populate OS & architecture as well as distro information

---

### Related
<!-- If this PR addresses an issue, please provide the issue number below. -->

Resolves #1371

---

### Context
<!-- Add any other context that may help reviewers (e.g., code that requires special attention, etc.). -->



Patch:
diff --git a/platform/target_data.go b/platform/target_data.go
index b77ce477..85c32f68 100644
--- a/platform/target_data.go
+++ b/platform/target_data.go
@@ -1,6 +1,9 @@
 package platform
 
 import (
+	"fmt"
+	"runtime"
+
 	"github.com/buildpacks/imgutil"
 
 	"github.com/buildpacks/lifecycle/buildpack"
@@ -87,10 +90,16 @@ func matches(target1, target2 string) bool {
 	return target1 == target2
 }
 
-// GetTargetOSFromFileSystem populates the target metadata you pass in if the information is available
-// returns a boolean indicating whether it populated any data.
+// GetTargetOSFromFileSystem populates the provided target metadata with information from /etc/os-release
+// if it is available.
 func GetTargetOSFromFileSystem(d fsutil.Detector, tm *files.TargetMetadata, logger log.Logger) {
 	if d.HasSystemdFile() {
+		if tm.OS == "" {
+			tm.OS = "linux"
+		}
+		if tm.Arch == "" {
+			tm.Arch = runtime.GOARCH // in a future world where we support cross platform builds, this should be removed
+		}
 		contents, err := d.ReadSystemdFile()
 		if err != nil {
 			logger.Warnf("Encountered error trying to read /etc/os-release file: %s", err.Error())
@@ -112,21 +121,30 @@ func EnvVarsFor(d fsutil.Detector, tm files.TargetMetadata, logger log.Logger) [
 		logger.Info("target distro name/version labels not found, reading /etc/os-release file")
 		GetTargetOSFromFileSystem(d, &tm, logger)
 	}
+	// required
 	ret := []string{
 		"CNB_TARGET_OS=" + tm.OS,
 		"CNB_TARGET_ARCH=" + tm.Arch,
-		"CNB_TARGET_ARCH_VARIANT=" + tm.ArchVariant,
 	}
+	// optional
 	var distName, distVersion string
 	if tm.Distro != nil {
 		distName = tm.Distro.Name
 		distVersion = tm.Distro.Version
 	}
-	ret = append(ret, "CNB_TARGET_DISTRO_NAME="+distName)
-	ret = append(ret, "CNB_TARGET_DISTRO_VERSION="+distVersion)
+	ret = appendIfNotEmpty(ret, "CNB_TARGET_ARCH_VARIANT", tm.ArchVariant)
+	ret = appendIfNotEmpty(ret, "CNB_TARGET_DISTRO_NAME", distName)
+	ret = appendIfNotEmpty(ret, "CNB_TARGET_DISTRO_VERSION", distVersion)
 	return ret
 }
 
+func appendIfNotEmpty(env []string, key, val string) []string {
+	if val == "" {
+		return env
+	}
+	return append(env, fmt.Sprintf("%s=%s", key, val))
+}
+
 // TargetSatisfiedForRebase treats optional fields (ArchVariant and Distribution fields) as wildcards if empty, returns true if all populated fields match
 func TargetSatisfiedForRebase(t files.TargetMetadata, appTargetMetadata files.TargetMetadata) bool {
 	if t.OS != appTargetMetadata.OS || t.Arch != appTargetMetadata.Arch {


Test patch:
diff --git a/phase/builder_test.go b/phase/builder_test.go
index e4f4fa1e..3a197cce 100644
--- a/phase/builder_test.go
+++ b/phase/builder_test.go
@@ -189,7 +189,6 @@ func testBuilder(t *testing.T, when spec.G, it spec.S) {
 			executor.EXPECT().Build(*bpA, gomock.Any(), gomock.Any()).DoAndReturn(
 				func(_ buildpack.BpDescriptor, inputs buildpack.BuildInputs, logger llog.Logger) (buildpack.BuildOutputs, error) {
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH=amd64")
-					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH_VARIANT=")
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_OS=linux")
 					return buildpack.BuildOutputs{}, nil
 				},
@@ -201,7 +200,6 @@ func testBuilder(t *testing.T, when spec.G, it spec.S) {
 			executor.EXPECT().Build(*bpB, gomock.Any(), gomock.Any()).Do(
 				func(_ buildpack.BpDescriptor, inputs buildpack.BuildInputs, _ llog.Logger) (buildpack.BuildOutputs, error) {
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH=amd64")
-					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH_VARIANT=")
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_OS=linux")
 					return buildpack.BuildOutputs{}, nil
 				})
diff --git a/phase/detector_test.go b/phase/detector_test.go
index 1c0e3be0..ff429cd5 100644
--- a/phase/detector_test.go
+++ b/phase/detector_test.go
@@ -221,7 +221,6 @@ func testDetector(t *testing.T, when spec.G, it spec.S) {
 			executor.EXPECT().Detect(bpA1, gomock.Any(), gomock.Any()).Do(
 				func(_ buildpack.Descriptor, inputs buildpack.DetectInputs, _ log.Logger) buildpack.DetectOutputs {
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH=amd64")
-					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_ARCH_VARIANT=")
 					h.AssertContains(t, inputs.TargetEnv, "CNB_TARGET_OS=linux")
 					return buildpack.DetectOutputs{}
 				})
diff --git a/phase/generator_test.go b/phase/generator_test.go
index aefc13e1..df7ecb98 100644
--- a/phase/generator_test.go
+++ b/phase/generator_test.go
@@ -283,7 +283,6 @@ func testGenerator(t *testing.T, when spec.G, it spec.S) {
 						func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
 							h.AssertContains(t, inputs.TargetEnv,
 								"CNB_TARGET_ARCH=amd64",
-								"CNB_TARGET_ARCH_VARIANT=",
 								"CNB_TARGET_OS=linux",
 							)
 							return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
@@ -293,7 +292,6 @@ func testGenerator(t *testing.T, when spec.G, it spec.S) {
 						func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
 							h.AssertContains(t, inputs.TargetEnv,
 								"CNB_TARGET_ARCH=amd64",
-								"CNB_TARGET_ARCH_VARIANT=",
 								"CNB_TARGET_OS=linux",
 							)
 							return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
diff --git a/platform/target_data_test.go b/platform/target_data_test.go
index cb78ade9..f5aa9ee6 100644
--- a/platform/target_data_test.go
+++ b/platform/target_data_test.go
@@ -2,6 +2,7 @@ package platform_test
 
 import (
 	"fmt"
+	"runtime"
 	"testing"
 
 	"github.com/apex/log"
@@ -129,6 +130,8 @@ func testTargetData(t *testing.T, when spec.G, it spec.S) {
 				t:       t,
 				HasFile: true}
 			platform.GetTargetOSFromFileSystem(&d, &tm, logr)
+			h.AssertEq(t, "linux", tm.OS)
+			h.AssertEq(t, runtime.GOARCH, tm.Arch)
 			h.AssertEq(t, "opensesame", tm.Distro.Name)
 			h.AssertEq(t, "3.14", tm.Distro.Version)
 		})
@@ -199,12 +202,28 @@ func testTargetData(t *testing.T, when spec.G, it spec.S) {
 			observed := platform.EnvVarsFor(d, tm, &log.Logger{Handler: memory.New()})
 			h.AssertContains(t, observed, "CNB_TARGET_ARCH="+tm.Arch)
 			h.AssertContains(t, observed, "CNB_TARGET_OS="+tm.OS)
-			// note: per the spec only the ID field is optional, so I guess the others should always be set: https://github.com/buildpacks/rfcs/blob/main/text/0096-remove-stacks-mixins.md#runtime-metadata
-			// the empty ones:
-			h.AssertContains(t, observed, "CNB_TARGET_ARCH_VARIANT=")
-			h.AssertContains(t, observed, "CNB_TARGET_DISTRO_NAME=")
-			h.AssertContains(t, observed, "CNB_TARGET_DISTRO_VERSION=")
-			h.AssertEq(t, len(observed), 5)
+			h.AssertEq(t, len(observed), 2)
+		})
+
+		when("optional vars are empty", func() {
+			it("omits them", func() {
+				tm := files.TargetMetadata{
+					// required
+					OS:   "linux",
+					Arch: "pentium",
+					// optional
+					ArchVariant: "",
+					Distro:      &files.OSDistro{Name: "nix", Version: ""},
+					ID:          "",
+				}
+				d := &mockDetector{
+					contents: "this is just test contents really",
+					t:        t,
+					HasFile:  false,
+				}
+				observed := platform.EnvVarsFor(d, tm, &log.Logger{Handler: memory.New()})
+				h.AssertEq(t, len(observed), 3)
+			})
 		})
 	})