GCC Code Coverage Report


Directory: cvmfs/
File: cvmfs/swissknife_overlay.cc
Date: 2026-05-03 02:36:16
Exec Total Coverage
Lines: 0 591 0.0%
Branches: 0 393 0.0%

Line Branch Exec Source
1 /**
2 * This file is part of the CernVM File System.
3 *
4 * Implementation of the overlay swissknife command that merges multiple
5 * CVMFS subdirectory catalogs using overlay semantics (similar to OverlayFS)
6 * and publishes the result as a repository subdirectory.
7 */
8
9 #include "swissknife_overlay.h"
10
11 #include <fcntl.h>
12 #include <inttypes.h>
13 #include <sys/stat.h>
14 #include <unistd.h>
15
16 #include <cassert>
17 #include <ctime>
18 #include <map>
19 #include <string>
20 #include <vector>
21
22 #include "catalog.h"
23 #include "catalog_mgr_rw.h"
24 #include "catalog_rw.h"
25 #include "catalog_sql.h"
26 #include "compression/compression.h"
27 #include "crypto/hash.h"
28 #include "directory_entry.h"
29 #include "ingestion/ingestion_source.h"
30 #include "json_document.h"
31 #include "manifest.h"
32 #include "network/download.h"
33 #include "network/sink_path.h"
34 #include "repository_tag.h"
35 #include "shortstring.h"
36 #include "statistics.h"
37 #include "upload.h"
38 #include "upload_spooler_definition.h"
39 #include "upload_spooler_result.h"
40 #include "util/logging.h"
41 #include "util/pointer.h"
42 #include "util/posix.h"
43 #include "util/string.h"
44 #include "xattr.h"
45
46 using namespace std; // NOLINT
47
48 namespace swissknife {
49
50 ParameterList CommandOverlay::GetParams() const {
51 ParameterList r;
52 // Publish workflow parameters (same convention as ingest/sync)
53 r.push_back(Parameter::Mandatory('r', "upstream storage definition"));
54 r.push_back(Parameter::Mandatory('w', "stratum 0 URL"));
55 r.push_back(Parameter::Mandatory('t', "temporary directory"));
56 r.push_back(Parameter::Mandatory('o', "manifest output path"));
57 r.push_back(Parameter::Mandatory('b', "base hash of current root catalog"));
58 r.push_back(Parameter::Mandatory('K', "public key path"));
59 r.push_back(Parameter::Mandatory('N', "repository name"));
60
61 // Overlay-specific parameters
62 r.push_back(Parameter::Mandatory('l', "comma-separated layer paths "
63 "(bottom-to-top order)"));
64 r.push_back(Parameter::Mandatory('d', "destination subdirectory path in "
65 "repository for the merged overlay"));
66 r.push_back(Parameter::Optional('e', "hash algorithm (default: sha1)"));
67 r.push_back(Parameter::Optional('Z', "compression algorithm "
68 "(default: zlib)"));
69 r.push_back(Parameter::Optional('@', "proxy URL"));
70 r.push_back(Parameter::Switch('L', "follow HTTP redirects"));
71 r.push_back(Parameter::Optional('c', "OCI image config JSON file path "
72 "(when provided, Singularity .singularity.d dotfiles are "
73 "injected into the merged overlay)"));
74 r.push_back(Parameter::Switch('S', "skip Singularity dotfile injection "
75 "even when an OCI config is provided"));
76 return r;
77 }
78
79
80 bool CommandOverlay::IsWhiteoutFile(const string &name) {
81 return HasPrefix(name, ".wh.", false) && !IsOpaqueMarker(name);
82 }
83
84
85 string CommandOverlay::GetWhiteoutTarget(const string &name) {
86 // ".wh." is 4 characters
87 if (name.length() <= 4) return "";
88 return name.substr(4);
89 }
90
91
92 bool CommandOverlay::IsOpaqueMarker(const string &name) {
93 return name == ".wh..wh..opq";
94 }
95
96
97 bool CommandOverlay::ReadCatalogEntries(
98 catalog::Catalog *catalog,
99 const string &catalog_root_path,
100 const string &relative_prefix,
101 const string &repo_base,
102 const string &temp_dir,
103 map<string, OverlayEntry> *entries) {
104 // List entries at this path in the catalog
105 catalog::DirectoryEntryList listing;
106 const PathString ps_path(catalog_root_path.data(),
107 catalog_root_path.length());
108 const bool has_entries = catalog->ListingPath(ps_path, &listing);
109
110 if (!has_entries && catalog_root_path != catalog->mountpoint().ToString()) {
111 // No entries at this path - it might be a file, not a directory
112 return true;
113 }
114
115 for (size_t i = 0; i < listing.size(); ++i) {
116 const catalog::DirectoryEntry &dirent = listing[i];
117 const string name = dirent.name().ToString();
118
119 // Skip CVMFS bookkeeping files — they are internal metadata and must not
120 // be carried over into the merged overlay. PublishMergedEntries() will
121 // create its own .cvmfscatalog marker for the destination catalog.
122 if (name == ".cvmfscatalog" || name == ".cvmfsdirtab"
123 || name == ".cvmfsautocatalog") {
124 continue;
125 }
126
127 const string child_catalog_path =
128 catalog_root_path.empty() ? "/" + name : catalog_root_path + "/" + name;
129 const string child_relative =
130 relative_prefix.empty() ? name : relative_prefix + "/" + name;
131
132 OverlayEntry oe;
133 oe.entry = dirent;
134 oe.path = child_relative;
135 oe.parent = relative_prefix;
136 oe.is_whiteout = IsWhiteoutFile(name);
137 oe.is_opaque_dir = false;
138
139 // Look up xattrs for this entry
140 XattrList xattrs;
141 const PathString ps_child(child_catalog_path.data(),
142 child_catalog_path.length());
143 catalog->LookupXattrsPath(ps_child, &xattrs);
144 oe.xattrs = xattrs;
145
146 (*entries)[child_relative] = oe;
147
148 if (dirent.IsDirectory()) {
149 if (dirent.IsNestedCatalogMountpoint() && !repo_base.empty()) {
150 // Load the nested catalog and recurse into it
151 shash::Any nested_hash;
152 uint64_t nested_size;
153 if (!catalog->FindNested(ps_child, &nested_hash, &nested_size)) {
154 LogCvmfs(kLogCvmfs, kLogStderr,
155 "Failed to find nested catalog hash for %s",
156 child_catalog_path.c_str());
157 return false;
158 }
159
160 catalog::Catalog *nested = LoadCatalogForPath(
161 repo_base, child_catalog_path, temp_dir, nested_hash);
162 if (nested == NULL) {
163 LogCvmfs(kLogCvmfs, kLogStderr,
164 "Failed to load nested catalog for %s",
165 child_catalog_path.c_str());
166 return false;
167 }
168
169 // Check for opaque marker in the nested catalog root
170 catalog::DirectoryEntryList sub_listing;
171 nested->ListingPath(ps_child, &sub_listing);
172 for (size_t j = 0; j < sub_listing.size(); ++j) {
173 if (IsOpaqueMarker(sub_listing[j].name().ToString())) {
174 (*entries)[child_relative].is_opaque_dir = true;
175 break;
176 }
177 }
178
179 if (!ReadCatalogEntries(nested, child_catalog_path,
180 child_relative, repo_base, temp_dir, entries)) {
181 delete nested;
182 return false;
183 }
184 delete nested;
185 } else if (!dirent.IsNestedCatalogMountpoint()) {
186 // Regular directory — check for opaque marker among children
187 catalog::DirectoryEntryList sub_listing;
188 catalog->ListingPath(ps_child, &sub_listing);
189 for (size_t j = 0; j < sub_listing.size(); ++j) {
190 if (IsOpaqueMarker(sub_listing[j].name().ToString())) {
191 (*entries)[child_relative].is_opaque_dir = true;
192 break;
193 }
194 }
195
196 if (!ReadCatalogEntries(catalog, child_catalog_path,
197 child_relative, repo_base, temp_dir, entries)) {
198 return false;
199 }
200 }
201 // else: nested catalog mountpoint but no repo_base — skip (e.g. cache)
202 }
203 }
204
205 return true;
206 }
207
208
209 void CommandOverlay::MergeLayer(
210 const map<string, OverlayEntry> &layer_entries,
211 map<string, OverlayEntry> *merged) const {
212 // First pass: collect whiteouts and opaque directories
213 vector<string> whiteout_targets;
214 vector<string> opaque_dirs;
215
216 for (map<string, OverlayEntry>::const_iterator it = layer_entries.begin();
217 it != layer_entries.end(); ++it) {
218 const OverlayEntry &oe = it->second;
219 const string &path = it->first;
220
221 if (oe.is_whiteout) {
222 // Whiteout: mark the target for deletion from lower layers
223 const string target_name = GetWhiteoutTarget(
224 GetFileName(path));
225 const string target_path =
226 oe.parent.empty() ? target_name : oe.parent + "/" + target_name;
227 whiteout_targets.push_back(target_path);
228 continue;
229 }
230
231 if (IsOpaqueMarker(GetFileName(path))) {
232 // Don't add the opaque marker itself to the merged output
233 continue;
234 }
235
236 if (oe.is_opaque_dir) {
237 opaque_dirs.push_back(path);
238 }
239 }
240
241 // Apply opaque directory semantics: remove all entries from lower layers
242 // that are under opaque directories
243 for (size_t i = 0; i < opaque_dirs.size(); ++i) {
244 const string &opaque_path = opaque_dirs[i];
245 const string prefix = opaque_path + "/";
246
247 // Remove children of this directory from merged (lower layer entries)
248 vector<string> to_remove;
249 for (map<string, OverlayEntry>::iterator it = merged->begin();
250 it != merged->end(); ++it) {
251 if (HasPrefix(it->first, prefix, false)) {
252 to_remove.push_back(it->first);
253 }
254 }
255 for (size_t j = 0; j < to_remove.size(); ++j) {
256 merged->erase(to_remove[j]);
257 }
258 }
259
260 // Apply whiteout semantics: remove targeted entries and their children
261 for (size_t i = 0; i < whiteout_targets.size(); ++i) {
262 const string &target = whiteout_targets[i];
263 const string prefix = target + "/";
264
265 // Remove the target entry itself
266 merged->erase(target);
267
268 // Remove all children of the target
269 vector<string> to_remove;
270 for (map<string, OverlayEntry>::iterator it = merged->begin();
271 it != merged->end(); ++it) {
272 if (HasPrefix(it->first, prefix, false)) {
273 to_remove.push_back(it->first);
274 }
275 }
276 for (size_t j = 0; j < to_remove.size(); ++j) {
277 merged->erase(to_remove[j]);
278 }
279 }
280
281 // Second pass: add/override entries from this layer
282 for (map<string, OverlayEntry>::const_iterator it = layer_entries.begin();
283 it != layer_entries.end(); ++it) {
284 const OverlayEntry &oe = it->second;
285 const string &path = it->first;
286
287 // Skip whiteout files and opaque markers - they are control files
288 if (oe.is_whiteout || IsOpaqueMarker(GetFileName(path))) {
289 continue;
290 }
291
292 // Upper layer overrides lower layer for the same path
293 (*merged)[path] = oe;
294 }
295 }
296
297
298 // ---------------------------------------------------------------------------
299 // Singularity dotfile generation
300 // ---------------------------------------------------------------------------
301
302 // Static file contents for /.singularity.d — these mirror the Go
303 // constants in singularity/dotfiles.go (originally from Sylabs/Singularity).
304
305 static const char *const kSingExec =
306 "#!/bin/sh\n"
307 "for script in /.singularity.d/env/*.sh; do\n"
308 " if [ -f \"$script\" ]; then\n"
309 " . \"$script\"\n"
310 " fi\n"
311 "done\n"
312 "exec \"$@\"\n";
313
314 static const char *const kSingRun =
315 "#!/bin/sh\n"
316 "for script in /.singularity.d/env/*.sh; do\n"
317 " if [ -f \"$script\" ]; then\n"
318 " . \"$script\"\n"
319 " fi\n"
320 "done\n"
321 "if test -n \"${SINGULARITY_APPNAME:-}\"; then\n"
322 " if test -x \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/runscript\"; then\n"
323 " exec \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/runscript\" \"$@\"\n"
324 " else\n"
325 " echo \"No Singularity runscript for contained app: ${SINGULARITY_APPNAME:-}\"\n"
326 " exit 1\n"
327 " fi\n"
328 "elif test -x \"/.singularity.d/runscript\"; then\n"
329 " exec \"/.singularity.d/runscript\" \"$@\"\n"
330 "else\n"
331 " echo \"No Singularity runscript found, executing /bin/sh\"\n"
332 " exec /bin/sh \"$@\"\n"
333 "fi\n";
334
335 static const char *const kSingShell =
336 "#!/bin/sh\n"
337 "for script in /.singularity.d/env/*.sh; do\n"
338 " if [ -f \"$script\" ]; then\n"
339 " . \"$script\"\n"
340 " fi\n"
341 "done\n"
342 "if test -n \"$SINGULARITY_SHELL\" -a -x \"$SINGULARITY_SHELL\"; then\n"
343 " exec $SINGULARITY_SHELL \"$@\"\n"
344 " echo \"ERROR: Failed running shell as defined by '\\$SINGULARITY_SHELL'\" 1>&2\n"
345 " exit 1\n"
346 "elif test -x /bin/bash; then\n"
347 " SHELL=/bin/bash\n"
348 " PS1=\"Singularity $SINGULARITY_NAME:\\w> \"\n"
349 " export SHELL PS1\n"
350 " exec /bin/bash --norc \"$@\"\n"
351 "elif test -x /bin/sh; then\n"
352 " SHELL=/bin/sh\n"
353 " export SHELL\n"
354 " exec /bin/sh \"$@\"\n"
355 "else\n"
356 " echo \"ERROR: /bin/sh does not exist in container\" 1>&2\n"
357 "fi\n"
358 "exit 1\n";
359
360 static const char *const kSingStart =
361 "#!/bin/sh\n"
362 "# if we are here start notify PID 1 to continue\n"
363 "# DON'T REMOVE\n"
364 "kill -CONT 1\n"
365 "for script in /.singularity.d/env/*.sh; do\n"
366 " if [ -f \"$script\" ]; then\n"
367 " . \"$script\"\n"
368 " fi\n"
369 "done\n"
370 "if test -x \"/.singularity.d/startscript\"; then\n"
371 " exec \"/.singularity.d/startscript\"\n"
372 "fi\n";
373
374 static const char *const kSingTest =
375 "#!/bin/sh\n"
376 "for script in /.singularity.d/env/*.sh; do\n"
377 " if [ -f \"$script\" ]; then\n"
378 " . \"$script\"\n"
379 " fi\n"
380 "done\n"
381 "if test -n \"${SINGULARITY_APPNAME:-}\"; then\n"
382 " if test -x \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/test\"; then\n"
383 " exec \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/test\" \"$@\"\n"
384 " else\n"
385 " echo \"No tests for contained app: ${SINGULARITY_APPNAME:-}\"\n"
386 " exit 1\n"
387 " fi\n"
388 "elif test -x \"/.singularity.d/test\"; then\n"
389 " exec \"/.singularity.d/test\" \"$@\"\n"
390 "else\n"
391 " echo \"No test found in container, executing /bin/sh -c true\"\n"
392 " exec /bin/sh -c true\n"
393 "fi\n";
394
395 static const char *const kSingEnv01Base =
396 "#!/bin/sh\n"
397 "# \n"
398 "# Copyright (c) 2017, SingularityWare, LLC. All rights reserved.\n"
399 "# Copyright (c) 2015-2017, Gregory M. Kurtzer. All rights reserved.\n"
400 "# \n"
401 "# Copyright (c) 2016-2017, The Regents of the University of California,\n"
402 "# through Lawrence Berkeley National Laboratory (subject to receipt of any\n"
403 "# required approvals from the U.S. Dept. of Energy). All rights reserved.\n"
404 "# \n";
405
406 static const char *const kSingEnv90 =
407 "#!/bin/sh\n"
408 "# Custom environment shell code should follow\n";
409
410 static const char *const kSingEnv95Apps =
411 "#!/bin/sh\n"
412 "#\n"
413 "# Copyright (c) 2017, SingularityWare, LLC. All rights reserved.\n"
414 "#\n"
415 "if test -n \"${SINGULARITY_APPNAME:-}\"; then\n"
416 " # The active app should be exported\n"
417 " export SINGULARITY_APPNAME\n"
418 " if test -d \"/scif/apps/${SINGULARITY_APPNAME:-}/\"; then\n"
419 " SCIF_APPS=\"/scif/apps\"\n"
420 " SCIF_APPROOT=\"/scif/apps/${SINGULARITY_APPNAME:-}\"\n"
421 " export SCIF_APPROOT SCIF_APPS\n"
422 " PATH=\"/scif/apps/${SINGULARITY_APPNAME:-}:$PATH\"\n"
423 " if test -d \"/scif/apps/${SINGULARITY_APPNAME:-}/bin\"; then\n"
424 " PATH=\"/scif/apps/${SINGULARITY_APPNAME:-}/bin:$PATH\"\n"
425 " fi\n"
426 " if test -d \"/scif/apps/${SINGULARITY_APPNAME:-}/lib\"; then\n"
427 " LD_LIBRARY_PATH=\"/scif/apps/${SINGULARITY_APPNAME:-}/lib:$LD_LIBRARY_PATH\"\n"
428 " export LD_LIBRARY_PATH\n"
429 " fi\n"
430 " if [ -f \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/env/01-base.sh\" ]; then\n"
431 " . \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/env/01-base.sh\"\n"
432 " fi\n"
433 " if [ -f \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/env/90-environment.sh\" ]; then\n"
434 " . \"/scif/apps/${SINGULARITY_APPNAME:-}/scif/env/90-environment.sh\"\n"
435 " fi\n"
436 " export PATH\n"
437 " else\n"
438 " echo \"Could not locate the container application: ${SINGULARITY_APPNAME}\"\n"
439 " exit 1\n"
440 " fi\n"
441 "fi\n";
442
443 static const char *const kSingEnv99Base =
444 "#!/bin/sh\n"
445 "# \n"
446 "# Copyright (c) 2017, SingularityWare, LLC. All rights reserved.\n"
447 "# Copyright (c) 2015-2017, Gregory M. Kurtzer. All rights reserved.\n"
448 "# \n"
449 "if [ -z \"$LD_LIBRARY_PATH\" ]; then\n"
450 " LD_LIBRARY_PATH=\"/.singularity.d/libs\"\n"
451 "else\n"
452 " LD_LIBRARY_PATH=\"$LD_LIBRARY_PATH:/.singularity.d/libs\"\n"
453 "fi\n"
454 "PS1=\"Singularity> \"\n"
455 "export LD_LIBRARY_PATH PS1\n";
456
457 static const char *const kSingEnv99Runtimevars =
458 "#!/bin/sh\n"
459 "if [ -n \"${SING_USER_DEFINED_PREPEND_PATH:-}\" ]; then\n"
460 "\tPATH=\"${SING_USER_DEFINED_PREPEND_PATH}:${PATH}\"\n"
461 "fi\n"
462 "if [ -n \"${SING_USER_DEFINED_APPEND_PATH:-}\" ]; then\n"
463 "\tPATH=\"${PATH}:${SING_USER_DEFINED_APPEND_PATH}\"\n"
464 "fi\n"
465 "if [ -n \"${SING_USER_DEFINED_PATH:-}\" ]; then\n"
466 "\tPATH=\"${SING_USER_DEFINED_PATH}\"\n"
467 "fi\n"
468 "unset SING_USER_DEFINED_PREPEND_PATH \\\n"
469 "\t SING_USER_DEFINED_APPEND_PATH \\\n"
470 "\t SING_USER_DEFINED_PATH\n"
471 "export PATH\n";
472
473 static const char *const kSingStartscript =
474 "#!/bin/sh\n";
475
476
477 string CommandOverlay::ShellEscape(const string &s) {
478 string escaped = ReplaceAll(s, "\\", "\\\\");
479 escaped = ReplaceAll(escaped, "\"", "\\\"");
480 escaped = ReplaceAll(escaped, "`", "\\`");
481 escaped = ReplaceAll(escaped, "$", "\\$");
482 return escaped;
483 }
484
485
486 string CommandOverlay::ArgsQuoted(const vector<string> &args) {
487 string quoted;
488 for (size_t i = 0; i < args.size(); ++i) {
489 if (i > 0) quoted += " ";
490 quoted += "\"" + ShellEscape(args[i]) + "\"";
491 }
492 return quoted;
493 }
494
495
496 string CommandOverlay::GenerateRunscript(
497 const vector<string> &entrypoint,
498 const vector<string> &cmd) {
499 string script = "#!/bin/sh\n";
500 if (!entrypoint.empty()) {
501 script += "OCI_ENTRYPOINT='" + ArgsQuoted(entrypoint) + "'\n";
502 } else {
503 script += "OCI_ENTRYPOINT=''\n";
504 }
505 if (!cmd.empty()) {
506 script += "OCI_CMD='" + ArgsQuoted(cmd) + "'\n";
507 } else {
508 script += "OCI_CMD=''\n";
509 }
510 script +=
511 "CMDLINE_ARGS=\"\"\n"
512 "# prepare command line arguments for evaluation\n"
513 "for arg in \"$@\"; do\n"
514 " CMDLINE_ARGS=\"${CMDLINE_ARGS} \\\"$arg\\\"\"\n"
515 "done\n"
516 "# ENTRYPOINT only - run entrypoint plus args\n"
517 "if [ -z \"$OCI_CMD\" ] && [ -n \"$OCI_ENTRYPOINT\" ]; then\n"
518 " if [ $# -gt 0 ]; then\n"
519 " SINGULARITY_OCI_RUN=\"${OCI_ENTRYPOINT} ${CMDLINE_ARGS}\"\n"
520 " else\n"
521 " SINGULARITY_OCI_RUN=\"${OCI_ENTRYPOINT}\"\n"
522 " fi\n"
523 "fi\n"
524 "# CMD only - run CMD or override with args\n"
525 "if [ -n \"$OCI_CMD\" ] && [ -z \"$OCI_ENTRYPOINT\" ]; then\n"
526 " if [ $# -gt 0 ]; then\n"
527 " SINGULARITY_OCI_RUN=\"${CMDLINE_ARGS}\"\n"
528 " else\n"
529 " SINGULARITY_OCI_RUN=\"${OCI_CMD}\"\n"
530 " fi\n"
531 "fi\n"
532 "# ENTRYPOINT and CMD - run ENTRYPOINT with CMD as default args\n"
533 "# override with user provided args\n"
534 "if [ $# -gt 0 ]; then\n"
535 " SINGULARITY_OCI_RUN=\"${OCI_ENTRYPOINT} ${CMDLINE_ARGS}\"\n"
536 "else\n"
537 " SINGULARITY_OCI_RUN=\"${OCI_ENTRYPOINT} ${OCI_CMD}\"\n"
538 "fi\n"
539 "# Evaluate shell expressions first and set arguments accordingly,\n"
540 "# then execute final command as first container process\n"
541 "eval \"set ${SINGULARITY_OCI_RUN}\"\n"
542 "exec \"$@\"\n";
543 return script;
544 }
545
546
547 string CommandOverlay::GenerateEnvScript(const vector<string> &env) {
548 string script = "#!/bin/sh\n";
549 for (size_t i = 0; i < env.size(); ++i) {
550 const string &element = env[i];
551 const size_t eq = element.find('=');
552 if (eq == string::npos) {
553 // No '=' — just export empty default
554 script += "export " + element + "=\"${" + element + ":-}\"\n";
555 } else {
556 const string key = element.substr(0, eq);
557 const string val = element.substr(eq + 1);
558 if (key == "PATH") {
559 script += "export PATH=\"" + ShellEscape(val) + "\"\n";
560 } else {
561 script += "export " + key + "=\"${" + key + ":-\""
562 + ShellEscape(val) + "\"}\"\n";
563 }
564 }
565 }
566 return script;
567 }
568
569
570 OverlayEntry CommandOverlay::MakeDirEntry(const string &path,
571 const string &parent) {
572 OverlayEntry oe;
573 oe.path = path;
574 oe.parent = parent;
575 oe.is_whiteout = false;
576 oe.is_opaque_dir = false;
577 oe.entry.name_ = NameString(GetFileName(path));
578 oe.entry.mode_ = S_IFDIR | 0755;
579 oe.entry.uid_ = 0;
580 oe.entry.gid_ = 0;
581 oe.entry.size_ = 4096;
582 oe.entry.mtime_ = time(NULL);
583 oe.entry.linkcount_ = 2;
584 return oe;
585 }
586
587
588 /**
589 * Helper class to collect spooler results for singularity dotfiles.
590 * Registered as a listener on the spooler, it stores the content hash
591 * for each processed file keyed by path.
592 */
593 class SingularitySpoolerSink {
594 public:
595 void OnFileProcessed(const upload::SpoolerResult &result) {
596 hashes_[result.local_path] = result.content_hash;
597 }
598
599 bool GetHash(const string &path, shash::Any *hash) const {
600 const map<string, shash::Any>::const_iterator it = hashes_.find(path);
601 if (it == hashes_.end()) return false;
602 *hash = it->second;
603 return true;
604 }
605
606 private:
607 map<string, shash::Any> hashes_;
608 };
609
610
611 OverlayEntry CommandOverlay::MakeFileEntry(const string &path,
612 const string &parent,
613 const string &content,
614 upload::Spooler *spooler) {
615 // Process the content through the spooler to get a content hash.
616 // We use a StringIngestionSource so no temp file is needed.
617 // The spooler is used in a synchronous fashion: process one file,
618 // wait, then read the result via a temporary listener.
619 SingularitySpoolerSink sink;
620 typename upload::Spooler::CallbackPtr cb = spooler->RegisterListener(
621 &SingularitySpoolerSink::OnFileProcessed, &sink);
622
623 spooler->Process(
624 new StringIngestionSource(content, path),
625 false /* no chunking */);
626 spooler->WaitForUpload();
627 spooler->UnregisterListener(cb);
628
629 shash::Any content_hash;
630 if (!sink.GetHash(path, &content_hash)) {
631 LogCvmfs(kLogCvmfs, kLogStderr,
632 "Failed to get content hash for singularity file %s",
633 path.c_str());
634 }
635
636 OverlayEntry oe;
637 oe.path = path;
638 oe.parent = parent;
639 oe.is_whiteout = false;
640 oe.is_opaque_dir = false;
641 oe.entry.name_ = NameString(GetFileName(path));
642 oe.entry.mode_ = S_IFREG | 0755;
643 oe.entry.uid_ = 0;
644 oe.entry.gid_ = 0;
645 oe.entry.size_ = content.size();
646 oe.entry.mtime_ = time(NULL);
647 oe.entry.linkcount_ = 1;
648 oe.entry.checksum_ = content_hash;
649 return oe;
650 }
651
652
653 OverlayEntry CommandOverlay::MakeSymlinkEntry(const string &path,
654 const string &parent,
655 const string &target) {
656 OverlayEntry oe;
657 oe.path = path;
658 oe.parent = parent;
659 oe.is_whiteout = false;
660 oe.is_opaque_dir = false;
661 oe.entry.name_ = NameString(GetFileName(path));
662 oe.entry.mode_ = S_IFLNK | 0777;
663 oe.entry.uid_ = 0;
664 oe.entry.gid_ = 0;
665 oe.entry.size_ = target.size();
666 oe.entry.mtime_ = time(NULL);
667 oe.entry.linkcount_ = 1;
668 oe.entry.symlink_ = LinkString(target);
669 return oe;
670 }
671
672
673 bool CommandOverlay::InjectSingularityDotfiles(
674 const string &oci_config_path,
675 upload::Spooler *spooler,
676 map<string, OverlayEntry> *merged) {
677 // ---------------------------------------------------------------
678 // 1. Parse the OCI image config JSON
679 // ---------------------------------------------------------------
680 const int fd = open(oci_config_path.c_str(), O_RDONLY);
681 if (fd < 0) {
682 LogCvmfs(kLogCvmfs, kLogStderr,
683 "Failed to open OCI config file %s", oci_config_path.c_str());
684 return false;
685 }
686 string config_json;
687 if (!SafeReadToString(fd, &config_json)) {
688 close(fd);
689 LogCvmfs(kLogCvmfs, kLogStderr,
690 "Failed to read OCI config from %s", oci_config_path.c_str());
691 return false;
692 }
693 close(fd);
694
695 const UniquePtr<JsonDocument> json(JsonDocument::Create(config_json));
696 if (!json.IsValid()) {
697 LogCvmfs(kLogCvmfs, kLogStderr,
698 "Failed to parse OCI config JSON from %s",
699 oci_config_path.c_str());
700 return false;
701 }
702
703 // Extract config.Env, config.Entrypoint, config.Cmd
704 vector<string> entrypoint;
705 vector<string> cmd;
706 vector<string> env;
707
708 const JSON *config_obj =
709 JsonDocument::SearchInObject(json->root(), "config", JSON_OBJECT);
710 if (config_obj != NULL) {
711 const JSON *ep_arr =
712 JsonDocument::SearchInObject(config_obj, "Entrypoint", JSON_ARRAY);
713 if (ep_arr != NULL) {
714 for (JSON::const_iterator it = ep_arr->begin();
715 it != ep_arr->end(); ++it) {
716 if (it->is_string()) entrypoint.push_back(it->get<string>());
717 }
718 }
719 const JSON *cmd_arr =
720 JsonDocument::SearchInObject(config_obj, "Cmd", JSON_ARRAY);
721 if (cmd_arr != NULL) {
722 for (JSON::const_iterator it = cmd_arr->begin();
723 it != cmd_arr->end(); ++it) {
724 if (it->is_string()) cmd.push_back(it->get<string>());
725 }
726 }
727 const JSON *env_arr =
728 JsonDocument::SearchInObject(config_obj, "Env", JSON_ARRAY);
729 if (env_arr != NULL) {
730 for (JSON::const_iterator it = env_arr->begin();
731 it != env_arr->end(); ++it) {
732 if (it->is_string()) env.push_back(it->get<string>());
733 }
734 }
735 }
736
737 LogCvmfs(kLogCvmfs, kLogStdout,
738 "Injecting Singularity dotfiles (Entrypoint: %zu, Cmd: %zu, "
739 "Env: %zu entries)",
740 entrypoint.size(), cmd.size(), env.size());
741
742 // ---------------------------------------------------------------
743 // 2. Create directory entries
744 // ---------------------------------------------------------------
745 (*merged)[".singularity.d"] =
746 MakeDirEntry(".singularity.d", "");
747 (*merged)[".singularity.d/libs"] =
748 MakeDirEntry(".singularity.d/libs", ".singularity.d");
749 (*merged)[".singularity.d/actions"] =
750 MakeDirEntry(".singularity.d/actions", ".singularity.d");
751 (*merged)[".singularity.d/env"] =
752 MakeDirEntry(".singularity.d/env", ".singularity.d");
753
754 // Also create common FHS directories if missing
755 const char *fhs_dirs[] = {
756 "dev", "proc", "root", "var", "var/tmp", "tmp", "etc", "sys", "home",
757 NULL};
758 for (int i = 0; fhs_dirs[i] != NULL; ++i) {
759 const string d = fhs_dirs[i];
760 if (merged->find(d) == merged->end()) {
761 const string par = (d.find('/') != string::npos)
762 ? GetParentPath(d)
763 : "";
764 (*merged)[d] = MakeDirEntry(d, par);
765 }
766 }
767
768 // ---------------------------------------------------------------
769 // 3. Create file entries (content is uploaded via spooler)
770 // ---------------------------------------------------------------
771 // Action scripts
772 (*merged)[".singularity.d/actions/exec"] =
773 MakeFileEntry(".singularity.d/actions/exec",
774 ".singularity.d/actions", kSingExec, spooler);
775 (*merged)[".singularity.d/actions/run"] =
776 MakeFileEntry(".singularity.d/actions/run",
777 ".singularity.d/actions", kSingRun, spooler);
778 (*merged)[".singularity.d/actions/shell"] =
779 MakeFileEntry(".singularity.d/actions/shell",
780 ".singularity.d/actions", kSingShell, spooler);
781 (*merged)[".singularity.d/actions/start"] =
782 MakeFileEntry(".singularity.d/actions/start",
783 ".singularity.d/actions", kSingStart, spooler);
784 (*merged)[".singularity.d/actions/test"] =
785 MakeFileEntry(".singularity.d/actions/test",
786 ".singularity.d/actions", kSingTest, spooler);
787
788 // Environment scripts
789 (*merged)[".singularity.d/env/01-base.sh"] =
790 MakeFileEntry(".singularity.d/env/01-base.sh",
791 ".singularity.d/env", kSingEnv01Base, spooler);
792 (*merged)[".singularity.d/env/90-environment.sh"] =
793 MakeFileEntry(".singularity.d/env/90-environment.sh",
794 ".singularity.d/env", kSingEnv90, spooler);
795 (*merged)[".singularity.d/env/91-environment.sh"] =
796 MakeFileEntry(".singularity.d/env/91-environment.sh",
797 ".singularity.d/env", kSingEnv90, spooler);
798 (*merged)[".singularity.d/env/95-apps.sh"] =
799 MakeFileEntry(".singularity.d/env/95-apps.sh",
800 ".singularity.d/env", kSingEnv95Apps, spooler);
801 (*merged)[".singularity.d/env/99-base.sh"] =
802 MakeFileEntry(".singularity.d/env/99-base.sh",
803 ".singularity.d/env", kSingEnv99Base, spooler);
804 (*merged)[".singularity.d/env/99-runtimevars.sh"] =
805 MakeFileEntry(".singularity.d/env/99-runtimevars.sh",
806 ".singularity.d/env", kSingEnv99Runtimevars, spooler);
807
808 // OCI-config-dependent files
809 const string runscript = GenerateRunscript(entrypoint, cmd);
810 (*merged)[".singularity.d/runscript"] =
811 MakeFileEntry(".singularity.d/runscript",
812 ".singularity.d", runscript, spooler);
813 (*merged)[".singularity.d/startscript"] =
814 MakeFileEntry(".singularity.d/startscript",
815 ".singularity.d", kSingStartscript, spooler);
816
817 const string env_script = GenerateEnvScript(env);
818 (*merged)[".singularity.d/env/10-docker2singularity.sh"] =
819 MakeFileEntry(".singularity.d/env/10-docker2singularity.sh",
820 ".singularity.d/env", env_script, spooler);
821
822 // ---------------------------------------------------------------
823 // 4. Create symlinks
824 // ---------------------------------------------------------------
825 // Only create if not already present from a layer
826 if (merged->find("singularity") == merged->end()) {
827 (*merged)["singularity"] =
828 MakeSymlinkEntry("singularity", "",
829 ".singularity.d/runscript");
830 }
831 if (merged->find(".run") == merged->end()) {
832 (*merged)[".run"] =
833 MakeSymlinkEntry(".run", "",
834 ".singularity.d/actions/run");
835 }
836 if (merged->find(".shell") == merged->end()) {
837 (*merged)[".shell"] =
838 MakeSymlinkEntry(".shell", "",
839 ".singularity.d/actions/shell");
840 }
841 if (merged->find(".exec") == merged->end()) {
842 (*merged)[".exec"] =
843 MakeSymlinkEntry(".exec", "",
844 ".singularity.d/actions/exec");
845 }
846 if (merged->find(".test") == merged->end()) {
847 (*merged)[".test"] =
848 MakeSymlinkEntry(".test", "",
849 ".singularity.d/actions/test");
850 }
851 if (merged->find("environment") == merged->end()) {
852 (*merged)["environment"] =
853 MakeSymlinkEntry("environment", "",
854 ".singularity.d/env/90-environment.sh");
855 }
856
857 LogCvmfs(kLogCvmfs, kLogStdout,
858 "Injected Singularity dotfiles into merged overlay");
859 return true;
860 }
861
862
863 bool CommandOverlay::PublishMergedEntries(
864 catalog::WritableCatalogManager *catalog_mgr,
865 const map<string, OverlayEntry> &merged,
866 const string &dest_path) const {
867 // dest_path starts with '/' for LookupPath, but AddDirectory/AddFile
868 // expect parent_directory without leading '/' because MakeRelativePath
869 // (called internally) prepends it. Create a stripped copy for add calls.
870 const string dest_path_rel = (!dest_path.empty() && dest_path[0] == '/')
871 ? dest_path.substr(1) : dest_path;
872
873 // Ensure the destination directory itself exists in the catalog.
874 // Check if dest_path already exists; if not, create it.
875 catalog::DirectoryEntry dest_dirent;
876 if (!catalog_mgr->LookupPath(dest_path, catalog::kLookupDefault,
877 &dest_dirent)) {
878 // Create the destination directory (and any missing parents)
879 // Walk up to find the deepest existing ancestor
880 vector<string> dirs_to_create;
881 string check_path = dest_path;
882 while (!check_path.empty() && check_path != "/") {
883 catalog::DirectoryEntry check_dirent;
884 if (catalog_mgr->LookupPath(check_path, catalog::kLookupDefault,
885 &check_dirent)) {
886 break;
887 }
888 dirs_to_create.push_back(check_path);
889 check_path = GetParentPath(check_path);
890 }
891
892 // Create directories from outermost to innermost
893 for (int i = static_cast<int>(dirs_to_create.size()) - 1; i >= 0; --i) {
894 const string &dir = dirs_to_create[i];
895 string parent = GetParentPath(dir);
896 const string name = GetFileName(dir);
897
898 // Strip leading '/' — AddDirectory calls MakeRelativePath which adds it
899 if (!parent.empty() && parent[0] == '/') {
900 parent = parent.substr(1);
901 }
902
903 catalog::DirectoryEntryBase new_dir;
904 new_dir.name_.Assign(name.data(), name.length());
905 new_dir.mode_ = S_IFDIR | 0755;
906 new_dir.uid_ = 0;
907 new_dir.gid_ = 0;
908 new_dir.size_ = 4096;
909 new_dir.mtime_ = time(NULL);
910 new_dir.linkcount_ = 2;
911
912 catalog_mgr->AddDirectory(new_dir, XattrList(), parent);
913 LogCvmfs(kLogCvmfs, kLogDebug,
914 "Created destination directory: %s", dir.c_str());
915 }
916 }
917
918 // Add entries in sorted order. The map is sorted lexicographically,
919 // so parent directories appear before their children.
920 for (map<string, OverlayEntry>::const_iterator it = merged.begin();
921 it != merged.end(); ++it) {
922 const OverlayEntry &oe = it->second;
923
924 // Build parent path without leading '/' for AddDirectory/AddFile
925 // (MakeRelativePath inside those functions adds it back)
926 const string parent_path = oe.parent.empty()
927 ? dest_path_rel
928 : dest_path_rel + "/" + oe.parent;
929
930 if (oe.entry.IsDirectory()) {
931 catalog_mgr->AddDirectory(oe.entry, oe.xattrs, parent_path);
932 } else {
933 catalog_mgr->AddFile(
934 static_cast<const catalog::DirectoryEntryBase &>(oe.entry),
935 oe.xattrs, parent_path);
936 }
937 }
938
939 // Turn the destination directory into a nested catalog so that the overlay
940 // content lives in its own catalog database file.
941
942 // Add a .cvmfscatalog marker file
943 catalog::DirectoryEntryBase catalog_marker;
944 catalog_marker.name_ = NameString(".cvmfscatalog");
945 catalog_marker.mode_ = (S_IFREG | 0666);
946 catalog_marker.size_ = 0;
947 catalog_marker.mtime_ = time(NULL);
948 catalog_marker.uid_ = 0;
949 catalog_marker.gid_ = 0;
950 catalog_marker.linkcount_ = 1;
951 // Hash of the compressed empty file
952 catalog_marker.checksum_ = shash::MkFromHexPtr(
953 shash::HexPtr("e8ec3d88b62ebf526e4e5a4ff6162a3aa48a6b78"),
954 shash::kSuffixNone); // hash of ""
955 catalog_mgr->AddFile(catalog_marker, XattrList(), dest_path_rel);
956 // CreateNestedCatalog calls MakeRelativePath internally which prepends '/'.
957 // Pass the stripped version to avoid a double leading slash.
958 catalog_mgr->CreateNestedCatalog(dest_path_rel);
959
960 LogCvmfs(kLogCvmfs, kLogStdout,
961 "Published %zu entries under %s (nested catalog)",
962 merged.size(), dest_path.c_str());
963 return true;
964 }
965
966
967 catalog::Catalog *CommandOverlay::LoadCatalogForPath(
968 const string &repo_base,
969 const string &subdirectory,
970 const string &temp_dir,
971 const shash::Any &root_hash) {
972 // Fetch the root catalog from the repository
973 const string hash_path = "data/" + root_hash.MakePath();
974 string catalog_path;
975
976 if (IsHttpUrl(repo_base)) {
977 // Download and decompress from remote
978 const string url = repo_base + "/" + hash_path;
979 catalog_path = temp_dir + "/" + root_hash.ToString();
980
981 cvmfs::PathSink pathsink(catalog_path);
982 download::JobInfo download_job(&url, true, false, &root_hash, &pathsink);
983 const download::Failures retval = download_manager()->Fetch(&download_job);
984 if (retval != download::kFailOk) {
985 LogCvmfs(kLogCvmfs, kLogStderr, "Failed to download catalog %s (%d)",
986 root_hash.ToString().c_str(), retval);
987 return NULL;
988 }
989 } else {
990 // Local repository: decompress the catalog
991 const string source_path = repo_base + "/" + hash_path;
992 catalog_path = temp_dir + "/" + root_hash.ToString();
993
994 if (!zlib::DecompressPath2Path(source_path, catalog_path)) {
995 LogCvmfs(kLogCvmfs, kLogStderr,
996 "Failed to decompress catalog %s from %s",
997 root_hash.ToString().c_str(), source_path.c_str());
998 return NULL;
999 }
1000 }
1001
1002 catalog::Catalog *catalog = catalog::Catalog::AttachFreely(
1003 subdirectory, catalog_path, root_hash);
1004 if (catalog == NULL) {
1005 LogCvmfs(kLogCvmfs, kLogStderr,
1006 "Failed to attach catalog for path %s",
1007 subdirectory.c_str());
1008 unlink(catalog_path.c_str());
1009 return NULL;
1010 }
1011
1012 catalog->TakeDatabaseFileOwnership();
1013 return catalog;
1014 }
1015
1016
1017 catalog::Catalog *CommandOverlay::FindCatalogForLayer(
1018 const string &repo_base,
1019 const string &temp_dir,
1020 catalog::Catalog *catalog,
1021 const string &layer_path,
1022 vector<catalog::Catalog *> *loaded_catalogs) {
1023 // First try a direct lookup in the given catalog
1024 catalog::DirectoryEntry test_entry;
1025 const PathString ps_layer(layer_path.data(), layer_path.length());
1026 if (catalog->LookupPath(ps_layer, &test_entry)) {
1027 return catalog;
1028 }
1029
1030 // The path was not found directly. Walk the path components *below*
1031 // this catalog's mountpoint to find a nested catalog mountpoint that
1032 // is an ancestor of layer_path.
1033 const string mountpoint = catalog->mountpoint().ToString();
1034
1035 // Verify layer_path starts with the mountpoint (or mountpoint is empty
1036 // for the root catalog)
1037 if (!mountpoint.empty() && layer_path.substr(0, mountpoint.length())
1038 != mountpoint) {
1039 return NULL;
1040 }
1041
1042 // Get the suffix of layer_path below the mountpoint
1043 const string suffix = mountpoint.empty() ? layer_path
1044 : layer_path.substr(
1045 mountpoint.length());
1046 const vector<string> components = SplitString(suffix, '/');
1047 string prefix = mountpoint;
1048 for (size_t i = 0; i < components.size(); ++i) {
1049 if (components[i].empty()) continue;
1050 prefix += "/" + components[i];
1051
1052 catalog::DirectoryEntry dir_entry;
1053 const PathString ps_prefix(prefix.data(), prefix.length());
1054 if (!catalog->LookupPath(ps_prefix, &dir_entry)) {
1055 break;
1056 }
1057
1058 if (dir_entry.IsNestedCatalogMountpoint()) {
1059 shash::Any nested_hash;
1060 uint64_t nested_size;
1061 if (!catalog->FindNested(ps_prefix, &nested_hash, &nested_size)) {
1062 LogCvmfs(kLogCvmfs, kLogStderr,
1063 "Failed to find nested catalog hash for %s", prefix.c_str());
1064 return NULL;
1065 }
1066
1067 catalog::Catalog *nested = LoadCatalogForPath(
1068 repo_base, prefix, temp_dir, nested_hash);
1069 if (nested == NULL) {
1070 LogCvmfs(kLogCvmfs, kLogStderr,
1071 "Failed to load nested catalog at %s", prefix.c_str());
1072 return NULL;
1073 }
1074 loaded_catalogs->push_back(nested);
1075
1076 // Recurse: the layer path may be directly in this nested catalog
1077 // or in an even deeper nested catalog
1078 return FindCatalogForLayer(
1079 repo_base, temp_dir, nested, layer_path, loaded_catalogs);
1080 }
1081 }
1082
1083 LogCvmfs(kLogCvmfs, kLogStderr, "Layer path not found: %s",
1084 layer_path.c_str());
1085 return NULL;
1086 }
1087
1088
1089 int CommandOverlay::Main(const ArgumentList &args) {
1090 // Parse publish workflow parameters
1091 const string spooler_definition_str = *args.find('r')->second;
1092 const string stratum0 = *args.find('w')->second;
1093 const string temp_dir = MakeCanonicalPath(*args.find('t')->second);
1094 const string manifest_path = *args.find('o')->second;
1095 const shash::Any base_hash =
1096 shash::MkFromHexPtr(shash::HexPtr(*args.find('b')->second),
1097 shash::kSuffixCatalog);
1098 const string public_keys = *args.find('K')->second;
1099 const string repo_name = *args.find('N')->second;
1100
1101 // Parse overlay-specific parameters
1102 const string layers_str = *args.find('l')->second;
1103 string dest_path = MakeCanonicalPath(*args.find('d')->second);
1104 // Ensure dest_path starts with exactly one '/'
1105 while (dest_path.length() > 1 && dest_path[0] == '/' && dest_path[1] == '/') {
1106 dest_path = dest_path.substr(1);
1107 }
1108 if (dest_path.empty() || dest_path[0] != '/') {
1109 dest_path = "/" + dest_path;
1110 }
1111 shash::Algorithms hash_algorithm = shash::kSha1;
1112 if (args.find('e') != args.end()) {
1113 hash_algorithm = shash::ParseHashAlgorithm(*args.find('e')->second);
1114 if (hash_algorithm == shash::kAny) {
1115 PrintError("unknown hash algorithm");
1116 return 1;
1117 }
1118 }
1119 zlib::Algorithms compression_alg = zlib::kZlibDefault;
1120 if (args.find('Z') != args.end()) {
1121 compression_alg = zlib::ParseCompressionAlgorithm(
1122 *args.find('Z')->second);
1123 }
1124
1125 const string oci_config_path =
1126 (args.count('c') > 0) ? *args.find('c')->second : "";
1127 const bool skip_singularity = (args.count('S') > 0);
1128
1129 // Parse comma-separated layer paths
1130 const vector<string> layers = SplitString(layers_str, ',');
1131 if (layers.empty()) {
1132 LogCvmfs(kLogCvmfs, kLogStderr, "No layers specified");
1133 return 1;
1134 }
1135
1136 LogCvmfs(kLogCvmfs, kLogStdout, "Overlay merge of %zu layers into %s",
1137 layers.size(), dest_path.c_str());
1138 for (size_t i = 0; i < layers.size(); ++i) {
1139 LogCvmfs(kLogCvmfs, kLogStdout, " Layer %zu: %s", i, layers[i].c_str());
1140 }
1141
1142 // Set up spoolers (following the ingest pattern)
1143 perf::StatisticsTemplate publish_statistics("publish", this->statistics());
1144
1145 const upload::SpoolerDefinition spooler_definition(
1146 spooler_definition_str, hash_algorithm, compression_alg,
1147 false /* generate_legacy_bulk_chunks */,
1148 false /* use_file_chunking */,
1149 0, 0, 0 /* chunk sizes: unused */,
1150 "" /* session_token_file */, "" /* key_file */);
1151
1152 const upload::SpoolerDefinition spooler_definition_catalogs(
1153 spooler_definition.Dup2DefaultCompression());
1154
1155 const UniquePtr<upload::Spooler> spooler_files(
1156 upload::Spooler::Construct(spooler_definition, &publish_statistics));
1157 if (!spooler_files.IsValid()) {
1158 PrintError("Failed to create file spooler");
1159 return 3;
1160 }
1161 const UniquePtr<upload::Spooler> spooler_catalogs(
1162 upload::Spooler::Construct(spooler_definition_catalogs,
1163 &publish_statistics));
1164 if (!spooler_catalogs.IsValid()) {
1165 PrintError("Failed to create catalog spooler");
1166 return 3;
1167 }
1168
1169 // Initialize download manager and signature manager
1170 const bool follow_redirects = (args.count('L') > 0);
1171 const string proxy = (args.count('@') > 0) ? *args.find('@')->second : "";
1172 if (!InitDownloadManager(follow_redirects, proxy)) {
1173 PrintError("Failed to initialize download manager");
1174 return 3;
1175 }
1176 if (!InitSignatureManager(public_keys)) {
1177 PrintError("Failed to initialize signature manager");
1178 return 3;
1179 }
1180
1181 // Fetch repository manifest
1182 const UniquePtr<manifest::Manifest> manifest(
1183 FetchRemoteManifest(stratum0, repo_name, base_hash));
1184 if (!manifest.IsValid()) {
1185 PrintError("Failed to load repository manifest");
1186 return 3;
1187 }
1188
1189 const string old_root_hash = manifest->catalog_hash().ToString(true);
1190 LogCvmfs(kLogCvmfs, kLogStdout, "Root catalog hash: %s",
1191 old_root_hash.c_str());
1192
1193 // Load root catalog for reading layer entries
1194 map<string, OverlayEntry> merged;
1195 catalog::Catalog *root_catalog = LoadCatalogForPath(
1196 stratum0, "", temp_dir, manifest->catalog_hash());
1197 if (root_catalog == NULL) {
1198 PrintError("Failed to load root catalog");
1199 return 1;
1200 }
1201
1202 // Process layers bottom-to-top
1203 for (size_t i = 0; i < layers.size(); ++i) {
1204 string layer_path = MakeCanonicalPath(layers[i]);
1205 // Ensure layer path starts with exactly one '/'
1206 while (layer_path.length() > 1
1207 && layer_path[0] == '/' && layer_path[1] == '/') {
1208 layer_path = layer_path.substr(1);
1209 }
1210 if (layer_path.empty() || layer_path[0] != '/') {
1211 layer_path = "/" + layer_path;
1212 }
1213
1214 LogCvmfs(kLogCvmfs, kLogStdout, "Processing layer %zu: %s",
1215 i, layer_path.c_str());
1216
1217 map<string, OverlayEntry> layer_entries;
1218
1219 // Find the catalog that contains this layer path (may be nested)
1220 vector<catalog::Catalog *> loaded_catalogs;
1221 catalog::Catalog *layer_catalog = FindCatalogForLayer(
1222 stratum0, temp_dir, root_catalog, layer_path, &loaded_catalogs);
1223 if (layer_catalog == NULL) {
1224 for (size_t j = 0; j < loaded_catalogs.size(); ++j)
1225 delete loaded_catalogs[j];
1226 delete root_catalog;
1227 return 1;
1228 }
1229
1230 catalog::DirectoryEntry subdir_entry;
1231 const PathString ps_layer_path(layer_path.data(), layer_path.length());
1232 if (!layer_catalog->LookupPath(ps_layer_path, &subdir_entry)) {
1233 LogCvmfs(kLogCvmfs, kLogStderr,
1234 "Unexpected: layer path not found after catalog resolution: %s",
1235 layer_path.c_str());
1236 for (size_t j = 0; j < loaded_catalogs.size(); ++j)
1237 delete loaded_catalogs[j];
1238 delete root_catalog;
1239 return 1;
1240 }
1241
1242 // Check if the layer path itself is a nested catalog mountpoint;
1243 // if so, load that catalog and read its entries.
1244 if (subdir_entry.IsNestedCatalogMountpoint()) {
1245 shash::Any nested_hash;
1246 uint64_t nested_size;
1247 if (!layer_catalog->FindNested(ps_layer_path, &nested_hash,
1248 &nested_size)) {
1249 LogCvmfs(kLogCvmfs, kLogStderr,
1250 "Failed to find nested catalog for %s",
1251 layer_path.c_str());
1252 for (size_t j = 0; j < loaded_catalogs.size(); ++j)
1253 delete loaded_catalogs[j];
1254 delete root_catalog;
1255 return 1;
1256 }
1257
1258 catalog::Catalog *nested_catalog = LoadCatalogForPath(
1259 stratum0, layer_path, temp_dir, nested_hash);
1260 if (nested_catalog == NULL) {
1261 LogCvmfs(kLogCvmfs, kLogStderr,
1262 "Failed to load nested catalog for %s",
1263 layer_path.c_str());
1264 for (size_t j = 0; j < loaded_catalogs.size(); ++j)
1265 delete loaded_catalogs[j];
1266 delete root_catalog;
1267 return 1;
1268 }
1269
1270 ReadCatalogEntries(nested_catalog, layer_path, "",
1271 stratum0, temp_dir, &layer_entries);
1272 delete nested_catalog;
1273 } else {
1274 ReadCatalogEntries(layer_catalog, layer_path, "",
1275 stratum0, temp_dir, &layer_entries);
1276 }
1277
1278 // Clean up any intermediate catalogs loaded during hierarchy walk
1279 for (size_t j = 0; j < loaded_catalogs.size(); ++j)
1280 delete loaded_catalogs[j];
1281
1282 LogCvmfs(kLogCvmfs, kLogStdout, " Read %zu entries from layer %s",
1283 layer_entries.size(), layer_path.c_str());
1284
1285 MergeLayer(layer_entries, &merged);
1286
1287 LogCvmfs(kLogCvmfs, kLogStdout, " Merged total: %zu entries",
1288 merged.size());
1289 }
1290
1291 delete root_catalog;
1292
1293 // Inject Singularity dotfiles if requested
1294 if (!oci_config_path.empty() && !skip_singularity) {
1295 if (!InjectSingularityDotfiles(oci_config_path,
1296 spooler_files.weak_ref(), &merged)) {
1297 PrintError("Failed to inject Singularity dotfiles");
1298 return 4;
1299 }
1300 }
1301
1302 // Set up WritableCatalogManager and publish merged entries
1303 LogCvmfs(kLogCvmfs, kLogStdout,
1304 "Publishing %zu merged entries under %s",
1305 merged.size(), dest_path.c_str());
1306
1307 catalog::WritableCatalogManager catalog_manager(
1308 base_hash, stratum0, temp_dir,
1309 spooler_catalogs.weak_ref(), download_manager(),
1310 false /* enforce_limits */,
1311 0 /* nested_kcatalog_limit */,
1312 0 /* root_kcatalog_limit */,
1313 0 /* file_mbyte_limit */,
1314 statistics(),
1315 false /* is_balanceable */,
1316 0 /* max_weight */, 0 /* min_weight */);
1317 catalog_manager.Init();
1318
1319 if (!PublishMergedEntries(&catalog_manager, merged, dest_path)) {
1320 PrintError("Failed to publish merged entries");
1321 return 5;
1322 }
1323
1324 // Commit catalog changes and produce updated manifest
1325 catalog_manager.PrecalculateListings();
1326 if (!catalog_manager.Commit(false, 0, manifest.weak_ref())) {
1327 PrintError("Failed to commit catalog changes");
1328 return 5;
1329 }
1330
1331 // Finalize spoolers
1332 LogCvmfs(kLogCvmfs, kLogStdout, "Waiting for uploads to finish...");
1333 spooler_files->WaitForUpload();
1334 spooler_catalogs->WaitForUpload();
1335 spooler_files->FinalizeSession(false);
1336
1337 const string new_root_hash = manifest->catalog_hash().ToString(true);
1338 if (!spooler_catalogs->FinalizeSession(true, old_root_hash, new_root_hash,
1339 RepositoryTag())) {
1340 PrintError("Failed to finalize session");
1341 return 5;
1342 }
1343
1344 // Export manifest
1345 if (!manifest->Export(manifest_path)) {
1346 PrintError("Failed to export manifest");
1347 return 6;
1348 }
1349
1350 LogCvmfs(kLogCvmfs, kLogStdout,
1351 "Overlay published successfully to %s", dest_path.c_str());
1352 return 0;
1353 }
1354
1355 } // namespace swissknife
1356