|
|
@@ -1,59 +1,64 @@
|
|
|
using Invercargill;
|
|
|
+using Invercargill.DataStructures;
|
|
|
using Usm;
|
|
|
|
|
|
namespace UsmWeb {
|
|
|
|
|
|
/**
|
|
|
- * Read-side access to the on-disk USM repository this app browses and
|
|
|
- * serves, built entirely on libusm: {@link Usm.Repository.from_file} for
|
|
|
- * the `.usmr`, {@link Usm.RepositoryListing.from_stream} for
|
|
|
- * `public/PACKAGES.usml`.
|
|
|
+ * One on-disk USM repository discovered under the configured parent
|
|
|
+ * directory: a subdirectory holding a `.usmr` beside a `public/`
|
|
|
+ * directory, built on libusm via {@link Usm.Repository.from_file} and
|
|
|
+ * {@link Usm.RepositoryListing.from_stream}.
|
|
|
*
|
|
|
- * The repository root comes from {@link UsmWebConfig.repo_dir} (the
|
|
|
- * `--repo` argument, `USM_WEB_REPO_DIR` or the `/repo` default) and is
|
|
|
- * expected to hold one `.usmr` beside a `public/` directory. Both files
|
|
|
- * are re-read when their mtimes change, so a rebuilt repository is
|
|
|
- * picked up without a restart.
|
|
|
+ * Each entry tracks its `.usmr` and `public/PACKAGES.usml` mtimes
|
|
|
+ * independently, so one repository can be rebuilt and re-read without
|
|
|
+ * disturbing the others.
|
|
|
*/
|
|
|
- public class RepositoryService : Object {
|
|
|
+ public class RepositoryEntry : Object {
|
|
|
|
|
|
- private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
|
|
|
+ /** The repository's directory (absolute). */
|
|
|
+ public string dir_path { get; private set; }
|
|
|
+
|
|
|
+ /** The absolute path of the `.usmr` inside {@link dir_path}. */
|
|
|
+ public string usmr_path { get; private set; }
|
|
|
|
|
|
private Repository? repository;
|
|
|
private RepositoryListing? listing;
|
|
|
- private string? usmr_path;
|
|
|
private int64 usmr_mtime;
|
|
|
private int64 listing_mtime;
|
|
|
|
|
|
+ public RepositoryEntry(string dir_path, string usmr_path) {
|
|
|
+ this.dir_path = dir_path;
|
|
|
+ this.usmr_path = usmr_path;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Whether both the `.usmr` and the listing are currently loaded. */
|
|
|
+ public bool is_loaded {
|
|
|
+ get { return repository != null && listing != null; }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Reloads the `.usmr` and listing when either file changed on disk
|
|
|
- * (or was never loaded); a first failure throws, later failures keep
|
|
|
- * the previously loaded snapshot so an in-flight rebuild cannot take
|
|
|
- * the running app down.
|
|
|
+ * (or was never loaded); a failure throws while keeping the
|
|
|
+ * previously loaded snapshot, so an in-flight rebuild cannot lose
|
|
|
+ * what is already held.
|
|
|
*/
|
|
|
public void ensure_loaded() throws Error {
|
|
|
- var path = find_usmr();
|
|
|
- if (path == null) {
|
|
|
- throw new UsmWebError.INVALID_CONFIGURATION(
|
|
|
- @"No *.usmr found in \"$(config.repo_dir)\" — is the repository mounted?");
|
|
|
- }
|
|
|
-
|
|
|
- int64 current_usmr_mtime = file_mtime((!)path);
|
|
|
- int64 current_listing_mtime = file_mtime(listing_path());
|
|
|
- if (repository != null && listing != null
|
|
|
+ int64 current_usmr_mtime = file_mtime(usmr_path);
|
|
|
+ int64 current_listing_mtime = file_mtime(listing_location());
|
|
|
+ if (is_loaded
|
|
|
&& current_usmr_mtime == usmr_mtime
|
|
|
&& current_listing_mtime == listing_mtime) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (repository == null || current_usmr_mtime != usmr_mtime) {
|
|
|
- repository = new Repository.from_file((!)path);
|
|
|
- usmr_path = (!)path;
|
|
|
+ repository = new Repository.from_file(usmr_path);
|
|
|
usmr_mtime = current_usmr_mtime;
|
|
|
}
|
|
|
|
|
|
- if (current_listing_mtime != listing_mtime || listing == null) {
|
|
|
- var file = File.new_for_path(listing_path());
|
|
|
+ if (listing == null || current_listing_mtime != listing_mtime) {
|
|
|
+ var file = File.new_for_path(listing_location());
|
|
|
listing = new RepositoryListing.from_stream(new DataInputStream(file.read()));
|
|
|
listing_mtime = current_listing_mtime;
|
|
|
}
|
|
|
@@ -66,14 +71,14 @@ namespace UsmWeb {
|
|
|
}
|
|
|
|
|
|
/** The loaded `PACKAGES.usml` entries, in listing order. */
|
|
|
- public Enumerable<RepositoryListingEntry> entries() throws Error {
|
|
|
+ public Enumerable<RepositoryListingEntry> listing_entries() throws Error {
|
|
|
ensure_loaded();
|
|
|
return ((!)listing).entries;
|
|
|
}
|
|
|
|
|
|
/** The listing entry whose package file is `file`, or null. */
|
|
|
public RepositoryListingEntry? find_entry(string file) throws Error {
|
|
|
- foreach (var entry in entries()) {
|
|
|
+ foreach (var entry in listing_entries()) {
|
|
|
if (Path.get_basename(entry.path) == file) {
|
|
|
return entry;
|
|
|
}
|
|
|
@@ -81,51 +86,247 @@ namespace UsmWeb {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- /** The absolute path of the `.usmr` this app serves a rewritten copy of. */
|
|
|
- public string usmr_location() throws Error {
|
|
|
- ensure_loaded();
|
|
|
- return (!)usmr_path;
|
|
|
- }
|
|
|
-
|
|
|
/** The absolute path of the verbatim-served `PACKAGES.usml`. */
|
|
|
public string listing_location() {
|
|
|
- return listing_path();
|
|
|
+ return Path.build_filename(dir_path, "public", "PACKAGES.usml");
|
|
|
}
|
|
|
|
|
|
/** The absolute path of a package archive inside `public/`. */
|
|
|
public string package_location(string file) {
|
|
|
- return Path.build_filename(config.repo_dir, "public", file);
|
|
|
+ return Path.build_filename(dir_path, "public", file);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int64 file_mtime(string path) {
|
|
|
+ try {
|
|
|
+ var info = File.new_for_path(path).query_info(
|
|
|
+ FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE);
|
|
|
+ return (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
|
|
|
+ } catch (Error e) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Read-side access to the on-disk USM repositories this app browses and
|
|
|
+ * serves.
|
|
|
+ *
|
|
|
+ * The repositories live under {@link UsmWebConfig.repo_dir}: every
|
|
|
+ * subdirectory containing a `.usmr` is discovered by scanning, or only
|
|
|
+ * those named by {@link UsmWebConfig.repositories} are used when that
|
|
|
+ * list is configured. A repository is addressed by the name in its
|
|
|
+ * `.usmr`, which is also its routing segment (`/repo/<name>/…`). A
|
|
|
+ * legacy single-repository layout with the `.usmr` directly inside the
|
|
|
+ * configured directory is still served as one repository.
|
|
|
+ *
|
|
|
+ * Discovery re-runs on every {@link ensure_loaded}, so repositories
|
|
|
+ * added or removed on disk are picked up without a restart; each
|
|
|
+ * repository's `.usmr` and `PACKAGES.usml` reload independently when
|
|
|
+ * their mtimes change. A repository that fails to (re)load is skipped
|
|
|
+ * with a warning rather than taking the whole app down.
|
|
|
+ */
|
|
|
+ public class RepositoryService : Object {
|
|
|
+
|
|
|
+ private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
|
|
|
+
|
|
|
+ private Vector<RepositoryEntry> discovered = new Vector<RepositoryEntry>();
|
|
|
+ private Vector<RepositoryEntry> loaded = new Vector<RepositoryEntry>();
|
|
|
+ private Dictionary<string, RepositoryEntry> entries_by_dir = new Dictionary<string, RepositoryEntry>();
|
|
|
+ private string? legacy_dir;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Discovers the repositories under the configured parent directory
|
|
|
+ * and (re)loads each one. Throws when nothing is configured or
|
|
|
+ * discovered, or when nothing discovered can be loaded; individual
|
|
|
+ * failures only warn and drop that repository until it loads again.
|
|
|
+ */
|
|
|
+ public void ensure_loaded() throws Error {
|
|
|
+ var current = discover();
|
|
|
+
|
|
|
+ var still_current = new Vector<RepositoryEntry>();
|
|
|
+ Error? first_failure = null;
|
|
|
+ foreach (var entry in current) {
|
|
|
+ try {
|
|
|
+ entry.ensure_loaded();
|
|
|
+ still_current.add(entry);
|
|
|
+ } catch (Error e) {
|
|
|
+ warning("[usm-web] Repository at %s failed to load: %s\n", entry.dir_path, e.message);
|
|
|
+ if (first_failure == null) {
|
|
|
+ first_failure = e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var unique = new Vector<RepositoryEntry>();
|
|
|
+ var seen_names = new Dictionary<string, RepositoryEntry>();
|
|
|
+ foreach (var entry in still_current) {
|
|
|
+ var name = entry.repo().name;
|
|
|
+ RepositoryEntry? seen = null;
|
|
|
+ seen_names.try_get(name, out seen);
|
|
|
+ if (seen != null) {
|
|
|
+ warning("[usm-web] \"%s\" is also served by %s — keeping %s\n",
|
|
|
+ name, entry.dir_path, ((!)seen).dir_path);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ seen_names.set(name, entry);
|
|
|
+ unique.add(entry);
|
|
|
+ }
|
|
|
+
|
|
|
+ discovered.clear();
|
|
|
+ discovered.add_all(current);
|
|
|
+ loaded.clear();
|
|
|
+ loaded.add_all(unique);
|
|
|
+
|
|
|
+ if (loaded.length == 0) {
|
|
|
+ if (first_failure != null) {
|
|
|
+ throw first_failure;
|
|
|
+ }
|
|
|
+ throw new UsmWebError.INVALID_CONFIGURATION(
|
|
|
+ @"No repositories found under \"$(config.repo_dir)\" — is the parent directory mounted?");
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- private string listing_path() {
|
|
|
- return Path.build_filename(config.repo_dir, "public", "PACKAGES.usml");
|
|
|
+ /** The currently loadable repositories, in discovery order. */
|
|
|
+ public Enumerable<RepositoryEntry> entries() throws Error {
|
|
|
+ ensure_loaded();
|
|
|
+ return loaded;
|
|
|
}
|
|
|
|
|
|
- private string? find_usmr() {
|
|
|
+ /**
|
|
|
+ * The repository whose `.usmr` is named `name`, or null when no
|
|
|
+ * loaded repository carries that name. A discovered repository that
|
|
|
+ * cannot be read surfaces its error (so callers can 500 rather than
|
|
|
+ * 404) unless another repository matches cleanly first.
|
|
|
+ */
|
|
|
+ public RepositoryEntry? find(string name) throws Error {
|
|
|
+ ensure_loaded();
|
|
|
+
|
|
|
+ Error? first_failure = null;
|
|
|
+ foreach (var entry in discovered) {
|
|
|
+ string entry_name;
|
|
|
+ try {
|
|
|
+ entry_name = entry.repo().name;
|
|
|
+ } catch (Error e) {
|
|
|
+ if (first_failure == null) {
|
|
|
+ first_failure = e;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (entry_name == name) {
|
|
|
+ return entry;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (first_failure != null) {
|
|
|
+ throw first_failure;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Discovers repository directories: the configured subdirectories
|
|
|
+ * when {@link UsmWebConfig.repositories} names them, otherwise every
|
|
|
+ * subdirectory holding a `.usmr`, falling back to the legacy
|
|
|
+ * single-repository layout. Already-known directories keep their
|
|
|
+ * entry (and loaded snapshot).
|
|
|
+ */
|
|
|
+ private Vector<RepositoryEntry> discover() throws Error {
|
|
|
+ var result = new Vector<RepositoryEntry>();
|
|
|
+ var dir_names = new Vector<string>();
|
|
|
+
|
|
|
+ if (config.repositories.length > 0) {
|
|
|
+ foreach (var name in config.repositories) {
|
|
|
+ dir_names.add(name);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ try {
|
|
|
+ var dir = Dir.open(config.repo_dir);
|
|
|
+ string? child;
|
|
|
+ while ((child = dir.read_name()) != null) {
|
|
|
+ dir_names.add((!)child);
|
|
|
+ }
|
|
|
+ } catch (Error e) {
|
|
|
+ throw new UsmWebError.INVALID_CONFIGURATION(
|
|
|
+ @"Cannot read repository parent \"$(config.repo_dir)\": $(e.message)");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (var name in dir_names.order_by<string>(n => n, Operators.comparison<string>())) {
|
|
|
+ var child_path = Path.build_filename(config.repo_dir, name);
|
|
|
+ if (!FileUtils.test(child_path, FileTest.IS_DIR)) {
|
|
|
+ if (config.repositories.length > 0) {
|
|
|
+ throw new UsmWebError.INVALID_CONFIGURATION(
|
|
|
+ @"\"$name\" from the configured repositories list is not a directory under \"$(config.repo_dir)\"");
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var usmr_path = find_usmr(child_path);
|
|
|
+ if (usmr_path == null) {
|
|
|
+ if (config.repositories.length > 0) {
|
|
|
+ throw new UsmWebError.INVALID_CONFIGURATION(
|
|
|
+ @"\"$name\" from the configured repositories list has no *.usmr under \"$(config.repo_dir)\"");
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ result.add(entry_for(child_path, (!)usmr_path));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (result.length == 0) {
|
|
|
+ var legacy_usmr = find_usmr(config.repo_dir);
|
|
|
+ if (legacy_usmr != null) {
|
|
|
+ if (legacy_dir != config.repo_dir) {
|
|
|
+ legacy_dir = config.repo_dir;
|
|
|
+ warning("[usm-web] \"$(config.repo_dir)\" holds a .usmr directly — serving it as a single repository (move it into a subdirectory for the multi-repository layout)\n");
|
|
|
+ }
|
|
|
+ result.add(entry_for(config.repo_dir, (!)legacy_usmr));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ drop_stale(result);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Reuses the entry for `dir_path` when discovery finds it again. */
|
|
|
+ private RepositoryEntry entry_for(string dir_path, string usmr_path) {
|
|
|
+ RepositoryEntry? existing = null;
|
|
|
+ entries_by_dir.try_get(dir_path, out existing);
|
|
|
+ if (existing != null) {
|
|
|
+ return (!)existing;
|
|
|
+ }
|
|
|
+ var created = new RepositoryEntry(dir_path, usmr_path);
|
|
|
+ entries_by_dir.set(dir_path, created);
|
|
|
+ return created;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Forgets entries whose directory is no longer discovered. */
|
|
|
+ private void drop_stale(Vector<RepositoryEntry> current) {
|
|
|
+ var current_dirs = new Dictionary<string, RepositoryEntry>();
|
|
|
+ foreach (var entry in current) {
|
|
|
+ current_dirs.set(entry.dir_path, entry);
|
|
|
+ }
|
|
|
+ foreach (var dir_path in entries_by_dir.keys.to_array()) {
|
|
|
+ if (!current_dirs.has(dir_path)) {
|
|
|
+ entries_by_dir.remove(dir_path);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The first `*.usmr` directly inside `dir`, or null when there is none. */
|
|
|
+ private static string? find_usmr(string dir) {
|
|
|
try {
|
|
|
- var dir = Dir.open(config.repo_dir);
|
|
|
+ var handle = Dir.open(dir);
|
|
|
string? name = null;
|
|
|
string? found = null;
|
|
|
- while ((name = dir.read_name()) != null) {
|
|
|
+ while ((name = handle.read_name()) != null) {
|
|
|
if (name.has_suffix(".usmr")) {
|
|
|
found = name;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
- return found == null ? null : Path.build_filename(config.repo_dir, (!)found);
|
|
|
+ return found == null ? null : Path.build_filename(dir, (!)found);
|
|
|
} catch (Error e) {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- private static int64 file_mtime(string path) {
|
|
|
- try {
|
|
|
- var info = File.new_for_path(path).query_info(
|
|
|
- FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE);
|
|
|
- return (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
|
|
|
- } catch (Error e) {
|
|
|
- return -1;
|
|
|
- }
|
|
|
- }
|
|
|
}
|
|
|
}
|