GCC Code Coverage Report


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