blob: 7077887f92297631854233c7a2715b5d5d2449d1 [file] [log] [blame]
Jan Schär82900a72025-04-14 11:11:37 +00001// Copyright The Monogon Project Authors.
2// SPDX-License-Identifier: Apache-2.0
3
4package main
5
6import (
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/json"
10 "flag"
11 "fmt"
12 "io"
13 "log"
14 "os"
15 "path/filepath"
16 "regexp"
17
18 "github.com/klauspost/compress/zstd"
19 "github.com/opencontainers/go-digest"
20 ocispec "github.com/opencontainers/image-spec/specs-go"
21 ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
22
23 "source.monogon.dev/osbase/oci/osimage"
24)
25
26const hashChunkSize = 1024 * 1024
27
28var payloadNameRegexp = regexp.MustCompile(`^[0-9A-Za-z-](?:[0-9A-Za-z._-]{0,78}[0-9A-Za-z_-])?$`)
29
30var (
Jan Schär07e69052025-05-12 16:34:15 +000031 productInfoPath = flag.String("product_info", "", "Path to the product info JSON file")
Jan Schär82900a72025-04-14 11:11:37 +000032 payloadName = flag.String("payload_name", "", "Payload name for the next payload_file flag")
33 compressionLevel = flag.Int("compression_level", int(zstd.SpeedDefault), "Compression level")
34 outPath = flag.String("out", "", "Output OCI Image Layout directory path")
35)
36
Jan Schärd4309bb2025-07-18 10:13:22 +020037var architectureToOCI = map[string]string{
38 "x86_64": "amd64",
39 "aarch64": "arm64",
40}
41
Jan Schär82900a72025-04-14 11:11:37 +000042type payload struct {
43 name string
44 path string
45}
46
47type countWriter struct {
48 size int64
49}
50
51func (c *countWriter) Write(p []byte) (n int, err error) {
52 c.size += int64(len(p))
53 return len(p), nil
54}
55
56func processPayloadsUncompressed(payloads []payload, blobsPath string) ([]osimage.PayloadInfo, []ocispecv1.Descriptor, error) {
57 payloadInfos := []osimage.PayloadInfo{}
58 payloadDescriptors := []ocispecv1.Descriptor{}
59 buf := make([]byte, hashChunkSize)
60 for _, payload := range payloads {
61 payloadFile, err := os.Open(payload.path)
62 if err != nil {
63 return nil, nil, err
64 }
65 payloadStat, err := payloadFile.Stat()
66 if err != nil {
67 return nil, nil, err
68 }
69 remaining := payloadStat.Size()
70 fullHash := sha256.New()
71 var chunkHashes []string
72 for remaining != 0 {
73 chunk := buf[:min(remaining, int64(len(buf)))]
74 remaining -= int64(len(chunk))
75 _, err := io.ReadFull(payloadFile, chunk)
76 if err != nil {
77 return nil, nil, err
78 }
79 fullHash.Write(chunk)
80 chunkHashBytes := sha256.Sum256(chunk)
81 chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:])
82 chunkHashes = append(chunkHashes, chunkHash)
83 }
84 payloadFile.Close()
85
86 fullHashSum := fmt.Sprintf("%x", fullHash.Sum(nil))
87
88 payloadInfos = append(payloadInfos, osimage.PayloadInfo{
89 Name: payload.name,
90 Size: payloadStat.Size(),
91 HashChunkSize: hashChunkSize,
92 ChunkHashesSHA256: chunkHashes,
93 })
94 payloadDescriptors = append(payloadDescriptors, ocispecv1.Descriptor{
95 MediaType: osimage.MediaTypePayloadUncompressed,
96 Digest: digest.NewDigestFromEncoded(digest.SHA256, fullHashSum),
97 Size: payloadStat.Size(),
98 })
99
100 relPath, err := filepath.Rel(blobsPath, payload.path)
101 if err != nil {
102 return nil, nil, err
103 }
104 err = os.Symlink(relPath, filepath.Join(blobsPath, fullHashSum))
105 if err != nil {
106 return nil, nil, err
107 }
108 }
109 return payloadInfos, payloadDescriptors, nil
110}
111
112func processPayloadsZstd(payloads []payload, blobsPath string) ([]osimage.PayloadInfo, []ocispecv1.Descriptor, error) {
113 payloadInfos := []osimage.PayloadInfo{}
114 payloadDescriptors := []ocispecv1.Descriptor{}
115 buf := make([]byte, hashChunkSize)
116 tmpPath := filepath.Join(blobsPath, "tmp")
117 zstdWriter, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevel(*compressionLevel)))
118 if err != nil {
119 return nil, nil, fmt.Errorf("failed to create zstd writer: %w", err)
120 }
121 for _, payload := range payloads {
122 payloadFile, err := os.Open(payload.path)
123 if err != nil {
124 return nil, nil, err
125 }
126 payloadStat, err := payloadFile.Stat()
127 if err != nil {
128 return nil, nil, err
129 }
130
131 compressedFile, err := os.Create(tmpPath)
132 if err != nil {
133 return nil, nil, err
134 }
135 compressedSize := &countWriter{}
136 compressedHash := sha256.New()
137 compressedWriter := io.MultiWriter(compressedFile, compressedSize, compressedHash)
138 zstdWriter.ResetContentSize(compressedWriter, payloadStat.Size())
139 remaining := payloadStat.Size()
140 var chunkHashes []string
141 for remaining != 0 {
142 chunk := buf[:min(remaining, int64(len(buf)))]
143 remaining -= int64(len(chunk))
144 _, err := io.ReadFull(payloadFile, chunk)
145 if err != nil {
146 return nil, nil, err
147 }
148 _, err = zstdWriter.Write(chunk)
149 if err != nil {
150 return nil, nil, fmt.Errorf("failed to write compressed data: %w", err)
151 }
152 chunkHashBytes := sha256.Sum256(chunk)
153 chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:])
154 chunkHashes = append(chunkHashes, chunkHash)
155 }
156 err = zstdWriter.Close()
157 if err != nil {
158 return nil, nil, fmt.Errorf("failed to close zstd writer: %w", err)
159 }
160 err = compressedFile.Close()
161 if err != nil {
162 return nil, nil, err
163 }
164 payloadFile.Close()
165
166 compressedHashSum := fmt.Sprintf("%x", compressedHash.Sum(nil))
167
168 payloadInfos = append(payloadInfos, osimage.PayloadInfo{
169 Name: payload.name,
170 Size: payloadStat.Size(),
171 HashChunkSize: hashChunkSize,
172 ChunkHashesSHA256: chunkHashes,
173 })
174 payloadDescriptors = append(payloadDescriptors, ocispecv1.Descriptor{
175 MediaType: osimage.MediaTypePayloadZstd,
176 Digest: digest.NewDigestFromEncoded(digest.SHA256, compressedHashSum),
177 Size: compressedSize.size,
178 })
179
180 err = os.Rename(tmpPath, filepath.Join(blobsPath, compressedHashSum))
181 if err != nil {
182 return nil, nil, err
183 }
184 }
185 return payloadInfos, payloadDescriptors, nil
186}
187
188func main() {
189 var payloads []payload
190 seenNames := make(map[string]bool)
191 flag.Func("payload_file", "Payload file path", func(payloadPath string) error {
192 if *payloadName == "" {
193 return fmt.Errorf("payload_name not set")
194 }
195 if !payloadNameRegexp.MatchString(*payloadName) {
196 return fmt.Errorf("invalid payload name %q", *payloadName)
197 }
198 if seenNames[*payloadName] {
199 return fmt.Errorf("duplicate payload name %q", *payloadName)
200 }
201 seenNames[*payloadName] = true
202 payloads = append(payloads, payload{
203 name: *payloadName,
204 path: payloadPath,
205 })
206 return nil
207 })
208 flag.Parse()
209
Jan Schär07e69052025-05-12 16:34:15 +0000210 rawProductInfo, err := os.ReadFile(*productInfoPath)
211 if err != nil {
212 log.Fatalf("Failed to read product info file: %v", err)
213 }
214 var productInfo osimage.ProductInfo
215 err = json.Unmarshal(rawProductInfo, &productInfo)
216 if err != nil {
217 log.Fatal(err)
218 }
219
Jan Schär82900a72025-04-14 11:11:37 +0000220 // Create blobs directory.
221 blobsPath := filepath.Join(*outPath, "blobs", "sha256")
Jan Schär07e69052025-05-12 16:34:15 +0000222 err = os.MkdirAll(blobsPath, 0755)
Jan Schär82900a72025-04-14 11:11:37 +0000223 if err != nil {
224 log.Fatal(err)
225 }
226
227 // Process payloads.
228 var payloadInfos []osimage.PayloadInfo
229 var payloadDescriptors []ocispecv1.Descriptor
230 if *compressionLevel == 0 {
231 payloadInfos, payloadDescriptors, err = processPayloadsUncompressed(payloads, blobsPath)
232 } else {
233 payloadInfos, payloadDescriptors, err = processPayloadsZstd(payloads, blobsPath)
234 }
235 if err != nil {
236 log.Fatalf("Failed to process payloads: %v", err)
237 }
238
239 // Write the OS image config.
240 imageConfig := osimage.Config{
241 FormatVersion: osimage.ConfigVersion,
Jan Schär07e69052025-05-12 16:34:15 +0000242 ProductInfo: productInfo,
Jan Schär82900a72025-04-14 11:11:37 +0000243 Payloads: payloadInfos,
244 }
245 imageConfigBytes, err := json.MarshalIndent(imageConfig, "", "\t")
246 if err != nil {
247 log.Fatalf("Failed to marshal OS image config: %v", err)
248 }
249 imageConfigBytes = append(imageConfigBytes, '\n')
250 imageConfigHash := fmt.Sprintf("%x", sha256.Sum256(imageConfigBytes))
251 err = os.WriteFile(filepath.Join(blobsPath, imageConfigHash), imageConfigBytes, 0644)
252 if err != nil {
253 log.Fatalf("Failed to write OS image config: %v", err)
254 }
255
256 // Write the image manifest.
257 imageManifest := ocispecv1.Manifest{
258 Versioned: ocispec.Versioned{SchemaVersion: 2},
259 MediaType: ocispecv1.MediaTypeImageManifest,
260 ArtifactType: osimage.ArtifactTypeOSImage,
261 Config: ocispecv1.Descriptor{
262 MediaType: osimage.MediaTypeOSImageConfig,
263 Digest: digest.NewDigestFromEncoded(digest.SHA256, imageConfigHash),
264 Size: int64(len(imageConfigBytes)),
265 },
266 Layers: payloadDescriptors,
267 }
268 imageManifestBytes, err := json.MarshalIndent(imageManifest, "", "\t")
269 if err != nil {
270 log.Fatalf("Failed to marshal image manifest: %v", err)
271 }
272 imageManifestBytes = append(imageManifestBytes, '\n')
273 imageManifestHash := fmt.Sprintf("%x", sha256.Sum256(imageManifestBytes))
274 err = os.WriteFile(filepath.Join(blobsPath, imageManifestHash), imageManifestBytes, 0644)
275 if err != nil {
276 log.Fatalf("Failed to write image manifest: %v", err)
277 }
278
279 // Write the index.
Jan Schärd4309bb2025-07-18 10:13:22 +0200280 platformArchitecture, ok := architectureToOCI[productInfo.Architecture()]
281 if !ok {
282 log.Fatalf("Missing architectureToOCI entry for %q", productInfo.Architecture())
283 }
284 platformOS := productInfo.PlatformOS
285 if platformOS == "" {
286 platformOS = "unknown"
287 }
Jan Schär82900a72025-04-14 11:11:37 +0000288 imageIndex := ocispecv1.Index{
289 Versioned: ocispec.Versioned{SchemaVersion: 2},
290 MediaType: ocispecv1.MediaTypeImageIndex,
291 Manifests: []ocispecv1.Descriptor{{
292 MediaType: ocispecv1.MediaTypeImageManifest,
293 ArtifactType: osimage.MediaTypeOSImageConfig,
Jan Schärd4309bb2025-07-18 10:13:22 +0200294 // The platform set here is used when building an OCI index with Bazel.
295 // Other consumers cannot rely on it being present because it is not
296 // preserved when pushing to a registry. We don't use the OS field, but
297 // it's required by the OCI spec.
298 Platform: &ocispecv1.Platform{
299 Architecture: platformArchitecture,
300 OS: platformOS,
301 },
302 Digest: digest.NewDigestFromEncoded(digest.SHA256, imageManifestHash),
303 Size: int64(len(imageManifestBytes)),
Jan Schär82900a72025-04-14 11:11:37 +0000304 }},
305 }
306 imageIndexBytes, err := json.MarshalIndent(imageIndex, "", "\t")
307 if err != nil {
308 log.Fatalf("Failed to marshal image index: %v", err)
309 }
310 imageIndexBytes = append(imageIndexBytes, '\n')
311 err = os.WriteFile(filepath.Join(*outPath, "index.json"), imageIndexBytes, 0644)
312 if err != nil {
313 log.Fatalf("Failed to write image index: %v", err)
314 }
315
316 // Write the oci-layout marker file.
317 err = os.WriteFile(
318 filepath.Join(*outPath, "oci-layout"),
319 []byte(`{"imageLayoutVersion": "1.0.0"}`+"\n"),
320 0644,
321 )
322 if err != nil {
323 log.Fatalf("Failed to write oci-layout file: %v", err)
324 }
325}