Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/api/cmd/builder/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)

Expand All @@ -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
}
12 changes: 12 additions & 0 deletions apps/api/cmd/builder/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 3 additions & 1 deletion apps/api/cmd/builder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/unbindapp/unbind-api/internal/infrastructure/registrycache"
"maps"
"os"
"time"

Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions apps/api/internal/gitenv/gitenv.go
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions apps/api/internal/gitenv/gitenv_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 4 additions & 3 deletions apps/api/internal/services/deployments/redeploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/internal/services/deployments/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion apps/api/internal/services/service/redeploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/internal/services/variables/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
Expand Down
14 changes: 14 additions & 0 deletions apps/api/pkg/builder/builders/builders.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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"
)

type Builder struct {
config *config.Config
analysisResult *sourceanalyzer.AnalysisResult
gitEnv map[string]string
}

func NewBuilder(config *config.Config) *Builder {
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions apps/api/pkg/builder/builders/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading