package cnb import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/eapache/channels" "github.com/goodrain/rainbond/builder" "github.com/goodrain/rainbond/builder/build" "github.com/goodrain/rainbond/builder/parser/code" "github.com/goodrain/rainbond/builder/sources" "github.com/goodrain/rainbond/util" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // runCNBBuildJob creates and runs the CNB build pod func (b *Builder) runCNBBuildJob(re *build.Request, buildImageName string) error { name := fmt.Sprintf("%s-%s", re.ServiceID, re.DeployVersion) namespace := re.RbdNamespace cnbBuilderImage := GetCNBBuilderImageForLanguage(re.Lang) cnbRunImage := GetCNBRunImageForLanguage(re.Lang) bindings, err := b.resolvePlatformBindings(re) if err != nil { return err } podSpec := corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, } // Set node affinity for architecture and hostname nodeSelectors := []corev1.NodeSelectorRequirement{ { Key: "kubernetes.io/arch", Operator: corev1.NodeSelectorOpIn, Values: []string{re.Arch}, }, } if hostIP := os.Getenv("HOST_IP"); hostIP != "" { nodeSelectors = append(nodeSelectors, corev1.NodeSelectorRequirement{ Key: "kubernetes.io/hostname", Operator: corev1.NodeSelectorOpIn, Values: []string{hostIP}, }) } podSpec.Affinity = &corev1.Affinity{ NodeAffinity: &corev1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ NodeSelectorTerms: []corev1.NodeSelectorTerm{{ MatchExpressions: nodeSelectors, }}, }, }, } podSpec.Tolerations = []corev1.Toleration{{Operator: "Exists"}} rootUser := int64(0) podSpec.SecurityContext = &corev1.PodSecurityContext{ RunAsUser: &rootUser, RunAsGroup: &rootUser, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() secret, err := b.createAuthSecret(re) if err != nil { return err } defer b.deleteAuthSecret(re, secret.Name) imageDomain, buildKitTomlCMName := sources.GetImageFirstPart(builder.REGISTRYDOMAIN) err = b.prepareBuildKit(ctx, re.KubeClient, re.RbdNamespace, buildKitTomlCMName, imageDomain) if err != nil { return err } procfileBinding, cleanupProcfileBinding, err := b.createProcfileBinding(ctx, re, name) if err != nil { return err } if cleanupProcfileBinding != nil { defer cleanupProcfileBinding() } if procfileBinding != nil { bindings = append(bindings, *procfileBinding) } // Compute platform annotations after bindings so derived BP_* values are included. annotations := b.buildPlatformAnnotations(re) for _, binding := range bindings { annotations[bindingTypeAnnotationKey(binding.Name)] = binding.Type } job := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Labels: map[string]string{ "service": re.ServiceID, "job": "codebuild", "build-type": "cnb", }, Annotations: annotations, }, } volumes, mounts := b.createVolumeAndMount(re, secret.Name) platformVolume, platformMount := b.createPlatformVolume(annotations, bindings) if platformVolume != nil { volumes = append(volumes, *platformVolume) mounts = append(mounts, *platformMount) } podSpec.Volumes = volumes creatorArgs := b.buildCreatorArgs(re, buildImageName, cnbRunImage) // Chown workspace to cnb user inside the builder image (where cnb user exists with correct UID), // then exec the lifecycle creator. This ensures buildpacks can chmod generated files. container := corev1.Container{ Name: name, Image: cnbBuilderImage, ImagePullPolicy: corev1.PullIfNotPresent, Stdin: true, StdinOnce: true, Command: []string{"sh", "-c", `chown -R cnb:cnb /workspace && exec "$@"`, "--"}, Args: append([]string{CNBLifecycleCreatorPath}, creatorArgs...), Env: b.buildEnvVars(re), VolumeMounts: mounts, } podSpec.Containers = append(podSpec.Containers, container) podSpec.HostAliases = append(podSpec.HostAliases, build.NormalizeHostAliases(re.HostAlias)...) job.Spec = podSpec writer := newCNBPhaseWriter(re.Logger.GetWriter("builder", "info"), re.Logger) defer writer.finish() reChan := channels.NewRingChannel(10) logrus.Debugf("create CNB job[name: %s; namespace: %s]", job.Name, job.Namespace) err = b.jobCtrl.ExecJob(ctx, &job, writer, reChan) if err != nil { logrus.Errorf("create CNB job %s failed: %s", name, err.Error()) re.Logger.Error(util.Translation("Create CNB build job failed"), map[string]string{"step": "builder-exector", "status": "failure"}) return err } re.Logger.Info(util.Translation("create CNB build job success"), map[string]string{"step": "build-exector"}) defer b.jobCtrl.DeleteJob(job.Name) err = b.waitingComplete(re, reChan) writer.finish() return err } // buildCreatorArgs builds the lifecycle creator arguments func (b *Builder) buildCreatorArgs(re *build.Request, buildImageName, runImage string) []string { registryHost, _ := sources.GetImageFirstPart(builder.REGISTRYDOMAIN) latestImage := stableImageTag(buildImageName, "latest") logLevel := "info" if v := re.BuildEnvs["CNB_LOG_LEVEL"]; v != "" { logLevel = v } args := []string{ "-app=/workspace", "-layers=/layers", "-platform=/platform", "-run-image=" + runImage, "-tag=" + latestImage, "-insecure-registry=" + registryHost, "-log-level=" + logLevel, } policy := resolveCNBCachePolicy(re) policy.log(re) if !policy.reuseImage { args = append(args, "-skip-restore") } else { args = append(args, "-previous-image="+latestImage) if policy.registryCache { args = append(args, "-cache-image="+stableImageTag(buildImageName, "cnb-cache"), "-parallel") } } // Custom order from language config (e.g., pure static projects need nginx-only order) lang := getLanguageConfig(re) if bps := lang.CustomOrder(re); len(bps) > 0 { if flag := b.writeCustomOrder(re, bps, "custom language order"); flag != "" { args = append(args, flag) } } args = append(args, buildImageName) return args } // buildEnvVars builds environment variables for the CNB build container. func (b *Builder) buildEnvVars(re *build.Request) []corev1.EnvVar { registryHost, _ := sources.GetImageFirstPart(builder.REGISTRYDOMAIN) langLocale := resolveBuildLocale(re, "LANG", "C.UTF-8") lcAllLocale := resolveBuildLocale(re, "LC_ALL", langLocale) envs := []corev1.EnvVar{ {Name: "CNB_PLATFORM_API", Value: "0.13"}, {Name: "CNB_INSECURE_REGISTRIES", Value: registryHost}, {Name: "DOCKER_CONFIG", Value: "/home/cnb/.docker"}, // Keep lifecycle and buildpack tools on UTF-8 so non-ASCII resource names survive packaging. {Name: "LANG", Value: langLocale}, {Name: "LC_ALL", Value: lcAllLocale}, } if httpProxy := os.Getenv("HTTP_PROXY"); httpProxy != "" { envs = append(envs, corev1.EnvVar{Name: "HTTP_PROXY", Value: httpProxy}) } if httpsProxy := os.Getenv("HTTPS_PROXY"); httpsProxy != "" { envs = append(envs, corev1.EnvVar{Name: "HTTPS_PROXY", Value: httpsProxy}) } if noProxy := os.Getenv("NO_PROXY"); noProxy != "" { envs = append(envs, corev1.EnvVar{Name: "NO_PROXY", Value: noProxy}) } lang := getLanguageConfig(re) envs = append(envs, lang.BuildEnvVars(re)...) return envs } func (b *Builder) resolvePlatformBindings(re *build.Request) ([]platformBinding, error) { ctx := re.Ctx if ctx == nil { ctx = context.Background() } switch re.Lang { case code.JavaMaven: explicitName := strings.TrimSpace(firstNonEmptyEnv(re.BuildEnvs, "BUILD_MAVEN_SETTING_NAME", "MAVEN_SETTING_NAME")) configMapName := "" if explicitName != "" { configMapName = b.jobCtrl.GetLanguageBuildSetting(ctx, code.JavaMaven, explicitName) if configMapName == "" { return nil, fmt.Errorf("maven setting config %s not found", explicitName) } } else { configMapName = b.jobCtrl.GetDefaultLanguageBuildSetting(ctx, code.JavaMaven) } if configMapName == "" { return nil, nil } re.BuildEnvs["BP_MAVEN_SETTINGS_PATH"] = fmt.Sprintf("/platform/bindings/%s/settings.xml", configMapName) return []platformBinding{{ Name: configMapName, Type: "maven", ConfigMapName: configMapName, ConfigMapKey: "mavensetting", TargetFile: "settings.xml", }}, nil case code.NetCore: configMapName := strings.TrimSpace(firstNonEmptyEnv(re.BuildEnvs, "BUILD_NUGET_CONFIG_NAME")) if configMapName == "" { return nil, nil } if resolved := b.jobCtrl.GetLanguageBuildSetting(ctx, code.NetCore, configMapName); resolved == "" { return nil, fmt.Errorf("nuget config %s not found", configMapName) } return []platformBinding{{ Name: configMapName, Type: "nugetconfig", ConfigMapName: configMapName, ConfigMapKey: "nuget.config", TargetFile: "nuget.config", }}, nil default: return nil, nil } } func (b *Builder) createProcfileBinding(ctx context.Context, re *build.Request, jobName string) (*platformBinding, func(), error) { procfile, _ := resolveProcfileBindingContent(re) if strings.TrimSpace(procfile) == "" { return nil, nil, nil } if b.createConfigMap == nil || b.deleteConfigMap == nil { return nil, nil, fmt.Errorf("procfile binding handlers are not configured") } if re.KubeClient == nil && b.createConfigMap == nil { return nil, nil, fmt.Errorf("kube client is required for procfile binding") } configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ GenerateName: fmt.Sprintf("%s-procfile-", strings.ToLower(jobName)), Namespace: re.RbdNamespace, Labels: map[string]string{ "service": re.ServiceID, "job": "codebuild", "build-type": "cnb", }, }, Data: map[string]string{ "Procfile": normalizeProcfileBindingContent(procfile), }, } createdConfigMap, err := b.createConfigMap(ctx, re.KubeClient, re.RbdNamespace, configMap) if err != nil { return nil, nil, err } cleanup := func() { if err := b.deleteConfigMap(ctx, re.KubeClient, re.RbdNamespace, createdConfigMap.Name); err != nil { logrus.Warnf("delete procfile binding configmap %s failed: %v", createdConfigMap.Name, err) } } return &platformBinding{ Name: createdConfigMap.Name, Type: "Procfile", ConfigMapName: createdConfigMap.Name, ConfigMapKey: "Procfile", TargetFile: "Procfile", }, cleanup, nil } // createVolumeAndMount creates volumes and volume mounts for the CNB build pod func (b *Builder) createVolumeAndMount(re *build.Request, secretName string) ([]corev1.Volume, []corev1.VolumeMount) { hostPathType := corev1.HostPathDirectoryOrCreate hostPathDirectoryType := corev1.HostPathDirectory volumes := []corev1.Volume{ { Name: "workspace", VolumeSource: corev1.VolumeSource{ HostPath: &corev1.HostPathVolumeSource{ Path: filepath.Join("/opt/rainbond/", re.SourceDir), Type: &hostPathDirectoryType, }, }, }, { Name: "layers", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, { Name: "grdata", VolumeSource: corev1.VolumeSource{ HostPath: &corev1.HostPathVolumeSource{ Path: "/opt/rainbond/grdata", Type: &hostPathType, }, }, }, { Name: "docker-config", VolumeSource: corev1.VolumeSource{ Secret: &corev1.SecretVolumeSource{ SecretName: secretName, Items: []corev1.KeyToPath{ {Key: ".dockerconfigjson", Path: "config.json"}, }, }, }, }, } volumeMounts := []corev1.VolumeMount{ {Name: "workspace", MountPath: "/workspace"}, {Name: "layers", MountPath: "/layers"}, {Name: "grdata", MountPath: "/grdata"}, {Name: "docker-config", MountPath: "/home/cnb/.docker"}, } return volumes, volumeMounts } func resolveProcfileBindingContent(re *build.Request) (string, string) { if procfile := re.BuildEnvs["BUILD_PROCFILE"]; strings.TrimSpace(procfile) != "" { source := strings.TrimSpace(re.BuildEnvs["START_COMMAND_SOURCE"]) if source == "" { source = "procfile" } return procfile, source } if procfile := re.BuildEnvs["BUILD_AUTO_PROCFILE"]; strings.TrimSpace(procfile) != "" { source := strings.TrimSpace(re.BuildEnvs["START_COMMAND_SOURCE"]) if source == "" { source = "auto-detected" } return procfile, source } if re.SourceDir == "" { return "", "" } body, err := os.ReadFile(filepath.Join(re.SourceDir, "Procfile")) if err != nil || strings.TrimSpace(string(body)) == "" { return "", "" } return string(body), "procfile" } func normalizeProcfileBindingContent(procfile string) string { procfile = strings.TrimRight(procfile, "\r\n") return procfile + "\n" } // waitingComplete waits for the build job to complete func (b *Builder) waitingComplete(re *build.Request, reChan *channels.RingChannel) error { logComplete := false jobComplete := false var err error timeout := time.NewTimer(time.Minute * 60) defer timeout.Stop() for { select { case <-timeout.C: return fmt.Errorf("cnb build timed out (exceeded 60 minutes)") case jobStatus := <-reChan.Out(): status, ok := jobStatus.(string) if !ok { logrus.Warnf("unexpected job status type: %T", jobStatus) continue } switch status { case "complete": jobComplete = true if logComplete { return nil } re.Logger.Info(util.Translation("CNB build job completed"), map[string]string{"step": "build-exector"}) case "failed": jobComplete = true err = fmt.Errorf("cnb build job exec failure") if logComplete { return err } re.Logger.Info(util.Translation("CNB build job failed"), map[string]string{"step": "build-exector"}) case "cancel": jobComplete = true err = fmt.Errorf("cnb build job is canceled") if logComplete { return err } case "logcomplete": logComplete = true if jobComplete { return err } } } } } // stableImageTag replaces the tag portion of an image reference. // e.g. "goodrain.me/workload:20260220215515" + "latest" → "goodrain.me/workload:latest" func stableImageTag(imageName, tag string) string { if i := strings.LastIndex(imageName, ":"); i != -1 { return imageName[:i] + ":" + tag } return imageName + ":" + tag }