Deploy.vala 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. namespace Usm.Installer {
  4. /**
  5. * Canonical source of the self-contained USM installer script baked into
  6. * deploy images by default (the compiled form of `installer/` in the usm
  7. * source tree), hosted at packages.astrologue.nz; override it per deploy
  8. * with `--installer-url` (whose `file://` form is the sanctioned
  9. * local-testing path).
  10. */
  11. public const string CANONICAL_URL = "https://packages.astrologue.nz/install-usm.sh";
  12. }
  13. /** Default container base image, overridable with `--base`. */
  14. const string DEPLOY_DEFAULT_BASE_IMAGE = "registry.fedoraproject.org/fedora:43";
  15. /** Deploy context directory created inside the packaged project. */
  16. const string DEPLOY_CONTEXT_DIRECTORY = ".usm-deploy";
  17. /** Where the USM installer tree lives inside images (installer TARGET_DIR). */
  18. const string DEPLOY_USM_PREFIX = "/opt/usm";
  19. /** In-image location that file:// repository trees are rewritten to. */
  20. const string DEPLOY_REPO_TREES_PATH = "/usr/share/usm-repos";
  21. /**
  22. * The system package managers `deploy --spm` can wire into the image, one
  23. * per shim the installer ships; {@link DeploySpm.NONE} wires none and the
  24. * image resolves from USM repositories alone.
  25. */
  26. private enum DeploySpm {
  27. DNF,
  28. APT,
  29. APK,
  30. EMERGE,
  31. NONE;
  32. /**
  33. * Whether a `--spm` value names a choice, setting {@link parsed} to it
  34. * (and reporting the valid values) — {@link DeploySpm.NONE} on failure.
  35. */
  36. public static bool parse(string value, out DeploySpm parsed) {
  37. parsed = DeploySpm.NONE;
  38. switch(value) {
  39. case "dnf":
  40. parsed = DeploySpm.DNF;
  41. return true;
  42. case "apt":
  43. parsed = DeploySpm.APT;
  44. return true;
  45. case "apk":
  46. parsed = DeploySpm.APK;
  47. return true;
  48. case "emerge":
  49. parsed = DeploySpm.EMERGE;
  50. return true;
  51. case "none":
  52. parsed = DeploySpm.NONE;
  53. return true;
  54. default:
  55. printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge or none)\n");
  56. return false;
  57. }
  58. }
  59. /** The lowercase name completing `usm-spm-` in shim paths. */
  60. public string shim_name() {
  61. switch(this) {
  62. case DeploySpm.DNF:
  63. return "dnf";
  64. case DeploySpm.APT:
  65. return "apt";
  66. case DeploySpm.APK:
  67. return "apk";
  68. case DeploySpm.EMERGE:
  69. return "emerge";
  70. default:
  71. return "none";
  72. }
  73. }
  74. /**
  75. * The library directory the generated config installs `lib:`
  76. * resources under: apk targets musl/Alpine and apt targets Debian
  77. * multiarch, neither of which searches /usr/lib64 (their loaders and
  78. * pkg-config defaults resolve /usr/lib); the rpm and portage targets
  79. * follow the lib64 convention.
  80. */
  81. public string lib_path() {
  82. return this == DeploySpm.APK || this == DeploySpm.APT ? "lib" : "lib64";
  83. }
  84. }
  85. /**
  86. * `usm deploy <package.usmc|directory> [flags…]` — the package-then-deploy
  87. * convenience wrapper.
  88. *
  89. * A directory is deployed directly by running the manifest deploy verb inside
  90. * it; a `.usmc` archive is extracted to a temporary directory first, and the
  91. * finished image artifact is moved back next to the invocation directory.
  92. * Flags pass through to {@link manifest_deploy} unchanged.
  93. */
  94. public int deploy_main(string[] args) {
  95. string? target = null;
  96. var flags = new Vector<string>();
  97. for(int i = 2; i < args.length; i++) {
  98. var argument = args[i];
  99. if(argument.has_prefix("--")) {
  100. flags.add(argument);
  101. if(deploy_option_takes_value(argument.split("=", 2)[0])
  102. && !argument.contains("=")
  103. && i + 1 < args.length) {
  104. flags.add(args[++i]);
  105. }
  106. continue;
  107. }
  108. if(target != null) {
  109. printerr(@"Unexpected argument \"$argument\"\n");
  110. return deploy_usage();
  111. }
  112. target = argument;
  113. }
  114. if(target == null) {
  115. return deploy_usage();
  116. }
  117. var invocation_dir = Environment.get_current_dir();
  118. var target_path = Path.is_absolute(target) ? target : Path.build_filename(invocation_dir, target);
  119. // --repository is given relative to the invocation directory, but the
  120. // manifest deploy verb runs inside the target project: absolutise before
  121. // forwarding so both interpretations agree
  122. var forwarded = new string[] { "usm", "deploy" };
  123. for(int i = 0; i < flags.length; i++) {
  124. var flag = flags[i];
  125. if(flag.has_prefix("--repository=") && !Path.is_absolute(flag.split("=", 2)[1])) {
  126. forwarded += @"--repository=$(Path.build_filename(invocation_dir, flag.split("=", 2)[1]))";
  127. }
  128. else if(flag == "--repository" && i + 1 < flags.length && !Path.is_absolute(flags[i + 1])) {
  129. forwarded += flag;
  130. forwarded += Path.build_filename(invocation_dir, flags[++i]);
  131. }
  132. else {
  133. forwarded += flag;
  134. }
  135. }
  136. FileInfo file_info;
  137. try {
  138. if(!File.new_for_path(target_path).query_exists()) {
  139. printerr(@"\"$target\" does not exist\n");
  140. return 255;
  141. }
  142. file_info = File.new_for_path(target_path).query_info("*", FileQueryInfoFlags.NONE);
  143. }
  144. catch(Error e) {
  145. printerr(@"Could not inspect \"$target\": $(e.message)\n");
  146. return 255;
  147. }
  148. if(file_info.get_file_type() == FileType.DIRECTORY) {
  149. if(!File.new_for_path(Path.build_filename(target_path, "MANIFEST.usm")).query_exists()) {
  150. printerr(@"\"$target\" contains no MANIFEST.usm file\n");
  151. return 255;
  152. }
  153. Environment.set_current_dir(target_path);
  154. return manifest_main(forwarded);
  155. }
  156. if(!target.has_suffix(".usmc")) {
  157. printerr("\"$target\" is neither a directory containing MANIFEST.usm nor a .usmc package\n");
  158. return deploy_usage();
  159. }
  160. // Package-then-deploy: extract the archive, deploy from the extracted
  161. // tree, then bring the artifact back to the invocation directory
  162. Usm.Manifest archive_manifest;
  163. var extract_dir = File.new_build_filename("/tmp", @"usm-deploy-$(Uuid.string_random())");
  164. try {
  165. archive_manifest = new Usm.Manifest.from_package(target_path);
  166. extract_dir.make_directory();
  167. Usm.Util.unarchive(target_path, extract_dir.get_path());
  168. }
  169. catch(Error e) {
  170. printerr(@"Could not extract \"$target\": $(e.message)\n");
  171. return 243;
  172. }
  173. Environment.set_current_dir(extract_dir.get_path());
  174. var result = manifest_main(forwarded);
  175. if(result == 0) {
  176. var artifact = @"$(archive_manifest.name)-$(archive_manifest.version.to_string()).image.tar.xz";
  177. try {
  178. var produced = File.new_for_path(Path.build_filename(extract_dir.get_path(), artifact));
  179. if(produced.query_exists()) {
  180. produced.move(File.new_for_path(Path.build_filename(invocation_dir, artifact)), FileCopyFlags.OVERWRITE);
  181. printerr(@"Moved image artifact to \"$invocation_dir/$artifact\"\n");
  182. }
  183. }
  184. catch(Error e) {
  185. printerr(@"The image was built, but its artifact could not be moved to \"$invocation_dir\": $(e.message)\n");
  186. printerr(@"It remains at \"$(extract_dir.get_path())/$artifact\"\n");
  187. }
  188. }
  189. printerr(@"Deploy context kept at \"$(extract_dir.get_path())\"\n");
  190. return result;
  191. }
  192. /**
  193. * `usm manifest deploy [flags…]` — generate a single-stage container deploy
  194. * context for the manifest in the current directory, then (unless
  195. * `--no-build`) build it with podman and save an xz-compressed image
  196. * archive:
  197. *
  198. * - `--exec CMD`: the container command, split on whitespace into the
  199. * exec-form ENTRYPOINT. Default: the package's single `bin:` provide
  200. * (an error when there are zero or several).
  201. * - `--base IMAGE`: base image (default {@link DEPLOY_DEFAULT_BASE_IMAGE}).
  202. * - `--spm dnf|apt|apk|emerge|none`: the system package manager wired into
  203. * the image via the matching `usm-spm-<spm>` helper and a per-SPM
  204. * bootstrap RUN in the Containerfile. Default `none`: no
  205. * `system_package_manager` section and no bootstrap RUN, so the image
  206. * resolves everything from USM repositories alone.
  207. * - `--repository FILE`: use exactly the given `.usmr` files (repeatable)
  208. * instead of the machine-configured repositories. `file://` repositories
  209. * have their trees copied into the context and their URIs rewritten to the
  210. * in-image location.
  211. * - `--installer-url URL`: override the canonical USM installer source; a
  212. * `file://` URL carries the script inside the context.
  213. * - `--no-build`: stop after generating the context.
  214. *
  215. * Only repository public keys ever enter the context or the image.
  216. */
  217. public int manifest_deploy(string[] args) {
  218. string? exec_command = null;
  219. string? base_image = null;
  220. string? installer_url = null;
  221. DeploySpm deploy_spm = DeploySpm.NONE;
  222. bool no_build = false;
  223. // The top-level --verbose scan strips the flag before this parser runs,
  224. // so seed from USM_VERBOSE as well
  225. var verbose_env = Environment.get_variable("USM_VERBOSE");
  226. bool verbose_deploy = verbose_env != null && verbose_env.length > 0;
  227. var repository_overrides = new Vector<string>();
  228. for(int i = 2; i < args.length; i++) {
  229. var argument = args[i];
  230. string? inline_value = null;
  231. if(argument.has_prefix("--") && argument.contains("=")) {
  232. var assignment = argument.split("=", 2);
  233. argument = assignment[0];
  234. inline_value = assignment[1];
  235. }
  236. switch(argument) {
  237. case "--exec":
  238. exec_command = deploy_option_value(args, ref i, argument, inline_value);
  239. if(exec_command == null) {
  240. return deploy_usage();
  241. }
  242. break;
  243. case "--base":
  244. base_image = deploy_option_value(args, ref i, argument, inline_value);
  245. if(base_image == null) {
  246. return deploy_usage();
  247. }
  248. break;
  249. case "--repository":
  250. var repository_file = deploy_option_value(args, ref i, argument, inline_value);
  251. if(repository_file == null) {
  252. return deploy_usage();
  253. }
  254. repository_overrides.add(repository_file);
  255. break;
  256. case "--spm":
  257. var spm_value = deploy_option_value(args, ref i, argument, inline_value);
  258. if(spm_value == null) {
  259. return deploy_usage();
  260. }
  261. if(!DeploySpm.parse((!)spm_value, out deploy_spm)) {
  262. return deploy_usage();
  263. }
  264. break;
  265. case "--installer-url":
  266. installer_url = deploy_option_value(args, ref i, argument, inline_value);
  267. if(installer_url == null) {
  268. return deploy_usage();
  269. }
  270. break;
  271. case "--no-build":
  272. no_build = true;
  273. break;
  274. case "--verbose":
  275. case "-v":
  276. verbose_deploy = true;
  277. break;
  278. default:
  279. printerr(@"Unknown deploy option \"$argument\"\n");
  280. return deploy_usage();
  281. }
  282. }
  283. if(manifest.is_data_package) {
  284. printerr("Data packages cannot be deployed: they define no build or install machinery for the in-container install.\n");
  285. return 246;
  286. }
  287. if(manifest.executables == null || manifest.executables.build == null) {
  288. printerr(@"Package \"$(manifest.name)\" defines no build executable, which the in-container \"usm install\" requires.\n");
  289. return 245;
  290. }
  291. var entrypoint = deploy_entrypoint(exec_command);
  292. if(entrypoint.length == 0) {
  293. return 244;
  294. }
  295. var project_dir = Environment.get_current_dir();
  296. var context_dir = Path.build_filename(project_dir, DEPLOY_CONTEXT_DIRECTORY);
  297. var version_string = manifest.version.to_string();
  298. printerr(@"Generating deploy context in \"$context_dir\"...\n");
  299. // A stale context or artifact from an earlier run must never leak into
  300. // the package this deploy is about to create
  301. if(File.new_for_path(context_dir).query_exists()) {
  302. try {
  303. Usm.Util.delete_tree(context_dir);
  304. }
  305. catch(Error e) {
  306. printerr(@"Could not remove the stale deploy context: $(e.message)\n");
  307. return 243;
  308. }
  309. }
  310. foreach(var stale in new string[] { @"$(manifest.name)-$version_string.image.tar", @"$(manifest.name)-$version_string.image.tar.xz" }) {
  311. var stale_file = File.new_for_path(Path.build_filename(project_dir, stale));
  312. if(stale_file.query_exists()) {
  313. try {
  314. stale_file.delete();
  315. }
  316. catch(Error e) {
  317. printerr(@"Could not remove the stale artifact \"$stale\": $(e.message)\n");
  318. return 243;
  319. }
  320. }
  321. }
  322. DirUtils.create_with_parents(context_dir, 0755);
  323. DirUtils.create_with_parents(Path.build_filename(context_dir, "repos"), 0755);
  324. DirUtils.create_with_parents(Path.build_filename(context_dir, "repo-trees"), 0755);
  325. DirUtils.create_with_parents(Path.build_filename(context_dir, "package"), 0755);
  326. printerr("Packaging the project (usm manifest package)...\n");
  327. try {
  328. var package_proc = new Subprocess.newv(new string[] { "/proc/self/exe", "manifest", "package" }, SubprocessFlags.INHERIT_FDS);
  329. package_proc.wait_check();
  330. var produced = Path.build_filename(project_dir, "..", @"$(manifest.name)-$version_string.usmc");
  331. File.new_for_path(produced).move(File.new_build_filename(context_dir, "package", "package.usmc"), FileCopyFlags.OVERWRITE);
  332. }
  333. catch(Error e) {
  334. printerr(@"Failed to package the project: $(e.message)\n");
  335. return 242;
  336. }
  337. bool file_trees_provisioned = false;
  338. var provisioned = deploy_provision_repositories(repository_overrides, context_dir, project_dir, out file_trees_provisioned);
  339. if(provisioned != 0) {
  340. return provisioned;
  341. }
  342. var effective_installer_url = installer_url ?? Usm.Installer.CANONICAL_URL;
  343. var bundled_installer = false;
  344. if(effective_installer_url.has_prefix("file://")) {
  345. // A file:// installer URL can only usefully refer to the container's
  346. // own filesystem, so the script is carried inside the context
  347. var installer_path = deploy_file_uri_path(effective_installer_url);
  348. if(installer_path == null || !File.new_for_path(installer_path).query_exists()) {
  349. printerr(@"--installer-url \"$effective_installer_url\" does not point at an installer script on this machine\n");
  350. return 241;
  351. }
  352. try {
  353. var local_dir = Path.build_filename(context_dir, "installer-local");
  354. DirUtils.create_with_parents(local_dir, 0755);
  355. File.new_for_path(installer_path).copy(File.new_build_filename(local_dir, "install-usm.sh"), FileCopyFlags.OVERWRITE);
  356. }
  357. catch(Error e) {
  358. printerr(@"Could not carry the installer script into the context: $(e.message)\n");
  359. return 241;
  360. }
  361. effective_installer_url = "file:///usm-installer-local/install-usm.sh";
  362. bundled_installer = true;
  363. }
  364. try {
  365. deploy_write_container_config(context_dir, deploy_spm);
  366. var containerfile = deploy_containerfile(
  367. base_image ?? DEPLOY_DEFAULT_BASE_IMAGE,
  368. effective_installer_url,
  369. bundled_installer,
  370. manifest.name,
  371. version_string,
  372. entrypoint,
  373. file_trees_provisioned,
  374. verbose_deploy,
  375. deploy_spm);
  376. FileUtils.set_data(Path.build_filename(context_dir, "Containerfile"), containerfile.data);
  377. }
  378. catch(Error e) {
  379. printerr(@"Could not write the deploy context: $(e.message)\n");
  380. return 240;
  381. }
  382. var tag = @"$(deploy_tag_chunk(manifest.name)):$(deploy_tag_chunk(version_string))";
  383. if(no_build) {
  384. printerr(@"Deploy context generated in \"$context_dir\" (--no-build); build it with:\n podman build -t $tag \"$context_dir\"\n");
  385. return 0;
  386. }
  387. printerr(@"Building image \"$tag\" with podman...\n");
  388. try {
  389. var build_proc = new Subprocess.newv(new string[] { "podman", "build", "-t", tag, context_dir }, SubprocessFlags.INHERIT_FDS);
  390. build_proc.wait_check();
  391. }
  392. catch(Error e) {
  393. printerr(@"Image build failed: $(e.message)\n");
  394. return 239;
  395. }
  396. var artifact_base = @"$(manifest.name)-$version_string.image.tar";
  397. printerr(@"Saving image to \"$artifact_base.xz\"...\n");
  398. try {
  399. var save_proc = new Subprocess.newv(new string[] { "podman", "save", "-o", artifact_base, tag }, SubprocessFlags.INHERIT_FDS);
  400. save_proc.wait_check();
  401. var compress_proc = new Subprocess.newv(new string[] { "xz", "-T0", artifact_base }, SubprocessFlags.INHERIT_FDS);
  402. compress_proc.wait_check();
  403. }
  404. catch(Error e) {
  405. printerr(@"Saving the image failed: $(e.message)\n");
  406. return 238;
  407. }
  408. printerr(@"Built image \"$tag\"; artifact \"$artifact_base.xz\".\n");
  409. printerr(@"Load and run it with:\n podman load -i \"$artifact_base.xz\"\n podman run --rm $tag\n");
  410. return 0;
  411. }
  412. private int deploy_usage() {
  413. printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--spm dnf|apt|apk|emerge|none] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n");
  414. return 255;
  415. }
  416. /** Whether a deploy option consumes the following argument as its value. */
  417. private bool deploy_option_takes_value(string option) {
  418. return option == "--exec" || option == "--base" || option == "--spm" || option == "--repository" || option == "--installer-url";
  419. }
  420. /**
  421. * The value of a valued option, either the `--option=value` inline form or
  422. * the next argument; prints the option name and returns null when no value
  423. * follows.
  424. */
  425. private string? deploy_option_value(string[] args, ref int index, string option, string? inline_value) {
  426. if(inline_value != null) {
  427. return inline_value;
  428. }
  429. if(index + 1 >= args.length) {
  430. printerr(@"Expected a value after \"$option\"\n");
  431. return null;
  432. }
  433. return args[++index];
  434. }
  435. /**
  436. * The exec-form entrypoint words: `--exec`'s command split on whitespace, or
  437. * by default the package's single `bin:` provide as an absolute /usr/bin
  438. * path. Returns an empty array (after reporting why) when `--exec` is given
  439. * without words or the default is ambiguous.
  440. */
  441. private string[] deploy_entrypoint(string? exec_command) {
  442. if(exec_command != null) {
  443. var words = new Vector<string>();
  444. foreach(var word in exec_command.split(" ")) {
  445. if(word.length > 0) {
  446. words.add(word);
  447. }
  448. }
  449. if(words.length == 0) {
  450. printerr(@"--exec \"$(exec_command)\" contains no command\n");
  451. return new string[0];
  452. }
  453. return words.to_array();
  454. }
  455. var binaries = new Vector<string>();
  456. foreach(var provide in manifest.provides) {
  457. if(provide.key.resource_type == Usm.ResourceType.BINARY) {
  458. binaries.add(provide.key.resource);
  459. }
  460. }
  461. if(binaries.length != 1) {
  462. printerr(@"Cannot pick a default entrypoint: expected exactly one \"bin:\" provide in \"$(manifest.name)\", found $(binaries.length)");
  463. foreach(var binary in binaries) {
  464. printerr(@"\n bin:$binary");
  465. }
  466. printerr("\nPass --exec to choose the container command explicitly.\n");
  467. return new string[0];
  468. }
  469. return new string[] { Path.build_filename("/", "usr", "bin", binaries.first_or_default()) };
  470. }
  471. /**
  472. * Fills the context's `repos/` and `repo-trees/` directories from either the
  473. * explicit {@link overrides} (exactly those `.usmr` files) or, by default,
  474. * the machine-configured repositories (`<config dir>/repos.d`, honouring
  475. * USM_CONFIGDIR like every other usm command).
  476. *
  477. * Every `.usmr` is copied with its embedded public key; a `file://` URI
  478. * additionally has its repository tree copied into `repo-trees/<name>/` and
  479. * the copied descriptor's URI rewritten to the in-image location so the
  480. * container resolves it without the host. Private signing key material is
  481. * never copied. Sets {@link file_trees_provisioned} when at least one tree
  482. * was carried in.
  483. */
  484. private int deploy_provision_repositories(Vector<string> overrides, string context_dir, string project_dir, out bool file_trees_provisioned) {
  485. file_trees_provisioned = false;
  486. var selected = new Vector<string>();
  487. if(overrides.length > 0) {
  488. foreach(var path in overrides) {
  489. selected.add(Path.is_absolute(path) ? path : Path.build_filename(project_dir, path));
  490. }
  491. }
  492. else {
  493. var repos_dir = Path.build_filename(paths.usm_config_dir, "repos.d");
  494. if(File.new_for_path(repos_dir).query_exists()) {
  495. try {
  496. foreach(var file in Iterate.directory(repos_dir)) {
  497. if(file.has_suffix(".usmr")) {
  498. selected.add(Path.build_filename(repos_dir, file));
  499. }
  500. }
  501. }
  502. catch(Error e) {
  503. printerr(@"Could not list \"$repos_dir\": $(e.message)\n");
  504. return 241;
  505. }
  506. }
  507. else {
  508. printerr(@"No configured repositories found in \"$repos_dir\"; the image will resolve dependencies from the system package manager alone. Pass --repository to provision USM repositories into the image.\n");
  509. }
  510. }
  511. foreach(var repository_file in selected) {
  512. Usm.Repository repository;
  513. try {
  514. repository = new Usm.Repository.from_file(repository_file);
  515. }
  516. catch(Error e) {
  517. printerr(@"\"$repository_file\" is not a valid repository file: $(e.message)\n");
  518. return 241;
  519. }
  520. var basename = Path.get_basename(repository_file);
  521. var stem = basename.has_suffix(".usmr") ? basename.substring(0, basename.length - ".usmr".length) : basename;
  522. var copied_descriptor = Path.build_filename(context_dir, "repos", basename);
  523. try {
  524. File.new_for_path(repository_file).copy(File.new_for_path(copied_descriptor), FileCopyFlags.OVERWRITE);
  525. if(repository.url != null && repository.url.has_prefix("file://")) {
  526. var tree = deploy_file_uri_path((!)repository.url);
  527. if(tree == null) {
  528. printerr(@"\"$repository_file\" has an unreadable file:// URI\n");
  529. return 241;
  530. }
  531. if(!File.new_for_path(tree).query_exists()) {
  532. printerr(@"The repository tree \"$tree\" referenced by \"$repository_file\" does not exist\n");
  533. return 241;
  534. }
  535. var destination = Path.build_filename(context_dir, "repo-trees", stem);
  536. DirUtils.create_with_parents(destination, 0755);
  537. deploy_copy_repository_tree(tree, destination);
  538. var element = new InvercargillJson.JsonElement.from_file(copied_descriptor);
  539. element.as<InvercargillJson.JsonObject>().set_native("url", @"file://$(DEPLOY_REPO_TREES_PATH)/$stem");
  540. element.write_to_file(copied_descriptor);
  541. printerr(@"Provisioned repository \"$stem\": tree copied from \"$tree\", URI rewritten to file://$(DEPLOY_REPO_TREES_PATH)/$stem\n");
  542. file_trees_provisioned = true;
  543. }
  544. else {
  545. printerr(@"Provisioned repository \"$stem\" from \"$repository_file\" (URI \"$(repository.url ?? "none")\")\n");
  546. }
  547. }
  548. catch(Error e) {
  549. printerr(@"Could not provision repository \"$repository_file\": $(e.message)\n");
  550. return 241;
  551. }
  552. }
  553. return 0;
  554. }
  555. /**
  556. * The local path a `file://` URI points at, or null when it cannot be
  557. * determined (GLib handles the percent-decoding).
  558. */
  559. private string? deploy_file_uri_path(string uri) {
  560. try {
  561. return Filename.from_uri(uri, null);
  562. }
  563. catch(Error e) {
  564. return null;
  565. }
  566. }
  567. /**
  568. * Recursively copies a repository tree into the context, refusing to carry
  569. * anything that looks like signing key material: only public artefacts (the
  570. * signed listing, packages, public keys) may ever enter a deploy context.
  571. */
  572. private void deploy_copy_repository_tree(string source, string destination) throws Error {
  573. var source_dir = File.new_for_path(source);
  574. var destination_dir = File.new_for_path(destination);
  575. if(!destination_dir.query_exists()) {
  576. destination_dir.make_directory();
  577. }
  578. var enumerator = source_dir.enumerate_children("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
  579. while(true) {
  580. var info = enumerator.next_file();
  581. if(info == null) {
  582. break;
  583. }
  584. var name = info.get_name();
  585. if(info.get_file_type() == FileType.DIRECTORY) {
  586. if(name == "keys" || name == ".git") {
  587. continue;
  588. }
  589. deploy_copy_repository_tree(Path.build_filename(source, name), Path.build_filename(destination, name));
  590. }
  591. else if(!name.has_prefix("private-key")) {
  592. source_dir.get_child(name).copy(destination_dir.get_child(name), FileCopyFlags.OVERWRITE);
  593. }
  594. }
  595. }
  596. /**
  597. * Writes the minimal in-image `usm.config`: managed state under /var/usm
  598. * and, unless {@link spm} is {@link DeploySpm.NONE}, the system package
  599. * manager wired to the matching helper the installer ships so in-container
  600. * resolution asks the SPM before USM repositories. Hand-authored on
  601. * purpose — the generated image must stay independent of this machine's
  602. * configuration.
  603. */
  604. private void deploy_write_container_config(string context_dir, DeploySpm spm) throws Error {
  605. var spm_section = "";
  606. if(spm != DeploySpm.NONE) {
  607. var helper = @"$(DEPLOY_USM_PREFIX)/bin/usm-spm-$(spm.shim_name())";
  608. spm_section = @",
  609. \"system_package_manager\": {
  610. \"query\": [\"$helper\", \"query\"],
  611. \"install\": [\"$helper\", \"install\"]
  612. }";
  613. }
  614. var config = @"{
  615. \"is_managed\": true,
  616. \"managed\": {
  617. \"state_path\": \"/var/usm\"
  618. },
  619. \"paths\": {
  620. \"lib\": \"$(spm.lib_path())\"
  621. }$spm_section
  622. }
  623. ";
  624. FileUtils.set_data(Path.build_filename(context_dir, "usm.config"), config.data);
  625. }
  626. /**
  627. * Renders the single-stage Containerfile: ARG-before-FROM base image, USM
  628. * installed from the installer URL, the minimal configuration, repository
  629. * descriptors (plus rewritten `file://` trees), the package pre-seeded into
  630. * the USM cache and installed in-container, and the exec-form ENTRYPOINT.
  631. * A {@link spm} other than {@link DeploySpm.NONE} contributes the SPM's
  632. * bootstrap RUN (emerge needs none — the stage3 base carries portage).
  633. */
  634. private string deploy_containerfile(string base_image, string installer_url, bool bundled_installer,
  635. string package_name, string version_string, string[] entrypoint, bool file_trees_provisioned,
  636. bool verbose_deploy, DeploySpm spm) {
  637. var builder = new StringBuilder();
  638. builder.append("# Generated by `usm manifest deploy` — regenerate rather than edit\n\n");
  639. builder.append_printf("ARG BASE_IMAGE=%s\n", base_image);
  640. builder.append("FROM ${BASE_IMAGE}\n\n");
  641. builder.append("# Override at build time with --build-arg USM_INSTALLER_URL=...\n");
  642. builder.append_printf("ARG USM_INSTALLER_URL=%s\n\n", installer_url);
  643. if(bundled_installer) {
  644. builder.append("COPY installer-local/install-usm.sh /usm-installer-local/install-usm.sh\n\n");
  645. }
  646. switch(spm) {
  647. case DeploySpm.DNF:
  648. builder.append("# DNF4 python bindings for the usm SPM helper (python3 is absent from the base image)\n");
  649. builder.append("RUN dnf install -y python3-dnf && dnf clean all\n\n");
  650. break;
  651. case DeploySpm.APT:
  652. builder.append("# curl (absent from the debian base) fetches the installer; python3-apt bindings plus the apt-file contents index serve the usm SPM helper\n");
  653. builder.append("RUN apt-get update && apt-get install -y curl python3 python3-apt apt-file && apt-file update && apt-get clean\n\n");
  654. break;
  655. case DeploySpm.APK:
  656. builder.append("# The shell SPM helper needs nothing beyond busybox; bash serves the package's manage: scripts and curl (absent from the alpine base) fetches the installer\n");
  657. builder.append("RUN apk add --no-cache bash curl\n\n");
  658. break;
  659. case DeploySpm.EMERGE:
  660. case DeploySpm.NONE:
  661. break;
  662. }
  663. builder.append("# Install USM. The installer is downloaded to a real file first because it\n");
  664. builder.append("# extracts its payload relative to $0, so `curl ... | sh` cannot work.\n");
  665. builder.append("RUN curl -fsSL \"${USM_INSTALLER_URL}\" -o /tmp/install-usm.sh \\\n");
  666. builder.append(" && bash /tmp/install-usm.sh -y \\\n");
  667. builder.append(" && rm -f /tmp/install-usm.sh\n\n");
  668. builder.append("COPY usm.config /etc/usm/usm.config\n\n");
  669. builder.append("COPY repos/ /etc/usm/repos.d/\n");
  670. if(file_trees_provisioned) {
  671. builder.append("COPY repo-trees/ /usr/share/usm-repos/\n");
  672. }
  673. builder.append("\n");
  674. builder.append_printf("COPY package/package.usmc /var/usm/packages/%s-%s/package.usmc\n", package_name, version_string);
  675. builder.append_printf("RUN mkdir -p /var/usm/lists /var/usm/installed && usm install %s%s\n\n", package_name, verbose_deploy ? " --verbose" : "");
  676. builder.append_printf("ENTRYPOINT %s\n", deploy_entrypoint_json(entrypoint));
  677. return builder.str;
  678. }
  679. /** The exec-form JSON array for the generated ENTRYPOINT. */
  680. private string deploy_entrypoint_json(string[] entrypoint) {
  681. var words = new Vector<string>();
  682. foreach(var word in entrypoint) {
  683. words.add("\"" + word.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
  684. }
  685. return @"[$(words.to_string(w => w, ", "))]";
  686. }
  687. /**
  688. * A podman-safe tag chunk: tag syntax only allows `[A-Za-z0-9_.-]`, so
  689. * anything else (usm release suffixes like `+` in versions, for instance)
  690. * maps to a dash. Artifact filenames keep the unsanitised form.
  691. */
  692. private string deploy_tag_chunk(string chunk) {
  693. var builder = new StringBuilder();
  694. for(int index = 0; index < chunk.length; index++) {
  695. var character = chunk[index];
  696. var valid = (character >= 'a' && character <= 'z')
  697. || (character >= 'A' && character <= 'Z')
  698. || (character >= '0' && character <= '9')
  699. || character == '_' || character == '.' || character == '-';
  700. builder.append_unichar(valid ? character : '-');
  701. }
  702. return builder.str;
  703. }