buildkit/build_llb/build_graph.go
buildkit/build_llb/build_graph.goBrowse 1970 files
3,646 tokens
13,365 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1// Converts the internal build plan graph to a BuildKit LLB2 3package build_llb4 5import (6 "fmt"7 "maps"8 "os"9 "path/filepath"10 "slices"11 "strings"12 13 "github.com/moby/buildkit/client/llb"14 "github.com/moby/buildkit/util/system"15 specs "github.com/opencontainers/image-spec/specs-go/v1"16 "github.com/railwayapp/railpack/buildkit/graph"17 "github.com/railwayapp/railpack/core/generate"18 "github.com/railwayapp/railpack/core/plan"19)20 21const githubTokenEnvVar = "GITHUB_TOKEN"22 23type BuildGraph struct {24 graph *graph.Graph25 CacheStore *BuildKitCacheStore26 Plan *plan.BuildPlan27 Platform *specs.Platform28 LocalState *llb.State29 NoCache bool30 31 githubToken string32 secretsFile *llb.State33 usedSecretsBase *llb.State34}35 36type BuildGraphOutput struct {37 State *llb.State38 GraphEnv BuildEnvironment39}40 41func NewBuildGraph(plan *plan.BuildPlan, localState *llb.State, cacheStore *BuildKitCacheStore, secretsHash string, platform *specs.Platform, githubToken string, noCache bool) (*BuildGraph, error) {42 var secretsFile *llb.State43 if secretsHash != "" {44 st := llb.Scratch().File(llb.Mkfile("/secrets-hash", 0644, []byte(secretsHash)), llb.WithCustomName("[railpack] secrets hash"))45 secretsFile = &st46 }47 usedSecretsBase := llb.Image("alpine:latest", llb.WithCustomName("[railpack] loading secrets"))48 49 g := &BuildGraph{50 graph: graph.NewGraph(),51 CacheStore: cacheStore,52 Plan: plan,53 Platform: platform,54 LocalState: localState,55 NoCache: noCache,56 57 githubToken: githubToken,58 secretsFile: secretsFile,59 usedSecretsBase: &usedSecretsBase,60 }61 62 // Create a node for each step63 for i := range plan.Steps {64 step := &plan.Steps[i]65 node := &StepNode{66 Step: step,67 Processed: false,68 OutputEnv: NewGraphEnvironment(),69 }70 71 g.graph.AddNode(node)72 }73 74 // Add dependencies to each node75 for _, node := range g.graph.GetNodes() {76 llbNode := node.(*StepNode)77 for _, input := range llbNode.Step.Inputs {78 // This input does not reference another step79 if input.Step == "" {80 continue81 }82 83 if depNode, exists := g.graph.GetNode(input.Step); exists {84 // Create edges between the current node and the dependency node85 parents := llbNode.GetParents()86 parents = append(parents, depNode)87 llbNode.SetParents(parents)88 89 children := depNode.GetChildren()90 children = append(children, llbNode)91 depNode.SetChildren(children)92 }93 }94 }95 96 g.graph.ComputeTransitiveDependencies()97 98 // g.graph.PrintGraph()99 100 return g, nil101}102 103// generate the LLB state for the build graph104func (g *BuildGraph) GenerateLLB() (*BuildGraphOutput, error) {105 // Get processing order using topological sort106 order, err := g.graph.ComputeProcessingOrder()107 if err != nil {108 return nil, err109 }110 111 // Process all nodes in order112 for _, node := range order {113 llbNode := node.(*StepNode)114 if err := g.processNode(llbNode); err != nil {115 return nil, err116 }117 }118 119 // Process deploy state120 deployInputs := append([]plan.Layer{g.Plan.Deploy.Base}, g.Plan.Deploy.Inputs...)121 deployState := g.GetFullStateFromLayers(deployInputs)122 123 graphEnv := NewGraphEnvironment()124 for _, input := range g.Plan.Deploy.Inputs {125 if node, exists := g.graph.GetNode(input.Step); exists {126 graphEnv.Merge(node.(*StepNode).OutputEnv)127 }128 }129 130 return &BuildGraphOutput{131 State: &deployState,132 GraphEnv: graphEnv,133 }, nil134}135 136// processNode processes a node and its parents to determine the state to build upon137func (g *BuildGraph) processNode(node *StepNode) error {138 // If already processed, we're done139 if node.Processed {140 return nil141 }142 143 // Check if all parents are processed144 for _, parent := range node.GetParents() {145 parentNode := parent.(*StepNode)146 if !parentNode.Processed {147 // If this node is marked in-progress, we have a dependency violation148 if node.InProgress {149 return fmt.Errorf("dependency violation: %s waiting for unprocessed parent %s",150 node.Step.Name, parentNode.Step.Name)151 }152 153 // Mark this node as in-progress and process the parent154 node.InProgress = true155 if err := g.processNode(parentNode); err != nil {156 node.InProgress = false157 return err158 }159 node.InProgress = false160 }161 }162 163 // Determine the state to build upon164 // var currentState llb.State165 currentGraphEnv := NewGraphEnvironment()166 167 // Merge the output envs of all the parent nodes168 for _, parent := range node.GetParents() {169 parentNode := parent.(*StepNode)170 currentGraphEnv.Merge(parentNode.OutputEnv)171 }172 173 node.InputEnv = currentGraphEnv174 175 // Convert this node's step to LLB176 stepState, err := g.convertNodeToLLB(node)177 if err != nil {178 return err179 }180 181 node.State = stepState182 node.Processed = true183 184 return nil185}186 187// converts a step node to an LLB state188func (g *BuildGraph) convertNodeToLLB(node *StepNode) (*llb.State, error) {189 state, err := g.getNodeStartingState(node)190 if err != nil {191 return nil, err192 }193 194 // Process the step commands195 if len(node.Step.Commands) > 0 {196 for _, cmd := range node.Step.Commands {197 var err error198 state, err = g.convertCommandToLLB(node, cmd, state, node.Step)199 if err != nil {200 return nil, err201 }202 }203 }204 205 return &state, nil206}207 208// Adds the input environment to the base state of the node209// This includes things like the environment variables and accumulated paths210func (g *BuildGraph) getNodeStartingState(node *StepNode) (llb.State, error) {211 state := g.GetFullStateFromLayers(node.Step.Inputs).Dir("/app")212 213 envVars := make(map[string]string)214 215 // Collect all environment variables first216 for k, v := range node.InputEnv.EnvVars {217 envVars[k] = v218 node.OutputEnv.AddEnvVar(k, v)219 }220 for k, v := range node.Step.Variables {221 envVars[k] = v222 node.OutputEnv.AddEnvVar(k, v)223 }224 225 for _, k := range slices.Sorted(maps.Keys(envVars)) {226 state = state.AddEnv(k, envVars[k])227 }228 229 if len(node.InputEnv.PathList) > 0 {230 pathString := strings.Join(node.InputEnv.PathList, ":")231 state = state.AddEnvf("PATH", "%s:%s", pathString, system.DefaultPathEnvUnix)232 node.OutputEnv.PathList = append(node.OutputEnv.PathList, node.InputEnv.PathList...)233 }234 235 return state, nil236}237 238func (g *BuildGraph) convertCommandToLLB(node *StepNode, cmd plan.Command, state llb.State, step *plan.Step) (llb.State, error) {239 switch cmd := cmd.(type) {240 case plan.ExecCommand:241 return g.convertExecCommandToLLB(node, cmd, state)242 case plan.PathCommand:243 return g.convertPathCommandToLLB(node, cmd, state)244 case plan.CopyCommand:245 return g.convertCopyCommandToLLB(cmd, state)246 case plan.FileCommand:247 return g.convertFileCommandToLLB(cmd, state, step)248 }249 return state, nil250}251 252// convertExecCommandToLLB converts an exec command to an LLB state253func (g *BuildGraph) convertExecCommandToLLB(node *StepNode, cmd plan.ExecCommand, state llb.State) (llb.State, error) {254 opts := []llb.RunOption{llb.Shlex(cmd.Cmd)}255 if cmd.CustomName != "" {256 opts = append(opts, llb.WithCustomName(cmd.CustomName))257 }258 259 if g.NoCache {260 opts = append(opts, llb.IgnoreCache)261 }262 263 // These options mount all secrets as environments variables264 // We want to add all secrets to all commands, even if they are not specified in the step265 // Note: This does mean that if the number of secrets change, then the cache for every step will be invalidated266 secretOpts := []llb.RunOption{}267 for _, secret := range g.Plan.Secrets {268 secretOpts = append(secretOpts, llb.AddSecret(secret, llb.SecretID(secret), llb.SecretAsEnv(true), llb.SecretAsEnvName(secret)))269 }270 opts = append(opts, secretOpts...)271 272 if len(node.Step.Secrets) > 0 {273 if g.secretsFile != nil {274 // These options mount the secrets hash file to the FS so that we can invalidate the cache if the secrets change275 secretInvalidationMountOpts := g.getSecretInvalidationMountOptions(node, secretOpts)276 opts = append(opts, secretInvalidationMountOpts...)277 }278 }279 280 if len(node.Step.Caches) > 0 {281 cacheOpts, err := g.getCacheMountOptions(node.Step.Caches)282 if err != nil {283 return state, err284 }285 opts = append(opts, cacheOpts...)286 }287 288 // Add GitHub token if applicable289 githubTokenOpts := g.addGitHubTokenToMiseInstall(cmd)290 if githubTokenOpts != nil {291 opts = append(opts, githubTokenOpts...)292 }293 294 s := state.Run(opts...).Root()295 296 return s, nil297}298 299// convertPathCommandToLLB converts a path command to an LLB state300func (g *BuildGraph) convertPathCommandToLLB(node *StepNode, cmd plan.PathCommand, state llb.State) (llb.State, error) {301 node.OutputEnv.PushPath(cmd.Path)302 pathString := strings.Join(node.getPathList(), ":")303 304 s := state.AddEnvf("PATH", "%s:%s", pathString, system.DefaultPathEnvUnix)305 return s, nil306}307 308// convertCopyCommandToLLB converts a copy command to an LLB state309func (g *BuildGraph) convertCopyCommandToLLB(cmd plan.CopyCommand, state llb.State) (llb.State, error) {310 var src llb.State311 if cmd.Image != "" {312 src = llb.Image(cmd.Image, llb.Platform(*g.Platform))313 } else {314 src = *g.LocalState315 }316 317 opts := []llb.ConstraintsOpt{}318 319 if cmd.Src == cmd.Dest {320 opts = append(opts, llb.WithCustomName(fmt.Sprintf("copy %s", cmd.Src)))321 }322 323 s := state.File(llb.Copy(src, cmd.Src, cmd.Dest, &llb.CopyInfo{324 CreateDestPath: true,325 FollowSymlinks: true,326 CopyDirContentsOnly: false,327 AllowWildcard: true,328 AllowEmptyWildcard: true,329 }), opts...)330 331 return s, nil332}333 334// convertFileCommandToLLB converts a file command to an LLB state335func (g *BuildGraph) convertFileCommandToLLB(cmd plan.FileCommand, state llb.State, step *plan.Step) (llb.State, error) {336 asset, ok := step.Assets[cmd.Name]337 if !ok {338 return state, fmt.Errorf("asset %q not found", cmd.Name)339 }340 341 // Create parent directories for the file342 parentDir := filepath.Dir(cmd.Path)343 if parentDir != "/" {344 s := state.File(llb.Mkdir(parentDir, 0755, llb.WithParents(true)))345 state = s346 }347 348 var mode os.FileMode = 0644349 if cmd.Mode != 0 {350 mode = cmd.Mode351 }352 353 fileAction := llb.Mkfile(cmd.Path, mode, []byte(asset))354 s := state.File(fileAction)355 if cmd.CustomName != "" {356 s = state.File(fileAction, llb.WithCustomName(cmd.CustomName))357 }358 359 return s, nil360}361 362func (g *BuildGraph) getSecretInvalidationMountOptions(node *StepNode, secretOpts []llb.RunOption) []llb.RunOption {363 opts := []llb.RunOption{}364 365 if len(node.Step.Secrets) == 0 || g.secretsFile == nil {366 return opts367 }368 369 // If all secrets are included, we can just copy the secrets hash file to the new state370 if slices.Contains(node.Step.Secrets, "*") {371 opts = append(opts, llb.AddMount("/secrets-hash", *g.secretsFile))372 } else {373 // If not all secrets are included, we want to compute the hash of only the used secrets374 secrets := slices.Clone(node.Step.Secrets)375 slices.Sort(secrets)376 secretsString := "$" + strings.Join(secrets, " $")377 378 // Hash all the secrets into a single file379 hashCommand := fmt.Sprintf("sh -c 'echo \"%s\" | sha256sum > /used-secrets-hash'", secretsString)380 381 usedSecretsState := g.usedSecretsBase.382 // Depend on the secrets-hash file so that it is invalidated when the secrets change383 File(llb.Copy(*g.secretsFile, "/secrets-hash", "/secrets-hash"),384 llb.WithCustomName("[railpack] copy secrets hash")).385 // Run the hash command to generate the used secrets hash386 Run(append([]llb.RunOption{387 llb.Shlex(hashCommand),388 llb.WithCustomName("[railpack] hash used secrets")},389 secretOpts...)...).Root()390 391 usedSecretsHash := llb.Scratch().File(392 llb.Copy(usedSecretsState, "/used-secrets-hash", "/used-secrets-hash"),393 llb.WithCustomName("[railpack] copy used secrets hash"))394 395 // Mount the used secrets file so that the layer is invalidated when these secrets change396 opts = append(opts, llb.AddMount("/used-secrets-hash", usedSecretsHash))397 }398 399 return opts400}401 402// returns the llb.RunOption slice for the given cache keys403func (g *BuildGraph) getCacheMountOptions(cacheKeys []string) ([]llb.RunOption, error) {404 var opts []llb.RunOption405 406 for _, cacheKey := range cacheKeys {407 if planCache, ok := g.Plan.Caches[cacheKey]; ok {408 cache := g.CacheStore.GetCache(cacheKey, planCache)409 cacheType := llb.CacheMountShared410 if planCache.Type == plan.CacheTypeLocked {411 cacheType = llb.CacheMountLocked412 }413 414 opts = append(opts,415 llb.AddMount(planCache.Directory, *cache.cacheState, llb.AsPersistentCacheDir(cache.cacheKey, cacheType)),416 )417 } else {418 return nil, fmt.Errorf("cache with key %q not found", cacheKey)419 }420 }421 return opts, nil422}423 424// addGitHubTokenToMiseInstall conditionally adds the GitHub token as an environment variable425// It only adds the token if:426// 1. A GitHub token is provided427// 2. The command is a mise install command (exact match or starts with "mise install")428// 3. GITHUB_TOKEN is not already in the plan's secrets429func (g *BuildGraph) addGitHubTokenToMiseInstall(cmd plan.ExecCommand) []llb.RunOption {430 // Check if we have a GitHub token and are installing mise packages431 if g.githubToken == "" || !isMiseInstallCommand(cmd.Cmd) {432 return nil433 }434 435 // Check if GITHUB_TOKEN is already in the secrets436 if slices.Contains(g.Plan.Secrets, githubTokenEnvVar) {437 return nil438 }439 440 return []llb.RunOption{llb.AddEnv(githubTokenEnvVar, g.githubToken)}441}442 443// isMiseInstallCommand checks if the command is a mise install command444func isMiseInstallCommand(cmd string) bool {445 // Check for exact match with the constant446 if cmd == generate.MiseInstallCommand {447 return true448 }449 // Check if command starts with "mise install"450 return strings.HasPrefix(cmd, "mise install")451}452