using Invercargill; using Invercargill.DataStructures; namespace Usm.Installer { /** * Canonical source of the self-contained USM installer script baked into * deploy images by default (the compiled form of `installer/` in the usm * source tree), hosted at packages.astrologue.nz; override it per deploy * with `--installer-url` (whose `file://` form is the sanctioned * local-testing path). */ public const string CANONICAL_URL = "https://packages.astrologue.nz/install-usm.sh"; } /** Default container base image, overridable with `--base`. */ const string DEPLOY_DEFAULT_BASE_IMAGE = "registry.fedoraproject.org/fedora:43"; /** Deploy context directory created inside the packaged project. */ const string DEPLOY_CONTEXT_DIRECTORY = ".usm-deploy"; /** Where the USM installer tree lives inside images (installer TARGET_DIR). */ const string DEPLOY_USM_PREFIX = "/opt/usm"; /** In-image location that file:// repository trees are rewritten to. */ const string DEPLOY_REPO_TREES_PATH = "/usr/share/usm-repos"; /** * The system package managers `deploy --spm` can wire into the image, one * per shim the installer ships; {@link DeploySpm.NONE} wires none and the * image resolves from USM repositories alone. */ private enum DeploySpm { DNF, APT, APK, EMERGE, NONE; /** * Whether a `--spm` value names a choice, setting {@link parsed} to it * (and reporting the valid values) — {@link DeploySpm.NONE} on failure. */ public static bool parse(string value, out DeploySpm parsed) { parsed = DeploySpm.NONE; switch(value) { case "dnf": parsed = DeploySpm.DNF; return true; case "apt": parsed = DeploySpm.APT; return true; case "apk": parsed = DeploySpm.APK; return true; case "emerge": parsed = DeploySpm.EMERGE; return true; case "none": parsed = DeploySpm.NONE; return true; default: printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge or none)\n"); return false; } } /** The lowercase name completing `usm-spm-` in shim paths. */ public string shim_name() { switch(this) { case DeploySpm.DNF: return "dnf"; case DeploySpm.APT: return "apt"; case DeploySpm.APK: return "apk"; case DeploySpm.EMERGE: return "emerge"; default: return "none"; } } /** * The library directory the generated config installs `lib:` * resources under: apk targets musl/Alpine and apt targets Debian * multiarch, neither of which searches /usr/lib64 (their loaders and * pkg-config defaults resolve /usr/lib); the rpm and portage targets * follow the lib64 convention. */ public string lib_path() { return this == DeploySpm.APK || this == DeploySpm.APT ? "lib" : "lib64"; } } /** * `usm deploy [flags…]` — the package-then-deploy * convenience wrapper. * * A directory is deployed directly by running the manifest deploy verb inside * it; a `.usmc` archive is extracted to a temporary directory first, and the * finished image artifact is moved back next to the invocation directory. * Flags pass through to {@link manifest_deploy} unchanged. */ public int deploy_main(string[] args) { string? target = null; var flags = new Vector(); for(int i = 2; i < args.length; i++) { var argument = args[i]; if(argument.has_prefix("--")) { flags.add(argument); if(deploy_option_takes_value(argument.split("=", 2)[0]) && !argument.contains("=") && i + 1 < args.length) { flags.add(args[++i]); } continue; } if(target != null) { printerr(@"Unexpected argument \"$argument\"\n"); return deploy_usage(); } target = argument; } if(target == null) { return deploy_usage(); } var invocation_dir = Environment.get_current_dir(); var target_path = Path.is_absolute(target) ? target : Path.build_filename(invocation_dir, target); // --repository is given relative to the invocation directory, but the // manifest deploy verb runs inside the target project: absolutise before // forwarding so both interpretations agree var forwarded = new string[] { "usm", "deploy" }; for(int i = 0; i < flags.length; i++) { var flag = flags[i]; if(flag.has_prefix("--repository=") && !Path.is_absolute(flag.split("=", 2)[1])) { forwarded += @"--repository=$(Path.build_filename(invocation_dir, flag.split("=", 2)[1]))"; } else if(flag == "--repository" && i + 1 < flags.length && !Path.is_absolute(flags[i + 1])) { forwarded += flag; forwarded += Path.build_filename(invocation_dir, flags[++i]); } else { forwarded += flag; } } FileInfo file_info; try { if(!File.new_for_path(target_path).query_exists()) { printerr(@"\"$target\" does not exist\n"); return 255; } file_info = File.new_for_path(target_path).query_info("*", FileQueryInfoFlags.NONE); } catch(Error e) { printerr(@"Could not inspect \"$target\": $(e.message)\n"); return 255; } if(file_info.get_file_type() == FileType.DIRECTORY) { if(!File.new_for_path(Path.build_filename(target_path, "MANIFEST.usm")).query_exists()) { printerr(@"\"$target\" contains no MANIFEST.usm file\n"); return 255; } Environment.set_current_dir(target_path); return manifest_main(forwarded); } if(!target.has_suffix(".usmc")) { printerr("\"$target\" is neither a directory containing MANIFEST.usm nor a .usmc package\n"); return deploy_usage(); } // Package-then-deploy: extract the archive, deploy from the extracted // tree, then bring the artifact back to the invocation directory Usm.Manifest archive_manifest; var extract_dir = File.new_build_filename("/tmp", @"usm-deploy-$(Uuid.string_random())"); try { archive_manifest = new Usm.Manifest.from_package(target_path); extract_dir.make_directory(); Usm.Util.unarchive(target_path, extract_dir.get_path()); } catch(Error e) { printerr(@"Could not extract \"$target\": $(e.message)\n"); return 243; } Environment.set_current_dir(extract_dir.get_path()); var result = manifest_main(forwarded); if(result == 0) { var artifact = @"$(archive_manifest.name)-$(archive_manifest.version.to_string()).image.tar.xz"; try { var produced = File.new_for_path(Path.build_filename(extract_dir.get_path(), artifact)); if(produced.query_exists()) { produced.move(File.new_for_path(Path.build_filename(invocation_dir, artifact)), FileCopyFlags.OVERWRITE); printerr(@"Moved image artifact to \"$invocation_dir/$artifact\"\n"); } } catch(Error e) { printerr(@"The image was built, but its artifact could not be moved to \"$invocation_dir\": $(e.message)\n"); printerr(@"It remains at \"$(extract_dir.get_path())/$artifact\"\n"); } } printerr(@"Deploy context kept at \"$(extract_dir.get_path())\"\n"); return result; } /** * `usm manifest deploy [flags…]` — generate a single-stage container deploy * context for the manifest in the current directory, then (unless * `--no-build`) build it with podman and save an xz-compressed image * archive: * * - `--exec CMD`: the container command, split on whitespace into the * exec-form ENTRYPOINT. Default: the package's single `bin:` provide * (an error when there are zero or several). * - `--base IMAGE`: base image (default {@link DEPLOY_DEFAULT_BASE_IMAGE}). * - `--spm dnf|apt|apk|emerge|none`: the system package manager wired into * the image via the matching `usm-spm-` helper and a per-SPM * bootstrap RUN in the Containerfile. Default `none`: no * `system_package_manager` section and no bootstrap RUN, so the image * resolves everything from USM repositories alone. * - `--repository FILE`: use exactly the given `.usmr` files (repeatable) * instead of the machine-configured repositories. `file://` repositories * have their trees copied into the context and their URIs rewritten to the * in-image location. * - `--installer-url URL`: override the canonical USM installer source; a * `file://` URL carries the script inside the context. * - `--no-build`: stop after generating the context. * * Only repository public keys ever enter the context or the image. */ public int manifest_deploy(string[] args) { string? exec_command = null; string? base_image = null; string? installer_url = null; DeploySpm deploy_spm = DeploySpm.NONE; bool no_build = false; // The top-level --verbose scan strips the flag before this parser runs, // so seed from USM_VERBOSE as well var verbose_env = Environment.get_variable("USM_VERBOSE"); bool verbose_deploy = verbose_env != null && verbose_env.length > 0; var repository_overrides = new Vector(); for(int i = 2; i < args.length; i++) { var argument = args[i]; string? inline_value = null; if(argument.has_prefix("--") && argument.contains("=")) { var assignment = argument.split("=", 2); argument = assignment[0]; inline_value = assignment[1]; } switch(argument) { case "--exec": exec_command = deploy_option_value(args, ref i, argument, inline_value); if(exec_command == null) { return deploy_usage(); } break; case "--base": base_image = deploy_option_value(args, ref i, argument, inline_value); if(base_image == null) { return deploy_usage(); } break; case "--repository": var repository_file = deploy_option_value(args, ref i, argument, inline_value); if(repository_file == null) { return deploy_usage(); } repository_overrides.add(repository_file); break; case "--spm": var spm_value = deploy_option_value(args, ref i, argument, inline_value); if(spm_value == null) { return deploy_usage(); } if(!DeploySpm.parse((!)spm_value, out deploy_spm)) { return deploy_usage(); } break; case "--installer-url": installer_url = deploy_option_value(args, ref i, argument, inline_value); if(installer_url == null) { return deploy_usage(); } break; case "--no-build": no_build = true; break; case "--verbose": case "-v": verbose_deploy = true; break; default: printerr(@"Unknown deploy option \"$argument\"\n"); return deploy_usage(); } } if(manifest.is_data_package) { printerr("Data packages cannot be deployed: they define no build or install machinery for the in-container install.\n"); return 246; } if(manifest.executables == null || manifest.executables.build == null) { printerr(@"Package \"$(manifest.name)\" defines no build executable, which the in-container \"usm install\" requires.\n"); return 245; } var entrypoint = deploy_entrypoint(exec_command); if(entrypoint.length == 0) { return 244; } var project_dir = Environment.get_current_dir(); var context_dir = Path.build_filename(project_dir, DEPLOY_CONTEXT_DIRECTORY); var version_string = manifest.version.to_string(); printerr(@"Generating deploy context in \"$context_dir\"...\n"); // A stale context or artifact from an earlier run must never leak into // the package this deploy is about to create if(File.new_for_path(context_dir).query_exists()) { try { Usm.Util.delete_tree(context_dir); } catch(Error e) { printerr(@"Could not remove the stale deploy context: $(e.message)\n"); return 243; } } foreach(var stale in new string[] { @"$(manifest.name)-$version_string.image.tar", @"$(manifest.name)-$version_string.image.tar.xz" }) { var stale_file = File.new_for_path(Path.build_filename(project_dir, stale)); if(stale_file.query_exists()) { try { stale_file.delete(); } catch(Error e) { printerr(@"Could not remove the stale artifact \"$stale\": $(e.message)\n"); return 243; } } } DirUtils.create_with_parents(context_dir, 0755); DirUtils.create_with_parents(Path.build_filename(context_dir, "repos"), 0755); DirUtils.create_with_parents(Path.build_filename(context_dir, "repo-trees"), 0755); DirUtils.create_with_parents(Path.build_filename(context_dir, "package"), 0755); printerr("Packaging the project (usm manifest package)...\n"); try { var package_proc = new Subprocess.newv(new string[] { "/proc/self/exe", "manifest", "package" }, SubprocessFlags.INHERIT_FDS); package_proc.wait_check(); var produced = Path.build_filename(project_dir, "..", @"$(manifest.name)-$version_string.usmc"); File.new_for_path(produced).move(File.new_build_filename(context_dir, "package", "package.usmc"), FileCopyFlags.OVERWRITE); } catch(Error e) { printerr(@"Failed to package the project: $(e.message)\n"); return 242; } bool file_trees_provisioned = false; var provisioned = deploy_provision_repositories(repository_overrides, context_dir, project_dir, out file_trees_provisioned); if(provisioned != 0) { return provisioned; } var effective_installer_url = installer_url ?? Usm.Installer.CANONICAL_URL; var bundled_installer = false; if(effective_installer_url.has_prefix("file://")) { // A file:// installer URL can only usefully refer to the container's // own filesystem, so the script is carried inside the context var installer_path = deploy_file_uri_path(effective_installer_url); if(installer_path == null || !File.new_for_path(installer_path).query_exists()) { printerr(@"--installer-url \"$effective_installer_url\" does not point at an installer script on this machine\n"); return 241; } try { var local_dir = Path.build_filename(context_dir, "installer-local"); DirUtils.create_with_parents(local_dir, 0755); File.new_for_path(installer_path).copy(File.new_build_filename(local_dir, "install-usm.sh"), FileCopyFlags.OVERWRITE); } catch(Error e) { printerr(@"Could not carry the installer script into the context: $(e.message)\n"); return 241; } effective_installer_url = "file:///usm-installer-local/install-usm.sh"; bundled_installer = true; } try { deploy_write_container_config(context_dir, deploy_spm); var containerfile = deploy_containerfile( base_image ?? DEPLOY_DEFAULT_BASE_IMAGE, effective_installer_url, bundled_installer, manifest.name, version_string, entrypoint, file_trees_provisioned, verbose_deploy, deploy_spm); FileUtils.set_data(Path.build_filename(context_dir, "Containerfile"), containerfile.data); } catch(Error e) { printerr(@"Could not write the deploy context: $(e.message)\n"); return 240; } var tag = @"$(deploy_tag_chunk(manifest.name)):$(deploy_tag_chunk(version_string))"; if(no_build) { printerr(@"Deploy context generated in \"$context_dir\" (--no-build); build it with:\n podman build -t $tag \"$context_dir\"\n"); return 0; } printerr(@"Building image \"$tag\" with podman...\n"); try { var build_proc = new Subprocess.newv(new string[] { "podman", "build", "-t", tag, context_dir }, SubprocessFlags.INHERIT_FDS); build_proc.wait_check(); } catch(Error e) { printerr(@"Image build failed: $(e.message)\n"); return 239; } var artifact_base = @"$(manifest.name)-$version_string.image.tar"; printerr(@"Saving image to \"$artifact_base.xz\"...\n"); try { var save_proc = new Subprocess.newv(new string[] { "podman", "save", "-o", artifact_base, tag }, SubprocessFlags.INHERIT_FDS); save_proc.wait_check(); var compress_proc = new Subprocess.newv(new string[] { "xz", "-T0", artifact_base }, SubprocessFlags.INHERIT_FDS); compress_proc.wait_check(); } catch(Error e) { printerr(@"Saving the image failed: $(e.message)\n"); return 238; } printerr(@"Built image \"$tag\"; artifact \"$artifact_base.xz\".\n"); printerr(@"Load and run it with:\n podman load -i \"$artifact_base.xz\"\n podman run --rm $tag\n"); return 0; } private int deploy_usage() { printerr("USAGE:\n\tusm deploy [--exec CMD] [--base IMAGE] [--spm dnf|apt|apk|emerge|none] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n"); return 255; } /** Whether a deploy option consumes the following argument as its value. */ private bool deploy_option_takes_value(string option) { return option == "--exec" || option == "--base" || option == "--spm" || option == "--repository" || option == "--installer-url"; } /** * The value of a valued option, either the `--option=value` inline form or * the next argument; prints the option name and returns null when no value * follows. */ private string? deploy_option_value(string[] args, ref int index, string option, string? inline_value) { if(inline_value != null) { return inline_value; } if(index + 1 >= args.length) { printerr(@"Expected a value after \"$option\"\n"); return null; } return args[++index]; } /** * The exec-form entrypoint words: `--exec`'s command split on whitespace, or * by default the package's single `bin:` provide as an absolute /usr/bin * path. Returns an empty array (after reporting why) when `--exec` is given * without words or the default is ambiguous. */ private string[] deploy_entrypoint(string? exec_command) { if(exec_command != null) { var words = new Vector(); foreach(var word in exec_command.split(" ")) { if(word.length > 0) { words.add(word); } } if(words.length == 0) { printerr(@"--exec \"$(exec_command)\" contains no command\n"); return new string[0]; } return words.to_array(); } var binaries = new Vector(); foreach(var provide in manifest.provides) { if(provide.key.resource_type == Usm.ResourceType.BINARY) { binaries.add(provide.key.resource); } } if(binaries.length != 1) { printerr(@"Cannot pick a default entrypoint: expected exactly one \"bin:\" provide in \"$(manifest.name)\", found $(binaries.length)"); foreach(var binary in binaries) { printerr(@"\n bin:$binary"); } printerr("\nPass --exec to choose the container command explicitly.\n"); return new string[0]; } return new string[] { Path.build_filename("/", "usr", "bin", binaries.first_or_default()) }; } /** * Fills the context's `repos/` and `repo-trees/` directories from either the * explicit {@link overrides} (exactly those `.usmr` files) or, by default, * the machine-configured repositories (`/repos.d`, honouring * USM_CONFIGDIR like every other usm command). * * Every `.usmr` is copied with its embedded public key; a `file://` URI * additionally has its repository tree copied into `repo-trees//` and * the copied descriptor's URI rewritten to the in-image location so the * container resolves it without the host. Private signing key material is * never copied. Sets {@link file_trees_provisioned} when at least one tree * was carried in. */ private int deploy_provision_repositories(Vector overrides, string context_dir, string project_dir, out bool file_trees_provisioned) { file_trees_provisioned = false; var selected = new Vector(); if(overrides.length > 0) { foreach(var path in overrides) { selected.add(Path.is_absolute(path) ? path : Path.build_filename(project_dir, path)); } } else { var repos_dir = Path.build_filename(paths.usm_config_dir, "repos.d"); if(File.new_for_path(repos_dir).query_exists()) { try { foreach(var file in Iterate.directory(repos_dir)) { if(file.has_suffix(".usmr")) { selected.add(Path.build_filename(repos_dir, file)); } } } catch(Error e) { printerr(@"Could not list \"$repos_dir\": $(e.message)\n"); return 241; } } else { 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"); } } foreach(var repository_file in selected) { Usm.Repository repository; try { repository = new Usm.Repository.from_file(repository_file); } catch(Error e) { printerr(@"\"$repository_file\" is not a valid repository file: $(e.message)\n"); return 241; } var basename = Path.get_basename(repository_file); var stem = basename.has_suffix(".usmr") ? basename.substring(0, basename.length - ".usmr".length) : basename; var copied_descriptor = Path.build_filename(context_dir, "repos", basename); try { File.new_for_path(repository_file).copy(File.new_for_path(copied_descriptor), FileCopyFlags.OVERWRITE); if(repository.url != null && repository.url.has_prefix("file://")) { var tree = deploy_file_uri_path((!)repository.url); if(tree == null) { printerr(@"\"$repository_file\" has an unreadable file:// URI\n"); return 241; } if(!File.new_for_path(tree).query_exists()) { printerr(@"The repository tree \"$tree\" referenced by \"$repository_file\" does not exist\n"); return 241; } var destination = Path.build_filename(context_dir, "repo-trees", stem); DirUtils.create_with_parents(destination, 0755); deploy_copy_repository_tree(tree, destination); var element = new InvercargillJson.JsonElement.from_file(copied_descriptor); element.as().set_native("url", @"file://$(DEPLOY_REPO_TREES_PATH)/$stem"); element.write_to_file(copied_descriptor); printerr(@"Provisioned repository \"$stem\": tree copied from \"$tree\", URI rewritten to file://$(DEPLOY_REPO_TREES_PATH)/$stem\n"); file_trees_provisioned = true; } else { printerr(@"Provisioned repository \"$stem\" from \"$repository_file\" (URI \"$(repository.url ?? "none")\")\n"); } } catch(Error e) { printerr(@"Could not provision repository \"$repository_file\": $(e.message)\n"); return 241; } } return 0; } /** * The local path a `file://` URI points at, or null when it cannot be * determined (GLib handles the percent-decoding). */ private string? deploy_file_uri_path(string uri) { try { return Filename.from_uri(uri, null); } catch(Error e) { return null; } } /** * Recursively copies a repository tree into the context, refusing to carry * anything that looks like signing key material: only public artefacts (the * signed listing, packages, public keys) may ever enter a deploy context. */ private void deploy_copy_repository_tree(string source, string destination) throws Error { var source_dir = File.new_for_path(source); var destination_dir = File.new_for_path(destination); if(!destination_dir.query_exists()) { destination_dir.make_directory(); } var enumerator = source_dir.enumerate_children("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS); while(true) { var info = enumerator.next_file(); if(info == null) { break; } var name = info.get_name(); if(info.get_file_type() == FileType.DIRECTORY) { if(name == "keys" || name == ".git") { continue; } deploy_copy_repository_tree(Path.build_filename(source, name), Path.build_filename(destination, name)); } else if(!name.has_prefix("private-key")) { source_dir.get_child(name).copy(destination_dir.get_child(name), FileCopyFlags.OVERWRITE); } } } /** * Writes the minimal in-image `usm.config`: managed state under /var/usm * and, unless {@link spm} is {@link DeploySpm.NONE}, the system package * manager wired to the matching helper the installer ships so in-container * resolution asks the SPM before USM repositories. Hand-authored on * purpose — the generated image must stay independent of this machine's * configuration. */ private void deploy_write_container_config(string context_dir, DeploySpm spm) throws Error { var spm_section = ""; if(spm != DeploySpm.NONE) { var helper = @"$(DEPLOY_USM_PREFIX)/bin/usm-spm-$(spm.shim_name())"; spm_section = @", \"system_package_manager\": { \"query\": [\"$helper\", \"query\"], \"install\": [\"$helper\", \"install\"] }"; } var config = @"{ \"is_managed\": true, \"managed\": { \"state_path\": \"/var/usm\" }, \"paths\": { \"lib\": \"$(spm.lib_path())\" }$spm_section } "; FileUtils.set_data(Path.build_filename(context_dir, "usm.config"), config.data); } /** * Renders the single-stage Containerfile: ARG-before-FROM base image, USM * installed from the installer URL, the minimal configuration, repository * descriptors (plus rewritten `file://` trees), the package pre-seeded into * the USM cache and installed in-container, and the exec-form ENTRYPOINT. * A {@link spm} other than {@link DeploySpm.NONE} contributes the SPM's * bootstrap RUN (emerge needs none — the stage3 base carries portage). */ private string deploy_containerfile(string base_image, string installer_url, bool bundled_installer, string package_name, string version_string, string[] entrypoint, bool file_trees_provisioned, bool verbose_deploy, DeploySpm spm) { var builder = new StringBuilder(); builder.append("# Generated by `usm manifest deploy` — regenerate rather than edit\n\n"); builder.append_printf("ARG BASE_IMAGE=%s\n", base_image); builder.append("FROM ${BASE_IMAGE}\n\n"); builder.append("# Override at build time with --build-arg USM_INSTALLER_URL=...\n"); builder.append_printf("ARG USM_INSTALLER_URL=%s\n\n", installer_url); if(bundled_installer) { builder.append("COPY installer-local/install-usm.sh /usm-installer-local/install-usm.sh\n\n"); } switch(spm) { case DeploySpm.DNF: builder.append("# DNF4 python bindings for the usm SPM helper (python3 is absent from the base image)\n"); builder.append("RUN dnf install -y python3-dnf && dnf clean all\n\n"); break; case DeploySpm.APT: 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"); builder.append("RUN apt-get update && apt-get install -y curl python3 python3-apt apt-file && apt-file update && apt-get clean\n\n"); break; case DeploySpm.APK: 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"); builder.append("RUN apk add --no-cache bash curl\n\n"); break; case DeploySpm.EMERGE: case DeploySpm.NONE: break; } builder.append("# Install USM. The installer is downloaded to a real file first because it\n"); builder.append("# extracts its payload relative to $0, so `curl ... | sh` cannot work.\n"); builder.append("RUN curl -fsSL \"${USM_INSTALLER_URL}\" -o /tmp/install-usm.sh \\\n"); builder.append(" && bash /tmp/install-usm.sh -y \\\n"); builder.append(" && rm -f /tmp/install-usm.sh\n\n"); builder.append("COPY usm.config /etc/usm/usm.config\n\n"); builder.append("COPY repos/ /etc/usm/repos.d/\n"); if(file_trees_provisioned) { builder.append("COPY repo-trees/ /usr/share/usm-repos/\n"); } builder.append("\n"); builder.append_printf("COPY package/package.usmc /var/usm/packages/%s-%s/package.usmc\n", package_name, version_string); builder.append_printf("RUN mkdir -p /var/usm/lists /var/usm/installed && usm install %s%s\n\n", package_name, verbose_deploy ? " --verbose" : ""); builder.append_printf("ENTRYPOINT %s\n", deploy_entrypoint_json(entrypoint)); return builder.str; } /** The exec-form JSON array for the generated ENTRYPOINT. */ private string deploy_entrypoint_json(string[] entrypoint) { var words = new Vector(); foreach(var word in entrypoint) { words.add("\"" + word.replace("\\", "\\\\").replace("\"", "\\\"") + "\""); } return @"[$(words.to_string(w => w, ", "))]"; } /** * A podman-safe tag chunk: tag syntax only allows `[A-Za-z0-9_.-]`, so * anything else (usm release suffixes like `+` in versions, for instance) * maps to a dash. Artifact filenames keep the unsanitised form. */ private string deploy_tag_chunk(string chunk) { var builder = new StringBuilder(); for(int index = 0; index < chunk.length; index++) { var character = chunk[index]; var valid = (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_' || character == '.' || character == '-'; builder.append_unichar(valid ? character : '-'); } return builder.str; }