diff --git a/apps/api/cmd/builder/env.go b/apps/api/cmd/builder/env.go index edb49533..42b2a559 100644 --- a/apps/api/cmd/builder/env.go +++ b/apps/api/cmd/builder/env.go @@ -3,8 +3,10 @@ package main import ( "encoding/base64" "encoding/json" + "maps" "github.com/unbindapp/unbind-api/internal/common/log" + "github.com/unbindapp/unbind-api/internal/gitenv" "github.com/unbindapp/unbind-api/pkg/builder/config" ) @@ -31,7 +33,8 @@ func decodeEnvJSON(raw string) (map[string]string, error) { return env, nil } -// mergeBuildSecrets layers the raw service secret under the rendered references so rendered values win +// mergeBuildSecrets layers the raw service secret under the rendered references so rendered values win. +// Git keys are left to the builder, which reads them from the clone. func mergeBuildSecrets(cfg *config.Config, rawSecrets, renderedEnv map[string]string) map[string]string { buildSecrets := make(map[string]string, len(rawSecrets)+len(renderedEnv)+2) @@ -48,5 +51,6 @@ func mergeBuildSecrets(cfg *config.Config, rawSecrets, renderedEnv map[string]st for k, v := range renderedEnv { buildSecrets[k] = v } + maps.DeleteFunc(buildSecrets, func(k, _ string) bool { return gitenv.IsKey(k) }) return buildSecrets } diff --git a/apps/api/cmd/builder/env_test.go b/apps/api/cmd/builder/env_test.go index 30931b7c..6f18df54 100644 --- a/apps/api/cmd/builder/env_test.go +++ b/apps/api/cmd/builder/env_test.go @@ -114,3 +114,15 @@ func TestMergeBuildSecretsOmitsEmptyRailpackCommands(t *testing.T) { t.Fatalf("expected no entries, got %v", got) } } + +// The builder sets the git keys from the clone, a stored value must not shadow them +func TestMergeBuildSecretsDropsGitKeys(t *testing.T) { + raw := map[string]string{"UNBIND_GIT_SHA": "stale", "A": "1"} + rendered := map[string]string{"UNBIND_GIT_BRANCH": "stale"} + + got := mergeBuildSecrets(&config.Config{}, raw, rendered) + + if len(got) != 1 || got["A"] != "1" { + t.Fatalf("got %v, want only A", got) + } +} diff --git a/apps/api/cmd/builder/main.go b/apps/api/cmd/builder/main.go index 4b9f797f..840ed900 100644 --- a/apps/api/cmd/builder/main.go +++ b/apps/api/cmd/builder/main.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "github.com/unbindapp/unbind-api/internal/infrastructure/registrycache" + "maps" "os" "time" @@ -14,6 +14,7 @@ import ( "github.com/unbindapp/unbind-api/internal/common/log" "github.com/unbindapp/unbind-api/internal/common/utils" "github.com/unbindapp/unbind-api/internal/infrastructure/database" + "github.com/unbindapp/unbind-api/internal/infrastructure/registrycache" repository "github.com/unbindapp/unbind-api/internal/repositories" "github.com/unbindapp/unbind-api/internal/repositories/repositories" service_repo "github.com/unbindapp/unbind-api/internal/repositories/service" @@ -319,6 +320,7 @@ func main() { log.Fatalf("Failed to read the service config: %v", err) } + maps.Copy(additionalEnv, builder.GitEnv()) _, serviceSpec, err := k8s.DeployImage(ctx, crdName, dockerImg, additionalEnv, securityContext, healthCheck, variableMounts, current.Edges.ServiceConfig) if err != nil { if err := markDeploymentFailed(ctx, cfg, webhooksService, repo, fmt.Sprintf("failed to deploy image %v", err), cfg.ServiceDeploymentID); err != nil { diff --git a/apps/api/go.mod b/apps/api/go.mod index 175e76d7..66341db9 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -40,6 +40,7 @@ require ( github.com/moby/buildkit v0.34.0 github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/ncruces/go-sqlite3 v0.35.6 + github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/pressly/goose/v3 v3.28.0 github.com/prometheus/client_golang v1.25.0 @@ -220,7 +221,6 @@ require ( github.com/ncruces/go-sqlite3-wasm/v6 v6.3.35304 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pb33f/go-yaml v0.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.2 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect diff --git a/apps/api/internal/gitenv/gitenv.go b/apps/api/internal/gitenv/gitenv.go new file mode 100644 index 00000000..c2a0a8c9 --- /dev/null +++ b/apps/api/internal/gitenv/gitenv.go @@ -0,0 +1,62 @@ +// Package gitenv names the commit a service was built from, for its build and its pods. +package gitenv + +import ( + "maps" + "slices" + "strings" + + "github.com/unbindapp/unbind-api/ent" + corev1 "k8s.io/api/core/v1" +) + +const ( + KeySHA = "UNBIND_GIT_SHA" + KeyBranch = "UNBIND_GIT_BRANCH" +) + +func IsKey(key string) bool { + return key == KeySHA || key == KeyBranch +} + +// Branch returns the branch of a bare branch name or a full ref, and nothing for a tag +func Branch(ref string) string { + if strings.HasPrefix(ref, "refs/tags/") { + return "" + } + return strings.TrimPrefix(ref, "refs/heads/") +} + +func Vars(commitSHA, ref string) map[string]string { + vars := map[string]string{} + if commitSHA != "" { + vars[KeySHA] = commitSHA + } + if branch := Branch(ref); branch != "" { + vars[KeyBranch] = branch + } + return vars +} + +func DeploymentVars(deployment *ent.Deployment) map[string]string { + var commitSHA, ref string + if deployment.CommitSha != nil { + commitSHA = *deployment.CommitSha + } + if deployment.GitBranch != nil { + ref = *deployment.GitBranch + } + return Vars(commitSHA, ref) +} + +// Append adds vars to envVars, replacing entries of the same name +func Append(envVars []corev1.EnvVar, vars map[string]string) []corev1.EnvVar { + merged := slices.DeleteFunc(slices.Clone(envVars), func(env corev1.EnvVar) bool { + _, replaced := vars[env.Name] + return replaced + }) + for _, name := range slices.Sorted(maps.Keys(vars)) { + merged = append(merged, corev1.EnvVar{Name: name, Value: vars[name]}) + } + return merged +} diff --git a/apps/api/internal/gitenv/gitenv_test.go b/apps/api/internal/gitenv/gitenv_test.go new file mode 100644 index 00000000..31201c4f --- /dev/null +++ b/apps/api/internal/gitenv/gitenv_test.go @@ -0,0 +1,52 @@ +package gitenv + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/unbindapp/unbind-api/ent" + corev1 "k8s.io/api/core/v1" +) + +func TestVars(t *testing.T) { + sha := "0123456789abcdef0123456789abcdef01234567" + tests := []struct { + name string + commitSHA string + ref string + want map[string]string + }{ + {"bare branch", sha, "main", map[string]string{KeySHA: sha, KeyBranch: "main"}}, + {"branch ref", sha, "refs/heads/feature/x", map[string]string{KeySHA: sha, KeyBranch: "feature/x"}}, + {"tag ref has no branch", sha, "refs/tags/v1.2.0", map[string]string{KeySHA: sha}}, + {"unknown commit", "", "refs/heads/main", map[string]string{KeyBranch: "main"}}, + {"nothing known", "", "", map[string]string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Vars(tt.commitSHA, tt.ref)) + }) + } +} + +func TestDeploymentVars(t *testing.T) { + assert.Equal(t, map[string]string{KeySHA: "abc", KeyBranch: "main"}, + DeploymentVars(&ent.Deployment{CommitSha: new("abc"), GitBranch: new("refs/heads/main")})) + assert.Empty(t, DeploymentVars(&ent.Deployment{})) +} + +func TestAppendReplacesSameName(t *testing.T) { + rendered := []corev1.EnvVar{ + {Name: "API_URL", Value: "http://api"}, + {Name: KeySHA, Value: "stale"}, + } + + got := Append(rendered, map[string]string{KeySHA: "abc", KeyBranch: "main"}) + + assert.Equal(t, []corev1.EnvVar{ + {Name: "API_URL", Value: "http://api"}, + {Name: KeyBranch, Value: "main"}, + {Name: KeySHA, Value: "abc"}, + }, got) + assert.Equal(t, "stale", rendered[1].Value) +} diff --git a/apps/api/internal/services/deployments/redeploy.go b/apps/api/internal/services/deployments/redeploy.go index 54a30f0b..449a9882 100644 --- a/apps/api/internal/services/deployments/redeploy.go +++ b/apps/api/internal/services/deployments/redeploy.go @@ -12,6 +12,7 @@ import ( "github.com/unbindapp/unbind-api/internal/common/errdefs" "github.com/unbindapp/unbind-api/internal/dbvolumes" "github.com/unbindapp/unbind-api/internal/deployctl" + "github.com/unbindapp/unbind-api/internal/gitenv" "github.com/unbindapp/unbind-api/internal/models" permissions_repo "github.com/unbindapp/unbind-api/internal/repositories/permissions" s3bucket_repo "github.com/unbindapp/unbind-api/internal/repositories/s3bucket" @@ -20,12 +21,12 @@ import ( corev1 "k8s.io/api/core/v1" ) -func (self *DeploymentService) resolveReferences(ctx context.Context, service *ent.Service) ([]corev1.EnvVar, error) { +func (self *DeploymentService) resolveEnv(ctx context.Context, service *ent.Service, deployment *ent.Deployment) ([]corev1.EnvVar, error) { rendered, err := self.variableService.RenderServiceVariables(ctx, service.ID) if err != nil { return nil, err } - return rendered.EnvVars(), nil + return gitenv.Append(rendered.EnvVars(), gitenv.DeploymentVars(deployment)), nil } func (self *DeploymentService) canRedeployWithoutBuild(ctx context.Context, service *ent.Service, deployment *ent.Deployment) bool { @@ -48,7 +49,7 @@ func (self *DeploymentService) canRedeployWithoutBuild(ctx context.Context, serv } func (self *DeploymentService) redeployExistingImage(ctx context.Context, service *ent.Service, deployment *ent.Deployment) (*models.DeploymentResponse, error) { - envVars, err := self.resolveReferences(ctx, service) + envVars, err := self.resolveEnv(ctx, service, deployment) if err != nil { return nil, err } diff --git a/apps/api/internal/services/deployments/remove.go b/apps/api/internal/services/deployments/remove.go index b2c260aa..00398198 100644 --- a/apps/api/internal/services/deployments/remove.go +++ b/apps/api/internal/services/deployments/remove.go @@ -41,7 +41,7 @@ func (self *DeploymentService) RemoveActiveDeployment(ctx context.Context, reque return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, "Deployment has no resource definition") } - envVars, err := self.resolveReferences(ctx, service) + envVars, err := self.resolveEnv(ctx, service, deployment) if err != nil { return nil, err } diff --git a/apps/api/internal/services/service/redeploy.go b/apps/api/internal/services/service/redeploy.go index fa56fe11..fe6183b5 100644 --- a/apps/api/internal/services/service/redeploy.go +++ b/apps/api/internal/services/service/redeploy.go @@ -14,6 +14,7 @@ import ( "github.com/unbindapp/unbind-api/internal/common/log" "github.com/unbindapp/unbind-api/internal/dbvolumes" "github.com/unbindapp/unbind-api/internal/deployctl" + "github.com/unbindapp/unbind-api/internal/gitenv" repository "github.com/unbindapp/unbind-api/internal/repositories" service_repo "github.com/unbindapp/unbind-api/internal/repositories/service" ) @@ -156,7 +157,7 @@ func (self *ServiceService) deployAdhocService(ctx context.Context, service *ent // Commit so the failed deployment stays visible return nil } - crdToDeploy.Spec.EnvVars = rendered.EnvVars() + crdToDeploy.Spec.EnvVars = gitenv.Append(rendered.EnvVars(), gitenv.DeploymentVars(newDeployment)) _, newService, err := self.k8s.DeployUnbindService(ctx, crdToDeploy) if err != nil { diff --git a/apps/api/internal/services/variables/write.go b/apps/api/internal/services/variables/write.go index 0c311ce1..c588fb88 100644 --- a/apps/api/internal/services/variables/write.go +++ b/apps/api/internal/services/variables/write.go @@ -12,6 +12,7 @@ import ( "github.com/unbindapp/unbind-api/ent/schema" "github.com/unbindapp/unbind-api/internal/common/errdefs" "github.com/unbindapp/unbind-api/internal/common/log" + "github.com/unbindapp/unbind-api/internal/gitenv" "github.com/unbindapp/unbind-api/internal/models" repository "github.com/unbindapp/unbind-api/internal/repositories" permissions_repo "github.com/unbindapp/unbind-api/internal/repositories/permissions" @@ -114,7 +115,7 @@ func (self *VariablesService) PrepareVariableWrite( // would be shadowed and never read. A value stored under one of these names before // they were reserved keeps working, or the raw editor could never save again. for name, value := range upserts { - if _, stored := existing[name]; !stored && isProvidedKey(name) { + if _, stored := existing[name]; !stored && (isProvidedKey(name) || gitenv.IsKey(name)) { return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, fmt.Sprintf("%s is provided by Unbind and cannot be set", name)) } diff --git a/apps/api/pkg/builder/builders/builders.go b/apps/api/pkg/builder/builders/builders.go index 577b3775..d52c3b8d 100644 --- a/apps/api/pkg/builder/builders/builders.go +++ b/apps/api/pkg/builder/builders/builders.go @@ -11,6 +11,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/unbindapp/unbind-api/internal/common/log" "github.com/unbindapp/unbind-api/internal/common/utils" + "github.com/unbindapp/unbind-api/internal/gitenv" "github.com/unbindapp/unbind-api/internal/sourceanalyzer" "github.com/unbindapp/unbind-api/pkg/builder/config" ) @@ -18,6 +19,7 @@ import ( type Builder struct { config *config.Config analysisResult *sourceanalyzer.AnalysisResult + gitEnv map[string]string } func NewBuilder(config *config.Config) *Builder { @@ -32,6 +34,11 @@ func (self *Builder) AnalysisResult() *sourceanalyzer.AnalysisResult { return self.analysisResult } +// GitEnv returns the git variables of the last build, or nil if it wasn't run +func (self *Builder) GitEnv() map[string]string { + return self.gitEnv +} + // analyzeSource detects provider/framework from the build's clone, best-effort: // a detection failure never fails a build that already succeeded. func (self *Builder) analyzeSource(tmpDir string) { @@ -53,6 +60,7 @@ func (self *Builder) analyzeSource(tmpDir string) { // same image, so they share it. type BuildInputs struct { CommitSHA string + GitBranch string Builder string DockerfilePath string BuildContext string @@ -80,9 +88,14 @@ func (in BuildInputs) configDigest() string { in.BuildCommand, in.RunCommand, in.SecretsHash, + in.GitBranch, ) } +func (in BuildInputs) gitEnv() map[string]string { + return gitenv.Vars(in.CommitSHA, in.GitBranch) +} + // cacheDigest keys the registry cache per service and build config. Build env is left // out: BuildKit already reruns only the steps that read a changed value, so keying on // it threw away every cached layer on a variable edit. @@ -167,6 +180,7 @@ func (self *Builder) imageRepository() string { func (self *Builder) buildInputs(repoDir string, buildSecrets map[string]string) BuildInputs { return BuildInputs{ CommitSHA: self.resolveCommitSHA(repoDir), + GitBranch: gitenv.Branch(self.config.GitRef), Builder: string(self.config.ServiceBuilder), DockerfilePath: self.config.ServiceDockerBuilderDockerfilePath, BuildContext: self.config.ServiceDockerBuilderBuildContext, diff --git a/apps/api/pkg/builder/builders/builders_test.go b/apps/api/pkg/builder/builders/builders_test.go index 7a8dd346..266eff6b 100644 --- a/apps/api/pkg/builder/builders/builders_test.go +++ b/apps/api/pkg/builder/builders/builders_test.go @@ -124,6 +124,29 @@ func TestGenerateBuildMetadataCommitChangesImageNotCache(t *testing.T) { } } +// The branch reaches the build as UNBIND_GIT_BRANCH, so two branches at one commit +// can bake different output and must not share an image. +func TestGenerateBuildMetadataBranchChangesImageNotCache(t *testing.T) { + b := testBuilder() + main := baseInputs() + main.GitBranch = "main" + mainImage, mainCache := b.GenerateBuildMetadata(main) + + staging := baseInputs() + staging.GitBranch = "staging" + image, cache := b.GenerateBuildMetadata(staging) + + if image == mainImage { + t.Errorf("two branches shared image ref %q", image) + } + if cache != mainCache { + t.Errorf("a branch change moved the cache ref: %q vs %q", cache, mainCache) + } + if b.RailpackCacheMountKey(staging) == b.RailpackCacheMountKey(main) { + t.Errorf("a branch change kept Railpack's cache mount key %q", b.RailpackCacheMountKey(staging)) + } +} + // With no commit to identify the build we can't claim two builds are the same one, // so the tag falls back to being unique per deployment rather than shared. func TestGenerateBuildMetadataUnknownCommitStaysUnique(t *testing.T) { diff --git a/apps/api/pkg/builder/builders/docker_file.go b/apps/api/pkg/builder/builders/docker_file.go index e6ef6722..dea5857a 100644 --- a/apps/api/pkg/builder/builders/docker_file.go +++ b/apps/api/pkg/builder/builders/docker_file.go @@ -3,6 +3,7 @@ package builders import ( "context" "fmt" + "maps" "os" "path" @@ -58,6 +59,7 @@ func (self *Builder) BuildDockerfile(ctx context.Context, buildSecrets map[strin // explicit "Dockerfile" hash to the same build. inputs := self.buildInputs(tmpDir, buildSecrets) outputImage, cacheKey := self.GenerateBuildMetadata(inputs) + self.gitEnv = inputs.gitEnv() // Build using BuildKit err = buildkit.BuildWithBuildkitClient( @@ -66,7 +68,7 @@ func (self *Builder) BuildDockerfile(ctx context.Context, buildSecrets map[strin buildkit.BuildWithBuildkitClientOptions{ ImageName: outputImage, CacheKey: cacheKey, - Secrets: buildSecrets, + Secrets: withGitEnv(buildSecrets, self.gitEnv), DockerfilePath: self.config.ServiceDockerBuilderDockerfilePath, ContextPath: self.config.ServiceDockerBuilderBuildContext, }, @@ -80,3 +82,11 @@ func (self *Builder) BuildDockerfile(ctx context.Context, buildSecrets map[strin log.Infof("Built image %s from Dockerfile: %s", outputImage, self.config.ServiceDockerBuilderDockerfilePath) return outputImage, repoName, nil } + +// withGitEnv adds the git variables to what the Dockerfile can declare as ARGs +func withGitEnv(buildSecrets map[string]string, gitEnv map[string]string) map[string]string { + merged := make(map[string]string, len(buildSecrets)+len(gitEnv)) + maps.Copy(merged, buildSecrets) + maps.Copy(merged, gitEnv) + return merged +} diff --git a/apps/api/pkg/builder/builders/railpack.go b/apps/api/pkg/builder/builders/railpack.go index 36b0739b..f131aca6 100644 --- a/apps/api/pkg/builder/builders/railpack.go +++ b/apps/api/pkg/builder/builders/railpack.go @@ -3,10 +3,13 @@ package builders import ( "context" "fmt" + "maps" "os" + "slices" "github.com/railwayapp/railpack/core" a "github.com/railwayapp/railpack/core/app" + "github.com/railwayapp/railpack/core/plan" "github.com/unbindapp/unbind-api/internal/common/log" "github.com/unbindapp/unbind-api/internal/integrations/github" "github.com/unbindapp/unbind-api/pkg/builder/internal/buildkit" @@ -51,6 +54,8 @@ func (self *Builder) BuildWithRailpack(ctx context.Context, buildSecrets map[str // Metadata, resolved from the clone so the image ref covers the built commit inputs := self.buildInputs(tmpDir, buildSecrets) outputImage, cacheKey := self.GenerateBuildMetadata(inputs) + self.gitEnv = inputs.gitEnv() + addBuildStepVariables(buildResult.Plan, self.gitEnv) err = buildkit.BuildWithBuildkitClient( self.config, @@ -106,3 +111,20 @@ func GenerateBuildResult(directory string, buildSecrets map[string]string, runCo return buildResult, app, &env, nil } + +// addBuildStepVariables sets vars on the build step only. Every step reads every +// secret, so passing a per-commit value as one would rerun the install steps on +// every commit, while the build step copies the source and reruns anyway. +func addBuildStepVariables(buildPlan *plan.BuildPlan, vars map[string]string) { + index := slices.IndexFunc(buildPlan.Steps, func(step plan.Step) bool { + return step.Name == "build" + }) + if index < 0 { + return + } + step := &buildPlan.Steps[index] + if step.Variables == nil { + step.Variables = map[string]string{} + } + maps.Copy(step.Variables, vars) +} diff --git a/apps/api/pkg/builder/builders/railpack_test.go b/apps/api/pkg/builder/builders/railpack_test.go new file mode 100644 index 00000000..5ae2167e --- /dev/null +++ b/apps/api/pkg/builder/builders/railpack_test.go @@ -0,0 +1,116 @@ +package builders + +import ( + "context" + "strings" + "testing" + + "github.com/containerd/platforms" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/solver/pb" + "github.com/opencontainers/go-digest" + rpBuildkit "github.com/railwayapp/railpack/buildkit" + "github.com/railwayapp/railpack/core/plan" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unbindapp/unbind-api/internal/gitenv" + "google.golang.org/protobuf/proto" +) + +// A new commit has to reach the build step without rerunning install +func TestAddBuildStepVariablesKeepsInstallCached(t *testing.T) { + first := nodePlan() + addBuildStepVariables(first, gitenv.Vars("1111111111111111111111111111111111111111", "main")) + second := nodePlan() + addBuildStepVariables(second, gitenv.Vars("2222222222222222222222222222222222222222", "main")) + + firstInstall, firstBuild := execOps(t, first) + secondInstall, secondBuild := execOps(t, second) + + assert.Equal(t, firstInstall.digest, secondInstall.digest, "install step changed with the commit") + assert.NotEqual(t, firstBuild.digest, secondBuild.digest, "build step did not change with the commit") + assert.Contains(t, secondBuild.env, gitenv.KeySHA+"=2222222222222222222222222222222222222222") + assert.Contains(t, secondBuild.env, gitenv.KeyBranch+"=main") + assert.Equal(t, "1", second.Steps[1].Variables["NEXT_TELEMETRY_DISABLED"]) +} + +func TestAddBuildStepVariablesWithoutBuildStep(t *testing.T) { + buildPlan := nodePlan() + buildPlan.Steps = buildPlan.Steps[:1] + + addBuildStepVariables(buildPlan, gitenv.Vars("abc", "main")) + + assert.Empty(t, buildPlan.Steps[0].Variables) +} + +func nodePlan() *plan.BuildPlan { + buildPlan := plan.NewBuildPlan() + + install := plan.NewStep("install") + install.Inputs = []plan.Layer{plan.NewImageLayer("node:22")} + install.Commands = []plan.Command{plan.NewCopyCommand("package.json"), plan.NewExecCommand("npm ci")} + + build := plan.NewStep("build") + build.Inputs = []plan.Layer{plan.NewStepLayer("install"), plan.NewLocalLayer()} + build.Variables["NEXT_TELEMETRY_DISABLED"] = "1" + build.Commands = []plan.Command{plan.NewExecCommand("npm run build")} + + buildPlan.Steps = []plan.Step{*install, *build} + buildPlan.Deploy.Base = plan.NewImageLayer("node:22") + buildPlan.Deploy.Inputs = []plan.Layer{plan.NewStepLayer("build", plan.NewIncludeFilter([]string{"."}))} + return buildPlan +} + +type execOp struct { + digest digest.Digest + env []string +} + +func execOps(t *testing.T, buildPlan *plan.BuildPlan) (install, build *execOp) { + state, _, err := rpBuildkit.ConvertPlanToLLB(buildPlan, rpBuildkit.ConvertPlanOptions{ + BuildPlatform: platforms.DefaultSpec(), + SecretsHash: "secrets", + }) + require.NoError(t, err) + def, err := state.Marshal(context.Background(), llb.LinuxAmd64) + require.NoError(t, err) + + ops := map[digest.Digest]*pb.Op{} + for _, dt := range def.Def { + var op pb.Op + require.NoError(t, proto.Unmarshal(dt, &op)) + ops[digest.FromBytes(dt)] = &op + } + + for dgst, op := range ops { + exec := op.GetExec() + if exec == nil { + continue + } + found := &execOp{digest: stableDigest(t, ops, dgst), env: exec.Meta.Env} + switch strings.Join(exec.Meta.Args, " ") { + case "npm ci": + install = found + case "npm run build": + build = found + } + } + require.NotNil(t, install) + require.NotNil(t, build) + return install, build +} + +// stableDigest hashes an op and its inputs without local.unique, which is random per +// conversion and not part of BuildKit's local source cache key (source/local/source.go). +func stableDigest(t *testing.T, ops map[digest.Digest]*pb.Op, dgst digest.Digest) digest.Digest { + op := proto.Clone(ops[dgst]).(*pb.Op) + if source := op.GetSource(); source != nil { + delete(source.Attrs, "local.unique") + } + for _, input := range op.Inputs { + input.Digest = string(stableDigest(t, ops, digest.Digest(input.Digest))) + } + dt, err := proto.MarshalOptions{Deterministic: true}.Marshal(op) + require.NoError(t, err) + return digest.FromBytes(dt) +} diff --git a/apps/docs/content/(platform)/services/builds.mdx b/apps/docs/content/(platform)/services/builds.mdx index 6ba72394..8c7b3a04 100644 --- a/apps/docs/content/(platform)/services/builds.mdx +++ b/apps/docs/content/(platform)/services/builds.mdx @@ -42,6 +42,8 @@ ARG VITE_API_URL RUN npm run build ``` +The same goes for `UNBIND_GIT_SHA` and `UNBIND_GIT_BRANCH`, the commit being built. See [Git commit](/services/variables#git-commit). + ## The build queue Builds run on your servers, and by default two run at a time. The rest wait in a queue. While a service has a build waiting, a newer one for the same service replaces it, so only the latest push gets built. A newer build also stops one that is already running, and starts once it has stopped. diff --git a/apps/docs/content/(platform)/services/variables.mdx b/apps/docs/content/(platform)/services/variables.mdx index 35667c7d..c270434d 100644 --- a/apps/docs/content/(platform)/services/variables.mdx +++ b/apps/docs/content/(platform)/services/variables.mdx @@ -70,6 +70,17 @@ Set these under **Settings** > **Variables** of a team or a project. They are a Some variables are managed by Unbind and show a lock, like a database's username and password. They can be read and referenced, but not changed or deleted. +## Git commit + +A service built from GitHub also gets the commit it was built from, while it builds and while it runs. Use it to show a version or to tag error reports. + +| Key | What it holds | +| ------------------- | --------------------------------------------- | +| `UNBIND_GIT_SHA` | The full commit SHA | +| `UNBIND_GIT_BRANCH` | The branch, not set when it builds from a tag | + +They are set by Unbind and can't be set as variables. + ## Who can see values People with the viewer role see variable names but not values. Editors and admins see both.