buildkit/frontend.go
buildkit/frontend.goBrowse 1970 files
1,829 tokens
7,140 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1// for platforms: used by `ghcr.io/railwayapp/railpack-frontend` as a buildkit frontend2// the buildkit library consumes buildkit input and exposes it to us via the client.Client interface3// note that `frontend` and `build` are completely separate paths4 5package buildkit6 7import (8 "context"9 "encoding/json"10 "fmt"11 "os"12 "strings"13 14 "github.com/charmbracelet/log"15 "github.com/moby/buildkit/client/llb"16 "github.com/moby/buildkit/exporter/containerimage/exptypes"17 "github.com/moby/buildkit/frontend/gateway/client"18 gw "github.com/moby/buildkit/frontend/gateway/grpcclient"19 "github.com/moby/buildkit/util/appcontext"20 specs "github.com/opencontainers/image-spec/specs-go/v1"21 "github.com/pkg/errors"22 "github.com/railwayapp/railpack/core/plan"23)24 25const (26 // The default local mount of where to look for the config file27 // This is "dockerfile" because that is commonly used for the config file mount28 configMountName = "dockerfile"29 30 // default filename for the serialized Railpack plan31 defaultRailpackPlan = "railpack-plan.json"32 33 // railpack build args34 secretsHash = "secrets-hash"35 cacheKey = "cache-key"36 githubToken = "github-token"37 38 // buildctl --import-cache is serialized into this frontend opt by the BuildKit client39 // `docker buildx` uses a different arg name, but the buildkit frontend normalizes the opt name the frontend receives40 keyCacheImports = "cache-imports"41)42 43func StartFrontend() {44 log.Info("starting frontend")45 46 ctx := appcontext.Context()47 if err := gw.RunFromEnvironment(ctx, Build); err != nil {48 log.Error("error: %+v\n", err)49 os.Exit(1)50 }51}52 53// handler for the buildkit gateway to let us read the railplan plan and generate a buildkit solve54func Build(ctx context.Context, c client.Client) (*client.Result, error) {55 opts := c.BuildOpts().Opts56 buildArgs := parseBuildArgs(opts)57 58 cacheKey := buildArgs[cacheKey]59 secretsHash := buildArgs[secretsHash]60 githubToken := buildArgs[githubToken]61 62 // TODO: Support building for multiple platforms63 buildPlatform, err := validatePlatform(opts)64 if err != nil {65 return nil, err66 }67 68 plan, err := readRailpackPlan(ctx, c)69 if err != nil {70 return nil, err71 }72 73 _, err = json.MarshalIndent(plan, "", " ")74 if err != nil {75 return nil, fmt.Errorf("error marshalling plan: %w", err)76 }77 78 llbState, image, err := ConvertPlanToLLB(plan, ConvertPlanOptions{79 BuildPlatform: buildPlatform,80 SecretsHash: secretsHash,81 CacheKey: cacheKey,82 SessionID: c.BuildOpts().SessionID,83 GitHubToken: githubToken,84 })85 if err != nil {86 return nil, fmt.Errorf("error converting plan to LLB: %w", err)87 }88 89 def, err := llbState.Marshal(ctx)90 if err != nil {91 return nil, fmt.Errorf("error marshalling LLB state: %w", err)92 }93 94 imageBytes, err := json.Marshal(image)95 if err != nil {96 return nil, fmt.Errorf("error marshalling image: %w", err)97 }98 99 // buildkit does not auto-apply --import-cache to this solve, we need to parse the frontend opt and set the CacheImports explicitly100 // cache exports are applied automatically for us since they do not impact the solve101 cacheImports, err := parseCacheImports(opts)102 if err != nil {103 return nil, err104 }105 // NOTE logs are swallowed and outputted to the buildkit container logs, not the buildctl logs106 log.Infof("frontend cache imports: %v", cacheImports)107 108 res, err := c.Solve(ctx, client.SolveRequest{109 Definition: def.ToPB(),110 CacheImports: cacheImports,111 })112 if err != nil {113 return nil, err114 }115 116 res.AddMeta(exptypes.ExporterImageConfigKey, imageBytes)117 118 return res, nil119}120 121func readRailpackPlan(ctx context.Context, c client.Client) (*plan.BuildPlan, error) {122 opts := c.BuildOpts().Opts123 filename := opts["filename"]124 if filename == "" {125 filename = defaultRailpackPlan126 }127 128 fileContents, err := readFile(ctx, c, filename)129 if err != nil {130 return nil, errors.Wrap(err, "failed to read railpack plan")131 }132 133 plan := plan.NewBuildPlan()134 err = json.Unmarshal([]byte(fileContents), plan)135 if err != nil {136 return nil, errors.Wrap(err, "failed to parse railpack plan")137 }138 139 return plan, nil140}141 142// checks if the platform is supported and returns the corresponding platform specs143func validatePlatform(opts map[string]string) (specs.Platform, error) {144 platformStr := opts["platform"]145 146 // Error if multiple platforms are specified147 if strings.Contains(platformStr, ",") {148 return specs.Platform{}, fmt.Errorf("multiple platforms are not supported, got: %s", platformStr)149 }150 151 platform, err := ParsePlatformWithDefaults(platformStr)152 if err != nil {153 return specs.Platform{}, fmt.Errorf("invalid platform format: %s. Must be one of: linux/amd64, linux/arm64, etc", platformStr)154 }155 156 return platform, nil157}158 159// Read a file from the build context. The frontend does not have a full `App` struct, which is why we have this helper160// to read railpack-plan.json.161func readFile(ctx context.Context, c client.Client, filename string) (string, error) {162 // Create a Local source for the dockerfile163 src := llb.Local(configMountName,164 llb.FollowPaths([]string{filename}),165 llb.SessionID(c.BuildOpts().SessionID),166 llb.WithCustomName("load build definition from "+filename),167 )168 169 srcDef, err := src.Marshal(ctx)170 if err != nil {171 return "", errors.Wrap(err, "failed to marshal local source")172 }173 174 res, err := c.Solve(ctx, client.SolveRequest{175 Definition: srcDef.ToPB(),176 })177 if err != nil {178 return "", errors.Wrap(err, "failed to resolve dockerfile")179 }180 181 ref, err := res.SingleRef()182 if err != nil {183 return "", err184 }185 186 content, err := ref.ReadFile(ctx, client.ReadRequest{187 Filename: filename,188 })189 if err != nil {190 return "", errors.Wrap(err, "failed to read file")191 }192 193 fileContents := string(content)194 195 return fileContents, nil196}197 198// Extracts Docker/buildx --build-arg values from frontend opts.199//200// Docker/buildx always namespaces those as "build-arg:<name>" (e.g.201// --build-arg cache-key=x → opts["build-arg:cache-key"]). bare buildctl202// --opt cache-key=x is opts["cache-key"] and is not returned here; use203// --opt build-arg:cache-key=x for the same shape as Docker.204func parseBuildArgs(opts map[string]string) map[string]string {205 buildArgs := make(map[string]string)206 207 for key, arg := range opts {208 if !strings.HasPrefix(key, "build-arg:") {209 continue210 }211 212 name := strings.TrimPrefix(key, "build-arg:")213 buildArgs[name] = arg214 }215 216 return buildArgs217}218 219// reads the "cache-imports" frontend opt set by the BuildKit220func parseCacheImports(opts map[string]string) ([]client.CacheOptionsEntry, error) {221 cacheImportsStr := opts[keyCacheImports]222 if cacheImportsStr == "" {223 return nil, nil224 }225 226 // Same JSON shape as control API CacheOptionsEntry (Type / Attrs).227 var entries []struct {228 Type string `json:"Type"`229 Attrs map[string]string `json:"Attrs"`230 }231 if err := json.Unmarshal([]byte(cacheImportsStr), &entries); err != nil {232 return nil, errors.Wrapf(err, "failed to unmarshal %s (%q)", keyCacheImports, cacheImportsStr)233 }234 235 cacheImports := make([]client.CacheOptionsEntry, 0, len(entries))236 for _, e := range entries {237 cacheImports = append(cacheImports, client.CacheOptionsEntry{Type: e.Type, Attrs: e.Attrs})238 }239 return cacheImports, nil240}241