Repository navigation
Expand file tree
/
Copy pathbootstrap_image.go
More file actions
284 lines (253 loc) · 10.4 KB
/
Copy pathbootstrap_image.go
File metadata and controls
284 lines (253 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/codefly-dev/core/shared"
)
//go:embed bootstrap-image.json
var bootstrapImageLockJSON []byte
//go:embed go.mod
var bootstrapMigrateGoMod []byte
//go:embed go.sum
var bootstrapMigrateGoSum []byte
// bootstrapArchitectures are the architectures the bootstrap image is built for,
// matching the recipe's linux/amd64 + linux/arm64 platform list. The lock must
// build a migrate binary for each of them, and the image rejects any other.
var bootstrapArchitectures = []string{"amd64", "arm64"}
// bootstrapRecipePlatforms are the buildx platforms the emitted recipe targets,
// derived from the architectures the lock resolves inputs for so a platform can
// never be advertised without a source-built migrate binary behind it.
func bootstrapRecipePlatforms() []string {
platforms := make([]string, 0, len(bootstrapArchitectures))
for _, architecture := range bootstrapArchitectures {
platforms = append(platforms, "linux/"+architecture)
}
return platforms
}
// bootstrapLock resolves the inputs the bootstrap image builds from. The runtime
// image lock covers only the managed Postgres image, so this separate image needs
// its own source of truth.
var bootstrapLock = shared.Must(parseBootstrapImageLock(bootstrapImageLockJSON))
// bootstrapImageLock pins the base, apk packages and source-build toolchain.
// The maintained module files are embedded separately; their hashes travel in
// provenance and are verified before compilation, so callers cannot substitute
// a different module graph behind the image labels.
type bootstrapImageLock struct {
Base bootstrapBaseImage `json:"base"`
Packages bootstrapPackages `json:"packages"`
Migrate bootstrapMigrateBinary `json:"migrate"`
// LockDigest identifies the JSON inputs; the embedded module-file hashes are
// separate provenance entries. Together they enter the aggregate recipe digest.
// Canonical encoding ignores JSON whitespace and key order.
LockDigest string `json:"-"`
}
type bootstrapBaseImage struct {
Image string `json:"image"`
Version string `json:"version"`
Digest string `json:"digest"`
}
type bootstrapPackages struct {
Repositories []string `json:"repositories"`
Pinned []bootstrapPackage `json:"pinned"`
}
type bootstrapPackage struct {
Name string `json:"name"`
Version string `json:"version"`
}
type bootstrapMigrateBinary struct {
Version string `json:"version"`
Toolchain string `json:"toolchain"`
}
func (bootstrapMigrateBinary) GoModSHA256() string {
return fmt.Sprintf("%x", sha256.Sum256(bootstrapMigrateGoMod))
}
func (bootstrapMigrateBinary) GoSumSHA256() string {
return fmt.Sprintf("%x", sha256.Sum256(bootstrapMigrateGoSum))
}
// Stage only agent-owned module inputs, never a consumer's module or credentials.
func stageBootstrapMigrate(root string) error {
dir := filepath.Join(root, "migrate")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for name, data := range map[string][]byte{"go.mod": bootstrapMigrateGoMod, "go.sum": bootstrapMigrateGoSum} {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
return err
}
}
return nil
}
// Reference is the base image the Dockerfile builds FROM. The locked digest is the
// multi-platform index digest, so buildx still selects the per-architecture
// manifest for the platform it is building.
func (b bootstrapBaseImage) Reference() string {
return b.Image + "@" + b.Digest
}
// RepositoryArguments renders the locked repositories as printf arguments, quoted
// for the shell, in lock order. They become the image's entire apk repository set.
func (p bootstrapPackages) RepositoryArguments() string {
quoted := make([]string, 0, len(p.Repositories))
for _, repository := range p.Repositories {
quoted = append(quoted, `"`+repository+`"`)
}
return strings.Join(quoted, " ")
}
// Specs renders the locked packages as apk arguments, each pinned to an exact
// version.
func (p bootstrapPackages) Specs() string {
specs := make([]string, 0, len(p.Pinned))
for _, pinned := range p.Pinned {
specs = append(specs, pinned.Name+"="+pinned.Version)
}
return strings.Join(specs, " ")
}
// bootstrapProvenance is one resolved input, exposed from the lock as a Dockerfile
// build argument, an image label, and a typed field of the emitted build recipe.
type bootstrapProvenance struct {
Arg string
Label string
Value string
}
// Provenance lists the resolved identities the image and the emitted recipe
// record, so a consumer can read what a built artifact was assembled from without
// re-resolving anything.
func (l *bootstrapImageLock) Provenance() []bootstrapProvenance {
return []bootstrapProvenance{
{Arg: "CODEFLY_BOOTSTRAP_LOCK_DIGEST", Label: "dev.codefly.bootstrap.lock-digest", Value: l.LockDigest},
{Arg: "CODEFLY_BOOTSTRAP_BASE", Label: "dev.codefly.bootstrap.base", Value: l.Base.Reference()},
{Arg: "CODEFLY_BOOTSTRAP_BASE_VERSION", Label: "dev.codefly.bootstrap.base-version", Value: l.Base.Version},
{Arg: "CODEFLY_BOOTSTRAP_APK_REPOSITORIES", Label: "dev.codefly.bootstrap.apk-repositories", Value: strings.Join(l.Packages.Repositories, " ")},
{Arg: "CODEFLY_BOOTSTRAP_APK_PACKAGES", Label: "dev.codefly.bootstrap.apk-packages", Value: l.Packages.Specs()},
{Arg: "CODEFLY_BOOTSTRAP_MIGRATE_VERSION", Label: "dev.codefly.bootstrap.migrate-version", Value: l.Migrate.Version},
{Arg: "CODEFLY_BOOTSTRAP_MIGRATE_TOOLCHAIN", Label: "dev.codefly.bootstrap.migrate-toolchain", Value: l.Migrate.Toolchain},
{Arg: "CODEFLY_BOOTSTRAP_MIGRATE_GO_MOD", Label: "dev.codefly.bootstrap.migrate-go-mod", Value: l.Migrate.GoModSHA256()},
{Arg: "CODEFLY_BOOTSTRAP_MIGRATE_GO_SUM", Label: "dev.codefly.bootstrap.migrate-go-sum", Value: l.Migrate.GoSumSHA256()},
{Arg: "CODEFLY_BOOTSTRAP_MIGRATE_DRIVERS", Label: "dev.codefly.bootstrap.migrate-drivers", Value: "postgres,file"},
}
}
// RecipeBuildArgs exposes the same resolved identities through the build recipe's
// typed build arguments, where core folds them into the plan's aggregate digest.
// Nothing in the Dockerfile reads them: every input the build resolves, verifies
// against, or records as a label is a rendered literal, so no caller can pass an
// argument that changes what the image is or what it claims to be.
func (l *bootstrapImageLock) RecipeBuildArgs() map[string]string {
provenance := l.Provenance()
buildArgs := make(map[string]string, len(provenance))
for _, entry := range provenance {
buildArgs[entry.Arg] = entry.Value
}
return buildArgs
}
func parseBootstrapImageLock(content []byte) (*bootstrapImageLock, error) {
var lock bootstrapImageLock
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&lock); err != nil {
return nil, fmt.Errorf("parse bootstrap image lock: %w", err)
}
if err := lock.validate(); err != nil {
return nil, err
}
canonical, err := json.Marshal(lock)
if err != nil {
return nil, fmt.Errorf("encode bootstrap image lock: %w", err)
}
document := sha256.Sum256(canonical)
lock.LockDigest = "sha256:" + hex.EncodeToString(document[:])
return &lock, nil
}
func (l *bootstrapImageLock) validate() error {
if l.Base.Image == "" {
return fmt.Errorf("bootstrap base image name is required")
}
if l.Base.Version == "" {
return fmt.Errorf("bootstrap base image version is required")
}
if err := validateSHA256Digest("bootstrap base image digest", l.Base.Digest); err != nil {
return err
}
if err := l.validatePackages(); err != nil {
return err
}
return l.validateMigrate()
}
func (l *bootstrapImageLock) validatePackages() error {
if len(l.Packages.Repositories) == 0 {
return fmt.Errorf("bootstrap apk repositories are required")
}
branch, err := alpineReleaseBranch(l.Base.Version)
if err != nil {
return err
}
for _, repository := range l.Packages.Repositories {
if !strings.HasPrefix(repository, "https://") {
return fmt.Errorf("bootstrap apk repository must be an https URL, got %q", repository)
}
// A base bumped to a new Alpine release while a package repository stays on
// the previous branch resolves packages that were never built against that
// base, so the lock only accepts both moving together.
if !strings.Contains(repository, "/"+branch+"/") {
return fmt.Errorf("bootstrap apk repository %q does not serve the locked Alpine %s branch", repository, branch)
}
}
if len(l.Packages.Pinned) == 0 {
return fmt.Errorf("bootstrap apk packages are required")
}
for _, pinned := range l.Packages.Pinned {
if pinned.Name == "" {
return fmt.Errorf("bootstrap apk package name is required")
}
if pinned.Version == "" {
return fmt.Errorf("bootstrap apk package %s must pin an exact version", pinned.Name)
}
if strings.ContainsAny(pinned.Name+pinned.Version, " =") {
return fmt.Errorf("bootstrap apk package %s=%s is not a single apk constraint", pinned.Name, pinned.Version)
}
}
return nil
}
func (l *bootstrapImageLock) validateMigrate() error {
if !regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(l.Migrate.Version) {
return fmt.Errorf("bootstrap migrate version must be a release tag, got %q", l.Migrate.Version)
}
if !regexp.MustCompile(`^golang:[0-9]+\.[0-9]+\.[0-9]+-alpine[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}$`).MatchString(l.Migrate.Toolchain) {
return fmt.Errorf("bootstrap migrate toolchain must pin an exact Go/Alpine tag and sha256 digest")
}
return nil
}
func alpineReleaseBranch(version string) (string, error) {
major, rest, found := strings.Cut(version, ".")
minor, _, _ := strings.Cut(rest, ".")
if !found || major == "" || minor == "" {
return "", fmt.Errorf("bootstrap base image version %q is not an Alpine release version", version)
}
return "v" + major + "." + minor, nil
}
// validateSHA256Digest accepts an "sha256:<hex>" reference, the form both image
// locks pin by.
func validateSHA256Digest(field, value string) error {
algorithm, encoded, found := strings.Cut(value, ":")
if !found || algorithm != "sha256" || !isSHA256Hex(encoded) {
return fmt.Errorf("%s must be a sha256 digest", field)
}
return nil
}
// validateSHA256Checksum accepts the bare hex a checksum file publishes.
func validateSHA256Checksum(field, value string) error {
if !isSHA256Hex(value) {
return fmt.Errorf("%s must be a sha256 checksum", field)
}
return nil
}
func isSHA256Hex(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}