blob: 1fb2254d6b0fe816e4f94adac402757c0d3cee8c [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
37type payload struct {
38 name string
39 path string
40}
41
42type countWriter struct {
43 size int64
44}
45
46func (c *countWriter) Write(p []byte) (n int, err error) {
47 c.size += int64(len(p))
48 return len(p), nil
49}
50
51func processPayloadsUncompressed(payloads []payload, blobsPath string) ([]osimage.PayloadInfo, []ocispecv1.Descriptor, error) {
52 payloadInfos := []osimage.PayloadInfo{}
53 payloadDescriptors := []ocispecv1.Descriptor{}
54 buf := make([]byte, hashChunkSize)
55 for _, payload := range payloads {
56 payloadFile, err := os.Open(payload.path)
57 if err != nil {
58 return nil, nil, err
59 }
60 payloadStat, err := payloadFile.Stat()
61 if err != nil {
62 return nil, nil, err
63 }
64 remaining := payloadStat.Size()
65 fullHash := sha256.New()
66 var chunkHashes []string
67 for remaining != 0 {
68 chunk := buf[:min(remaining, int64(len(buf)))]
69 remaining -= int64(len(chunk))
70 _, err := io.ReadFull(payloadFile, chunk)
71 if err != nil {
72 return nil, nil, err
73 }
74 fullHash.Write(chunk)
75 chunkHashBytes := sha256.Sum256(chunk)
76 chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:])
77 chunkHashes = append(chunkHashes, chunkHash)
78 }
79 payloadFile.Close()
80
81 fullHashSum := fmt.Sprintf("%x", fullHash.Sum(nil))
82
83 payloadInfos = append(payloadInfos, osimage.PayloadInfo{
84 Name: payload.name,
85 Size: payloadStat.Size(),
86 HashChunkSize: hashChunkSize,
87 ChunkHashesSHA256: chunkHashes,
88 })
89 payloadDescriptors = append(payloadDescriptors, ocispecv1.Descriptor{
90 MediaType: osimage.MediaTypePayloadUncompressed,
91 Digest: digest.NewDigestFromEncoded(digest.SHA256, fullHashSum),
92 Size: payloadStat.Size(),
93 })
94
95 relPath, err := filepath.Rel(blobsPath, payload.path)
96 if err != nil {
97 return nil, nil, err
98 }
99 err = os.Symlink(relPath, filepath.Join(blobsPath, fullHashSum))
100 if err != nil {
101 return nil, nil, err
102 }
103 }
104 return payloadInfos, payloadDescriptors, nil
105}
106
107func processPayloadsZstd(payloads []payload, blobsPath string) ([]osimage.PayloadInfo, []ocispecv1.Descriptor, error) {
108 payloadInfos := []osimage.PayloadInfo{}
109 payloadDescriptors := []ocispecv1.Descriptor{}
110 buf := make([]byte, hashChunkSize)
111 tmpPath := filepath.Join(blobsPath, "tmp")
112 zstdWriter, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevel(*compressionLevel)))
113 if err != nil {
114 return nil, nil, fmt.Errorf("failed to create zstd writer: %w", err)
115 }
116 for _, payload := range payloads {
117 payloadFile, err := os.Open(payload.path)
118 if err != nil {
119 return nil, nil, err
120 }
121 payloadStat, err := payloadFile.Stat()
122 if err != nil {
123 return nil, nil, err
124 }
125
126 compressedFile, err := os.Create(tmpPath)
127 if err != nil {
128 return nil, nil, err
129 }
130 compressedSize := &countWriter{}
131 compressedHash := sha256.New()
132 compressedWriter := io.MultiWriter(compressedFile, compressedSize, compressedHash)
133 zstdWriter.ResetContentSize(compressedWriter, payloadStat.Size())
134 remaining := payloadStat.Size()
135 var chunkHashes []string
136 for remaining != 0 {
137 chunk := buf[:min(remaining, int64(len(buf)))]
138 remaining -= int64(len(chunk))
139 _, err := io.ReadFull(payloadFile, chunk)
140 if err != nil {
141 return nil, nil, err
142 }
143 _, err = zstdWriter.Write(chunk)
144 if err != nil {
145 return nil, nil, fmt.Errorf("failed to write compressed data: %w", err)
146 }
147 chunkHashBytes := sha256.Sum256(chunk)
148 chunkHash := base64.RawStdEncoding.EncodeToString(chunkHashBytes[:])
149 chunkHashes = append(chunkHashes, chunkHash)
150 }
151 err = zstdWriter.Close()
152 if err != nil {
153 return nil, nil, fmt.Errorf("failed to close zstd writer: %w", err)
154 }
155 err = compressedFile.Close()
156 if err != nil {
157 return nil, nil, err
158 }
159 payloadFile.Close()
160
161 compressedHashSum := fmt.Sprintf("%x", compressedHash.Sum(nil))
162
163 payloadInfos = append(payloadInfos, osimage.PayloadInfo{
164 Name: payload.name,
165 Size: payloadStat.Size(),
166 HashChunkSize: hashChunkSize,
167 ChunkHashesSHA256: chunkHashes,
168 })
169 payloadDescriptors = append(payloadDescriptors, ocispecv1.Descriptor{
170 MediaType: osimage.MediaTypePayloadZstd,
171 Digest: digest.NewDigestFromEncoded(digest.SHA256, compressedHashSum),
172 Size: compressedSize.size,
173 })
174
175 err = os.Rename(tmpPath, filepath.Join(blobsPath, compressedHashSum))
176 if err != nil {
177 return nil, nil, err
178 }
179 }
180 return payloadInfos, payloadDescriptors, nil
181}
182
183func main() {
184 var payloads []payload
185 seenNames := make(map[string]bool)
186 flag.Func("payload_file", "Payload file path", func(payloadPath string) error {
187 if *payloadName == "" {
188 return fmt.Errorf("payload_name not set")
189 }
190 if !payloadNameRegexp.MatchString(*payloadName) {
191 return fmt.Errorf("invalid payload name %q", *payloadName)
192 }
193 if seenNames[*payloadName] {
194 return fmt.Errorf("duplicate payload name %q", *payloadName)
195 }
196 seenNames[*payloadName] = true
197 payloads = append(payloads, payload{
198 name: *payloadName,
199 path: payloadPath,
200 })
201 return nil
202 })
203 flag.Parse()
204
Jan Schär07e69052025-05-12 16:34:15 +0000205 rawProductInfo, err := os.ReadFile(*productInfoPath)
206 if err != nil {
207 log.Fatalf("Failed to read product info file: %v", err)
208 }
209 var productInfo osimage.ProductInfo
210 err = json.Unmarshal(rawProductInfo, &productInfo)
211 if err != nil {
212 log.Fatal(err)
213 }
214
Jan Schär82900a72025-04-14 11:11:37 +0000215 // Create blobs directory.
216 blobsPath := filepath.Join(*outPath, "blobs", "sha256")
Jan Schär07e69052025-05-12 16:34:15 +0000217 err = os.MkdirAll(blobsPath, 0755)
Jan Schär82900a72025-04-14 11:11:37 +0000218 if err != nil {
219 log.Fatal(err)
220 }
221
222 // Process payloads.
223 var payloadInfos []osimage.PayloadInfo
224 var payloadDescriptors []ocispecv1.Descriptor
225 if *compressionLevel == 0 {
226 payloadInfos, payloadDescriptors, err = processPayloadsUncompressed(payloads, blobsPath)
227 } else {
228 payloadInfos, payloadDescriptors, err = processPayloadsZstd(payloads, blobsPath)
229 }
230 if err != nil {
231 log.Fatalf("Failed to process payloads: %v", err)
232 }
233
234 // Write the OS image config.
235 imageConfig := osimage.Config{
236 FormatVersion: osimage.ConfigVersion,
Jan Schär07e69052025-05-12 16:34:15 +0000237 ProductInfo: productInfo,
Jan Schär82900a72025-04-14 11:11:37 +0000238 Payloads: payloadInfos,
239 }
240 imageConfigBytes, err := json.MarshalIndent(imageConfig, "", "\t")
241 if err != nil {
242 log.Fatalf("Failed to marshal OS image config: %v", err)
243 }
244 imageConfigBytes = append(imageConfigBytes, '\n')
245 imageConfigHash := fmt.Sprintf("%x", sha256.Sum256(imageConfigBytes))
246 err = os.WriteFile(filepath.Join(blobsPath, imageConfigHash), imageConfigBytes, 0644)
247 if err != nil {
248 log.Fatalf("Failed to write OS image config: %v", err)
249 }
250
251 // Write the image manifest.
252 imageManifest := ocispecv1.Manifest{
253 Versioned: ocispec.Versioned{SchemaVersion: 2},
254 MediaType: ocispecv1.MediaTypeImageManifest,
255 ArtifactType: osimage.ArtifactTypeOSImage,
256 Config: ocispecv1.Descriptor{
257 MediaType: osimage.MediaTypeOSImageConfig,
258 Digest: digest.NewDigestFromEncoded(digest.SHA256, imageConfigHash),
259 Size: int64(len(imageConfigBytes)),
260 },
261 Layers: payloadDescriptors,
262 }
263 imageManifestBytes, err := json.MarshalIndent(imageManifest, "", "\t")
264 if err != nil {
265 log.Fatalf("Failed to marshal image manifest: %v", err)
266 }
267 imageManifestBytes = append(imageManifestBytes, '\n')
268 imageManifestHash := fmt.Sprintf("%x", sha256.Sum256(imageManifestBytes))
269 err = os.WriteFile(filepath.Join(blobsPath, imageManifestHash), imageManifestBytes, 0644)
270 if err != nil {
271 log.Fatalf("Failed to write image manifest: %v", err)
272 }
273
274 // Write the index.
275 imageIndex := ocispecv1.Index{
276 Versioned: ocispec.Versioned{SchemaVersion: 2},
277 MediaType: ocispecv1.MediaTypeImageIndex,
278 Manifests: []ocispecv1.Descriptor{{
279 MediaType: ocispecv1.MediaTypeImageManifest,
280 ArtifactType: osimage.MediaTypeOSImageConfig,
281 Digest: digest.NewDigestFromEncoded(digest.SHA256, imageManifestHash),
282 Size: int64(len(imageManifestBytes)),
283 }},
284 }
285 imageIndexBytes, err := json.MarshalIndent(imageIndex, "", "\t")
286 if err != nil {
287 log.Fatalf("Failed to marshal image index: %v", err)
288 }
289 imageIndexBytes = append(imageIndexBytes, '\n')
290 err = os.WriteFile(filepath.Join(*outPath, "index.json"), imageIndexBytes, 0644)
291 if err != nil {
292 log.Fatalf("Failed to write image index: %v", err)
293 }
294
295 // Write the oci-layout marker file.
296 err = os.WriteFile(
297 filepath.Join(*outPath, "oci-layout"),
298 []byte(`{"imageLayoutVersion": "1.0.0"}`+"\n"),
299 0644,
300 )
301 if err != nil {
302 log.Fatalf("Failed to write oci-layout file: %v", err)
303 }
304}