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