| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- using Invercargill;
- using Invercargill.DataStructures;
- namespace Usm {
- /**
- * Rollback journal for a {@link Transaction}: every install and
- * remove records what it is about to change ({@link entry_install}
- * and {@link entry_remove}), every file that would be overwritten or
- * deleted is copied into the journal's backup area first
- * ({@link backup_file}), and each operation that succeeded marks
- * itself done ({@link complete}). When the transaction then fails,
- * {@link rollback} undoes the recorded operations in reverse —
- * deleting installed files, restoring overwritten and removed files
- * from their backups, and re-creating `installed/` marks — while
- * {@link describe_rollback} narrates what was undone.
- *
- * The journal owns `<backup-root>/<transaction-id>/` (with the
- * default root `/var/usm/backup`, rooted like every other USM
- * destination through {@link Paths}), holding `journal.json` plus
- * each backed-up file mirrored at `<package-name>/<original-path>`.
- * {@link finish} removes the whole directory on the success path; a
- * rollback deliberately keeps it for inspection.
- *
- * What a journal cannot undo:
- *
- * * post-install and remove scripts may change the system beyond
- * their manifest's resources — system users, enabled services,
- * databases — and those side effects persist through a rollback;
- * * system packages installed through the system package manager
- * are not journalable, they belong to that manager;
- * * parent directories created for new files are left behind, and
- * empty `dir` provides are neither backed up nor re-created;
- * * the `.usmc` downloads and build caches a transaction made stay
- * in the cache — harmless, and reusable by the retry;
- * * a rollback step that itself fails (a missing or unreadable
- * backup, for instance) is reported and skipped, never retried.
- */
- public class Journal {
- private const string OPERATION_INSTALL = "install";
- private const string OPERATION_REMOVE = "remove";
- /** Extension of the sidecar recording a backed-up symlink's target. */
- private const string LINK_SIDECAR_SUFFIX = ".usm-link-target";
- /** Directory holding every transaction's backup area, e.g. `/var/usm/backup`. */
- public string backup_root { get; private set; }
- /** This journal's unique directory name beneath {@link backup_root}. */
- public string transaction_id { get; private set; }
- /** The backup area `<backup-root>/<transaction-id>`, created by {@link begin}. */
- public string directory { get; private set; }
- private Vector<JournalEntry> entries = new Vector<JournalEntry>();
- private Vector<string> undone = new Vector<string>();
- public Journal(string backup_root) {
- this.backup_root = backup_root;
- transaction_id = @"$(new DateTime.now_utc().format_iso8601())-$(Uuid.string_random())";
- }
- /**
- * Creates the backup directory and writes an empty journal: the
- * first call of a transaction, before any operation runs.
- */
- public void begin() throws Error {
- directory = Path.build_filename(backup_root, transaction_id);
- File.new_for_path(directory).make_directory_with_parents();
- write();
- }
- /**
- * Records what installing {@link pkg} will do: the
- * {@link files_created} destination paths do not exist yet, the
- * {@link files_overwritten} paths do and will have their content
- * replaced — back those up with {@link backup_file} before the
- * install executes. {@link state_mark_path} is the `installed/`
- * symlink {@link SystemState.mark_installed} will create and
- * {@link previous_state_target} the target that symlink held
- * before, null when nothing was marked (see
- * {@link SystemState.read_installed_target}); a rollback
- * re-links the previous target, or removes a fresh mark.
- */
- public void entry_install(CachedPackage pkg, string[] files_created, string[] files_overwritten,
- string? state_mark_path = null, string? previous_state_target = null) throws Error {
- var entry = new JournalEntry();
- entry.operation = OPERATION_INSTALL;
- entry.package_name = pkg.package_name;
- entry.state_mark_path = state_mark_path;
- entry.previous_state_target = previous_state_target;
- foreach(var path in files_created) {
- entry.files_created.add(path);
- }
- foreach(var path in files_overwritten) {
- entry.files_overwritten.add(path);
- }
- entries.add(entry);
- write();
- }
- /**
- * Records what removing {@link pkg} will do: every
- * {@link files_removed} path is deleted by the removal — back
- * them up with {@link backup_file} before it executes.
- * {@link state_mark_path} is the `installed/` symlink
- * {@link SystemState.unmark_installed} will delete and
- * {@link state_target} the target it held (null when nothing was
- * marked); a rollback re-links it.
- */
- public void entry_remove(CachedPackage pkg, string[] files_removed,
- string? state_mark_path = null, string? state_target = null) throws Error {
- var entry = new JournalEntry();
- entry.operation = OPERATION_REMOVE;
- entry.package_name = pkg.package_name;
- entry.state_mark_path = state_mark_path;
- entry.state_target = state_target;
- foreach(var path in files_removed) {
- entry.files_removed.add(path);
- }
- entries.add(entry);
- write();
- }
- /**
- * Copies {@link file_path} into {@link package_name}'s backup
- * area, preserving mode and ownership metadata. A symbolic link
- * is recorded as a sidecar holding its target, so restoration
- * re-creates the link itself rather than a copy of whatever it
- * pointed at.
- */
- public void backup_file(string file_path, string package_name) throws Error {
- var backup_path = backup_path_for(file_path, package_name);
- var parent = File.new_for_path(backup_path).get_parent();
- if(parent != null && !parent.query_exists()) {
- parent.make_directory_with_parents();
- }
- var info = File.new_for_path(file_path)
- .query_info(FileAttribute.STANDARD_TYPE + "," + FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
- if(info.get_file_type() == FileType.SYMBOLIC_LINK) {
- FileUtils.set_contents(backup_path + LINK_SIDECAR_SUFFIX, info.get_symlink_target() ?? "");
- return;
- }
- File.new_for_path(file_path).copy(File.new_for_path(backup_path), FileCopyFlags.ALL_METADATA);
- }
- /**
- * Marks {@link package_name}'s latest uncompleted entry done: the
- * operation succeeded, so a rollback has to undo it.
- */
- public void complete(string package_name) throws Error {
- for(int i = (int)entries.length - 1; i >= 0; i--) {
- if(entries[i].package_name == package_name && !entries[i].completed) {
- entries[i].completed = true;
- break;
- }
- }
- write();
- }
- /**
- * Undoes every recorded operation in reverse order — completed
- * ones plus a trailing half-applied one whose own operation
- * failed — removing again what was installed and installing
- * again what was removed. Steps that fail are reported and
- * skipped, never retried; the backup directory is kept
- * afterwards for inspection. Follow with
- * {@link describe_rollback}.
- */
- public void rollback() {
- undone = new Vector<string>();
- for(int i = (int)entries.length - 1; i >= 0; i--) {
- var entry = entries[i];
- try {
- if(entry.operation == OPERATION_INSTALL) {
- rollback_install(entry);
- }
- else {
- rollback_remove(entry);
- }
- }
- catch(Error e) {
- warning(@"[Usm] Could not roll back the $(entry.operation) of \"$(entry.package_name)\": $(e.message)");
- }
- }
- }
- /**
- * A human-readable transcript of what the last {@link rollback}
- * undid, one line per operation; when nothing was ever recorded
- * the transaction had changed nothing, and the description says
- * so instead.
- */
- public string describe_rollback() {
- if(undone.length == 0) {
- return "Nothing had been changed when the transaction failed, so there was nothing to roll back.";
- }
- var builder = new StringBuilder("Rolled back after the failure:\n");
- foreach(var line in undone) {
- builder.append(" ").append(line).append("\n");
- }
- return builder.str;
- }
- /**
- * Removes the backup directory entirely — the success path. The
- * {@link backup_root} goes with it when no other transaction is
- * leaving a directory behind.
- */
- public void finish() throws Error {
- Util.delete_tree(directory);
- try {
- File.new_for_path(backup_root).delete();
- }
- catch(Error e) {
- // a non-empty root belongs to a concurrent transaction
- }
- }
- private void rollback_install(JournalEntry entry) throws Error {
- for(int i = (int)entry.files_created.length - 1; i >= 0; i--) {
- delete_if_present(entry.files_created[i]);
- }
- for(int i = (int)entry.files_overwritten.length - 1; i >= 0; i--) {
- restore_file(entry.files_overwritten[i], entry.package_name);
- }
- if(entry.state_mark_path != null) {
- if(entry.previous_state_target == null) {
- delete_if_present((!)entry.state_mark_path);
- }
- else {
- relink((!)entry.state_mark_path, (!)entry.previous_state_target);
- }
- }
- var parts = new Vector<string>();
- parts.add(@"removed $(entry.files_created.length) created file(s)");
- if(entry.files_overwritten.length > 0) {
- parts.add(@"restored $(entry.files_overwritten.length) overwritten file(s)");
- }
- if(entry.state_mark_path != null) {
- parts.add(entry.previous_state_target == null ? "removed the installed mark" : "restored the previous installed mark");
- }
- undone.add(@"uninstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
- }
- private void rollback_remove(JournalEntry entry) throws Error {
- for(int i = (int)entry.files_removed.length - 1; i >= 0; i--) {
- restore_file(entry.files_removed[i], entry.package_name);
- }
- if(entry.state_mark_path != null && entry.state_target != null) {
- relink((!)entry.state_mark_path, (!)entry.state_target);
- }
- var parts = new Vector<string>();
- parts.add(@"restored $(entry.files_removed.length) file(s)");
- if(entry.state_mark_path != null && entry.state_target != null) {
- parts.add("re-created the installed mark");
- }
- undone.add(@"reinstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
- }
- private void delete_if_present(string path) throws Error {
- try {
- File.new_for_path(path).delete();
- }
- catch(IOError.NOT_FOUND e) {
- }
- }
- private void relink(string mark_path, string target) throws Error {
- delete_if_present(mark_path);
- File.new_for_path(mark_path).make_symbolic_link(target);
- }
- private void restore_file(string file_path, string package_name) throws Error {
- var backup_path = backup_path_for(file_path, package_name);
- var sidecar_path = backup_path + LINK_SIDECAR_SUFFIX;
- if(File.new_for_path(sidecar_path).query_exists()) {
- string target;
- FileUtils.get_contents(sidecar_path, out target);
- delete_if_present(file_path);
- File.new_for_path(file_path).make_symbolic_link(target);
- return;
- }
- var dest = File.new_for_path(file_path);
- var parent = dest.get_parent();
- if(parent != null && !parent.query_exists()) {
- parent.make_directory_with_parents();
- }
- delete_if_present(file_path);
- File.new_for_path(backup_path).copy(dest, FileCopyFlags.ALL_METADATA);
- }
- /** The backup location mirroring {@link file_path} under this journal's area. */
- private string backup_path_for(string file_path, string package_name) {
- var mirrored = file_path.has_prefix(Path.DIR_SEPARATOR_S) ? file_path.substring(1) : file_path;
- return Path.build_filename(directory, package_name, mirrored);
- }
- private void write() throws Error {
- var builder = new StringBuilder("[");
- for(int i = 0; i < entries.length; i++) {
- if(i > 0) {
- builder.append(",");
- }
- entries[i].write(builder);
- }
- builder.append("]");
- FileUtils.set_contents(Path.build_filename(directory, "journal.json"), builder.str);
- }
- /** One recorded operation: everything a rollback needs to undo it. */
- private class JournalEntry {
- public string operation = "";
- public string package_name = "";
- public bool completed = false;
- public Vector<string> files_created = new Vector<string>();
- public Vector<string> files_overwritten = new Vector<string>();
- public Vector<string> files_removed = new Vector<string>();
- public string? state_mark_path = null;
- public string? state_target = null;
- public string? previous_state_target = null;
- public void write(StringBuilder builder) {
- var fields = new Vector<string>();
- fields.add(json_field("operation", json_string(operation)));
- fields.add(json_field("package", json_string(package_name)));
- fields.add(json_field("status", json_string(completed ? "completed" : "in-progress")));
- fields.add(json_field("files_created", json_string_array(files_created)));
- fields.add(json_field("files_overwritten", json_string_array(files_overwritten)));
- fields.add(json_field("files_removed", json_string_array(files_removed)));
- if(state_mark_path != null) {
- fields.add(json_field("state_entry", json_string((!)state_mark_path)));
- }
- builder.append("{").append(string.joinv(",", fields.to_array())).append("}");
- }
- private static string json_field(string key, string value) {
- return json_string(key) + ":" + value;
- }
- private static string json_string(string value) {
- var builder = new StringBuilder("\"");
- for(int i = 0; i < value.length; i++) {
- char c = value[i];
- switch(c) {
- case '"':
- builder.append("\\\"");
- break;
- case '\\':
- builder.append("\\\\");
- break;
- case '\b':
- builder.append("\\b");
- break;
- case '\f':
- builder.append("\\f");
- break;
- case '\n':
- builder.append("\\n");
- break;
- case '\r':
- builder.append("\\r");
- break;
- case '\t':
- builder.append("\\t");
- break;
- default:
- if(c < 0x20) {
- builder.append_printf("\\u%04x", (int)c);
- }
- else {
- builder.append_c(c);
- }
- break;
- }
- }
- return builder.append("\"").str;
- }
- private static string json_string_array(Vector<string> values) {
- var escaped = new Vector<string>();
- foreach(var value in values) {
- escaped.add(json_string(value));
- }
- return "[" + string.joinv(",", escaped.to_array()) + "]";
- }
- }
- }
- }
|