RepositoryService.vala 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Usm;
  4. namespace UsmWeb {
  5. /** The kind of a discovered repository directory. */
  6. public enum RepositoryKind {
  7. /** A USM source repository: `.usmr` + `public/PACKAGES.usml` + `.usmc` packages. */
  8. USM,
  9. /** A DCBA artifact repository: `.dcbar` + `public/ARTIFACTS.usml` + `.dcba` artifacts. */
  10. DCBA;
  11. /** The wire/display name used on generated pages. */
  12. public string to_string() {
  13. switch (this) {
  14. case RepositoryKind.USM:
  15. return "usm";
  16. case RepositoryKind.DCBA:
  17. return "dcba";
  18. default:
  19. assert_not_reached();
  20. }
  21. }
  22. }
  23. /**
  24. * One on-disk repository discovered under the configured parent
  25. * directory, built on libusm via {@link Usm.Repository.from_file}.
  26. *
  27. * A USM entry is a subdirectory holding a `.usmr` beside a `public/`
  28. * directory with `PACKAGES.usml`; a DCBA entry is the artifact-repo
  29. * counterpart holding a `.dcbar` (the same JSON shape) beside a
  30. * `public/` directory with `ARTIFACTS.usml` and `.dcba` files. The
  31. * two kinds share the loading machinery but expose disjoint members;
  32. * reaching for a member of the other kind throws instead of
  33. * silently answering nonsense.
  34. *
  35. * Each entry tracks its descriptor (`.usmr`/`.dcbar`) and listing
  36. * (`PACKAGES.usml`/`ARTIFACTS.usml`) mtimes independently, so one
  37. * repository can be rebuilt and re-read without disturbing the
  38. * others, and both file roles reload when their own mtime moves.
  39. */
  40. public class RepositoryEntry : Object {
  41. /** The repository's directory (absolute). */
  42. public string dir_path { get; private set; }
  43. /**
  44. * The absolute path of the `.usmr` (USM entries) or `.dcbar`
  45. * (DCBA entries) inside {@link dir_path}.
  46. */
  47. public string descriptor_path { get; private set; }
  48. /** This repository's kind (source or artifact). */
  49. public RepositoryKind kind { get; private set; }
  50. /**
  51. * Compatibility alias for {@link descriptor_path} — the endpoint
  52. * serving `repo.usmr` reads the raw descriptor through this name.
  53. */
  54. public string usmr_path {
  55. get { return descriptor_path; }
  56. }
  57. private Repository? repository;
  58. private RepositoryListing? listing;
  59. private DcbaListing? dcba_listing;
  60. private int64 descriptor_mtime;
  61. private int64 listing_mtime;
  62. public RepositoryEntry.usm(string dir_path, string usmr_path) {
  63. this.dir_path = dir_path;
  64. this.kind = RepositoryKind.USM;
  65. this.descriptor_path = usmr_path;
  66. }
  67. public RepositoryEntry.dcba(string dir_path, string dcbar_path) {
  68. this.dir_path = dir_path;
  69. this.kind = RepositoryKind.DCBA;
  70. this.descriptor_path = dcbar_path;
  71. }
  72. /** Whether the descriptor and this kind's listing are currently loaded. */
  73. public bool is_loaded {
  74. get {
  75. if (kind == RepositoryKind.DCBA) {
  76. return repository != null && dcba_listing != null;
  77. }
  78. return repository != null && listing != null;
  79. }
  80. }
  81. /**
  82. * Reloads the descriptor and listing when either file changed on
  83. * disk (or was never loaded); a failure throws while keeping the
  84. * previously loaded snapshot, so an in-flight rebuild cannot lose
  85. * what is already held.
  86. */
  87. public void ensure_loaded() throws Error {
  88. int64 current_descriptor_mtime = file_mtime(descriptor_path);
  89. int64 current_listing_mtime = file_mtime(current_listing_path());
  90. if (is_loaded
  91. && current_descriptor_mtime == descriptor_mtime
  92. && current_listing_mtime == listing_mtime) {
  93. return;
  94. }
  95. if (repository == null || current_descriptor_mtime != descriptor_mtime) {
  96. repository = new Repository.from_file(descriptor_path);
  97. descriptor_mtime = current_descriptor_mtime;
  98. }
  99. if (kind == RepositoryKind.DCBA) {
  100. if (dcba_listing == null || current_listing_mtime != listing_mtime) {
  101. var file = File.new_for_path(current_listing_path());
  102. dcba_listing = new DcbaListing.from_stream(new DataInputStream(file.read()));
  103. listing_mtime = current_listing_mtime;
  104. }
  105. } else if (listing == null || current_listing_mtime != listing_mtime) {
  106. var file = File.new_for_path(current_listing_path());
  107. listing = new RepositoryListing.from_stream(new DataInputStream(file.read()));
  108. listing_mtime = current_listing_mtime;
  109. }
  110. }
  111. /** The loaded repository metadata (name, summary, signing key). */
  112. public Repository repo() throws Error {
  113. ensure_loaded();
  114. return (!)repository;
  115. }
  116. /**
  117. * The loaded `PACKAGES.usml` entries, in listing order. USM
  118. * entries only — DCBA entries answer with
  119. * {@link dcba_listing_entries}.
  120. */
  121. public Enumerable<RepositoryListingEntry> listing_entries() throws Error {
  122. if (kind == RepositoryKind.DCBA) {
  123. throw_wrong_kind("PACKAGES.usml entries");
  124. }
  125. ensure_loaded();
  126. return ((!)listing).entries;
  127. }
  128. /**
  129. * The loaded `ARTIFACTS.usml` entries, in listing order. DCBA
  130. * entries only — USM entries answer with {@link listing_entries}.
  131. */
  132. public Enumerable<DcbaListingEntry> dcba_listing_entries() throws Error {
  133. if (kind != RepositoryKind.DCBA) {
  134. throw_wrong_kind("ARTIFACTS.usml entries");
  135. }
  136. ensure_loaded();
  137. return ((!)dcba_listing).entries;
  138. }
  139. /**
  140. * The listing entry whose package file is `file`, or null. USM
  141. * entries only.
  142. */
  143. public RepositoryListingEntry? find_entry(string file) throws Error {
  144. foreach (var entry in listing_entries()) {
  145. if (Path.get_basename(entry.path) == file) {
  146. return entry;
  147. }
  148. }
  149. return null;
  150. }
  151. /**
  152. * The artifact entry whose package name and version match `name`
  153. * and `version` exactly (a plain string comparison on both, per
  154. * the DCBA specification — listing strings never mutate through
  155. * a {@link Usm.Version} round trip), or null when this repository
  156. * carries no artifact for that package. DCBA entries only.
  157. */
  158. public DcbaListingEntry? find_dcba_artifact(string name, string version) throws Error {
  159. foreach (var entry in dcba_listing_entries()) {
  160. if (entry.name == name && entry.version == version) {
  161. return entry;
  162. }
  163. }
  164. return null;
  165. }
  166. /**
  167. * The absolute path of the verbatim-served `PACKAGES.usml`. USM
  168. * entries only — DCBA entries answer with
  169. * {@link artifacts_listing_location}.
  170. */
  171. public string listing_location() throws Error {
  172. if (kind == RepositoryKind.DCBA) {
  173. throw_wrong_kind("PACKAGES.usml location");
  174. }
  175. return Path.build_filename(dir_path, "public",
  176. Usm.RepositoryClient.LISTING_NAME);
  177. }
  178. /**
  179. * The absolute path of the verbatim-served `ARTIFACTS.usml`. DCBA
  180. * entries only — USM entries answer with {@link listing_location}.
  181. */
  182. public string artifacts_listing_location() throws Error {
  183. if (kind != RepositoryKind.DCBA) {
  184. throw_wrong_kind("ARTIFACTS.usml location");
  185. }
  186. return Path.build_filename(dir_path, "public",
  187. Usm.RepositoryClient.DCBA_LISTING_NAME);
  188. }
  189. /**
  190. * The path of this kind's listing file, unguarded: both kinds
  191. * keep their listing under `public/`, so {@link ensure_loaded}
  192. * watches one location per kind.
  193. */
  194. private string current_listing_path() {
  195. var listing_name = kind == RepositoryKind.DCBA
  196. ? Usm.RepositoryClient.DCBA_LISTING_NAME
  197. : Usm.RepositoryClient.LISTING_NAME;
  198. return Path.build_filename(dir_path, "public", listing_name);
  199. }
  200. /**
  201. * The actual descriptor filename inside the repository
  202. * directory (`name.usmr` or `name.dcbar`), for messages and
  203. * sibling-route naming.
  204. */
  205. public string descriptor_basename() {
  206. return Path.get_basename(descriptor_path);
  207. }
  208. /**
  209. * The absolute path of a package archive inside `public/`. USM
  210. * entries only — DCBA entries answer with
  211. * {@link artifact_location}.
  212. */
  213. public string package_location(string file) throws Error {
  214. if (kind == RepositoryKind.DCBA) {
  215. throw_wrong_kind("package location");
  216. }
  217. return Path.build_filename(dir_path, "public", file);
  218. }
  219. /**
  220. * The absolute path of a `.dcba` artifact inside `public/`. DCBA
  221. * entries only — USM entries answer with {@link package_location}.
  222. */
  223. public string artifact_location(string file) throws Error {
  224. if (kind != RepositoryKind.DCBA) {
  225. throw_wrong_kind("artifact location");
  226. }
  227. return Path.build_filename(dir_path, "public", file);
  228. }
  229. /**
  230. * Raises the cross-kind misuse error: every member documented as
  231. * kind-specific guards against being reached through the wrong
  232. * kind, so a DCBA entry never answers for a `PACKAGES.usml` and
  233. * vice versa.
  234. */
  235. private void throw_wrong_kind(string member) throws Error {
  236. throw new UsmWebError.INVALID_CONFIGURATION(
  237. @"Repository at \"$dir_path\" is a $kind repository — $member is only available for $(kind == RepositoryKind.DCBA ? RepositoryKind.USM : RepositoryKind.DCBA) repositories");
  238. }
  239. private static int64 file_mtime(string path) {
  240. try {
  241. var info = File.new_for_path(path).query_info(
  242. FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE);
  243. return (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
  244. } catch (Error e) {
  245. return -1;
  246. }
  247. }
  248. }
  249. /**
  250. * Read-side access to the on-disk repositories this app browses and
  251. * serves, in a mixed layout.
  252. *
  253. * The repositories live under {@link UsmWebConfig.repo_dir}: every
  254. * subdirectory containing a `.usmr` is a USM source repository
  255. * (`.usmc` packages listed in `public/PACKAGES.usml`) and every
  256. * subdirectory containing a `.dcbar` is a DCBA artifact repository
  257. * (`.dcba` files listed in `public/ARTIFACTS.usml`); both are
  258. * discovered by the same scan, or only those named by
  259. * {@link UsmWebConfig.repositories} are used when that list is
  260. * configured — the list applies to both kinds. A repository is
  261. * addressed by the name in its descriptor, which is also its routing
  262. * segment (`/repo/<name>/…`). A directory holding both a `.usmr`
  263. * and a `.dcbar` is a configuration error. A legacy
  264. * single-repository layout with the `.usmr` directly inside the
  265. * configured directory is still served as one USM repository.
  266. *
  267. * {@link entries} returns every loadable repository of either kind,
  268. * in discovery order; {@link usm_entries} and {@link dcba_entries}
  269. * filter it by kind for callers that iterate one world at a time
  270. * (the home package table and search only see source repositories).
  271. *
  272. * Discovery re-runs on every {@link ensure_loaded}, so repositories
  273. * added or removed on disk are picked up without a restart; each
  274. * repository's descriptor and listing reload independently when
  275. * their mtimes change. A repository that fails to (re)load is
  276. * skipped with a warning rather than taking the whole app down.
  277. */
  278. public class RepositoryService : Object {
  279. private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
  280. private Vector<RepositoryEntry> discovered = new Vector<RepositoryEntry>();
  281. private Vector<RepositoryEntry> loaded = new Vector<RepositoryEntry>();
  282. private Dictionary<string, RepositoryEntry> entries_by_dir = new Dictionary<string, RepositoryEntry>();
  283. private string? legacy_dir;
  284. /**
  285. * Discovers the repositories under the configured parent directory
  286. * and (re)loads each one. Throws when nothing is configured or
  287. * discovered, or when nothing discovered can be loaded; individual
  288. * failures only warn and drop that repository until it loads again.
  289. */
  290. public void ensure_loaded() throws Error {
  291. var current = discover();
  292. var still_current = new Vector<RepositoryEntry>();
  293. Error? first_failure = null;
  294. foreach (var entry in current) {
  295. try {
  296. entry.ensure_loaded();
  297. still_current.add(entry);
  298. } catch (Error e) {
  299. warning("[usm-web] Repository at %s failed to load: %s\n", entry.dir_path, e.message);
  300. if (first_failure == null) {
  301. first_failure = e;
  302. }
  303. }
  304. }
  305. var unique = new Vector<RepositoryEntry>();
  306. var seen_names = new Dictionary<string, RepositoryEntry>();
  307. foreach (var entry in still_current) {
  308. var name = entry.repo().name;
  309. RepositoryEntry? seen = null;
  310. seen_names.try_get(name, out seen);
  311. if (seen != null) {
  312. warning("[usm-web] \"%s\" is also served by %s — keeping %s\n",
  313. name, entry.dir_path, ((!)seen).dir_path);
  314. continue;
  315. }
  316. seen_names.set(name, entry);
  317. unique.add(entry);
  318. }
  319. discovered.clear();
  320. discovered.add_all(current);
  321. loaded.clear();
  322. loaded.add_all(unique);
  323. if (loaded.length == 0) {
  324. if (first_failure != null) {
  325. throw first_failure;
  326. }
  327. throw new UsmWebError.INVALID_CONFIGURATION(
  328. @"No repositories found under \"$(config.repo_dir)\" — is the parent directory mounted?");
  329. }
  330. }
  331. /** The currently loadable repositories, in discovery order. */
  332. public Enumerable<RepositoryEntry> entries() throws Error {
  333. ensure_loaded();
  334. return loaded;
  335. }
  336. /**
  337. * The currently loadable USM source repositories, in discovery
  338. * order — the home package table, search and package detail
  339. * flows iterate these. Same load/throw contract as
  340. * {@link entries}.
  341. */
  342. public Enumerable<RepositoryEntry> usm_entries() throws Error {
  343. var result = new Vector<RepositoryEntry>();
  344. foreach (var entry in entries()) {
  345. if (entry.kind == RepositoryKind.USM) {
  346. result.add(entry);
  347. }
  348. }
  349. return result;
  350. }
  351. /**
  352. * The currently loadable DCBA artifact repositories, in discovery
  353. * order. Same load/throw contract as {@link entries}.
  354. */
  355. public Enumerable<RepositoryEntry> dcba_entries() throws Error {
  356. var result = new Vector<RepositoryEntry>();
  357. foreach (var entry in entries()) {
  358. if (entry.kind == RepositoryKind.DCBA) {
  359. result.add(entry);
  360. }
  361. }
  362. return result;
  363. }
  364. /**
  365. * The repository whose descriptor is named `name`, or null when
  366. * no loaded repository carries that name. A discovered repository
  367. * that cannot be read surfaces its error (so callers can 500
  368. * rather than 404) unless another repository matches cleanly
  369. * first.
  370. */
  371. public RepositoryEntry? find(string name) throws Error {
  372. ensure_loaded();
  373. Error? first_failure = null;
  374. foreach (var entry in discovered) {
  375. string entry_name;
  376. try {
  377. entry_name = entry.repo().name;
  378. } catch (Error e) {
  379. if (first_failure == null) {
  380. first_failure = e;
  381. }
  382. continue;
  383. }
  384. if (entry_name == name) {
  385. return entry;
  386. }
  387. }
  388. if (first_failure != null) {
  389. throw first_failure;
  390. }
  391. return null;
  392. }
  393. /**
  394. * Discovers repository directories: the configured subdirectories
  395. * when {@link UsmWebConfig.repositories} names them, otherwise every
  396. * subdirectory holding a `.usmr` or a `.dcbar`, falling back to the
  397. * legacy single-repository layout (`.usmr` only). Already-known
  398. * directories keep their entry (and loaded snapshot).
  399. */
  400. private Vector<RepositoryEntry> discover() throws Error {
  401. var result = new Vector<RepositoryEntry>();
  402. var dir_names = new Vector<string>();
  403. if (config.repositories.length > 0) {
  404. foreach (var name in config.repositories) {
  405. dir_names.add(name);
  406. }
  407. } else {
  408. try {
  409. var dir = Dir.open(config.repo_dir);
  410. string? child;
  411. while ((child = dir.read_name()) != null) {
  412. dir_names.add((!)child);
  413. }
  414. } catch (Error e) {
  415. throw new UsmWebError.INVALID_CONFIGURATION(
  416. @"Cannot read repository parent \"$(config.repo_dir)\": $(e.message)");
  417. }
  418. }
  419. foreach (var name in dir_names.order_by<string>(n => n, Operators.comparison<string>())) {
  420. var child_path = Path.build_filename(config.repo_dir, name);
  421. if (!FileUtils.test(child_path, FileTest.IS_DIR)) {
  422. if (config.repositories.length > 0) {
  423. throw new UsmWebError.INVALID_CONFIGURATION(
  424. @"\"$name\" from the configured repositories list is not a directory under \"$(config.repo_dir)\"");
  425. }
  426. continue;
  427. }
  428. var descriptor = find_descriptor(child_path);
  429. if (descriptor == null) {
  430. if (config.repositories.length > 0) {
  431. throw new UsmWebError.INVALID_CONFIGURATION(
  432. @"\"$name\" from the configured repositories list has no *.usmr or *.dcbar under \"$(config.repo_dir)\"");
  433. }
  434. continue;
  435. }
  436. result.add(entry_for(child_path, (!)descriptor));
  437. }
  438. if (result.length == 0) {
  439. var legacy_usmr = find_usmr(config.repo_dir);
  440. if (legacy_usmr != null) {
  441. if (legacy_dir != config.repo_dir) {
  442. legacy_dir = config.repo_dir;
  443. 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");
  444. }
  445. result.add(entry_for(config.repo_dir, (!)legacy_usmr));
  446. }
  447. }
  448. drop_stale(result);
  449. return result;
  450. }
  451. /**
  452. * Reuses the entry for `dir_path` when discovery finds it again.
  453. * A directory whose descriptor kind changed on disk (a `.dcbar`
  454. * replaced by a `.usmr` between discoveries) gets a fresh entry
  455. * so no stale snapshot of the old kind lingers.
  456. */
  457. private RepositoryEntry entry_for(string dir_path, string descriptor_path) {
  458. RepositoryEntry? existing = null;
  459. entries_by_dir.try_get(dir_path, out existing);
  460. if (existing != null && ((!)existing).kind == kind_of((!)descriptor_path)) {
  461. return (!)existing;
  462. }
  463. var created = kind_of((!)descriptor_path) == RepositoryKind.DCBA
  464. ? new RepositoryEntry.dcba(dir_path, descriptor_path)
  465. : new RepositoryEntry.usm(dir_path, descriptor_path);
  466. entries_by_dir.set(dir_path, created);
  467. return created;
  468. }
  469. /** Forgets entries whose directory is no longer discovered. */
  470. private void drop_stale(Vector<RepositoryEntry> current) {
  471. var current_dirs = new Dictionary<string, RepositoryEntry>();
  472. foreach (var entry in current) {
  473. current_dirs.set(entry.dir_path, entry);
  474. }
  475. foreach (var dir_path in entries_by_dir.keys.to_array()) {
  476. if (!current_dirs.has(dir_path)) {
  477. entries_by_dir.remove(dir_path);
  478. }
  479. }
  480. }
  481. /**
  482. * The first `*.usmr` or `*.dcbar` directly inside `dir`, or null
  483. * when there is none. A directory holding both is a
  484. * configuration error.
  485. */
  486. private static string? find_descriptor(string dir) throws Error {
  487. string? usmr = find_suffix(dir, ".usmr");
  488. string? dcbar = find_suffix(dir, ".dcbar");
  489. if (usmr != null && dcbar != null) {
  490. throw new UsmWebError.INVALID_CONFIGURATION(
  491. @"\"$dir\" holds both a .usmr and a .dcbar — a repository directory must be either a USM source repository or a DCBA artifact repository, not both");
  492. }
  493. return usmr ?? dcbar;
  494. }
  495. /** The first `*.usmr` directly inside `dir`, or null when there is none. */
  496. private static string? find_usmr(string dir) {
  497. return find_suffix(dir, ".usmr");
  498. }
  499. /** The first file ending in `suffix` directly inside `dir`, or null. */
  500. private static string? find_suffix(string dir, string suffix) {
  501. try {
  502. var handle = Dir.open(dir);
  503. string? name = null;
  504. string? found = null;
  505. while ((name = handle.read_name()) != null) {
  506. if (name.has_suffix(suffix)) {
  507. found = name;
  508. break;
  509. }
  510. }
  511. return found == null ? null : Path.build_filename(dir, (!)found);
  512. } catch (Error e) {
  513. return null;
  514. }
  515. }
  516. /** The kind implied by a descriptor path's extension. */
  517. private static RepositoryKind kind_of(string descriptor_path) {
  518. return descriptor_path.has_suffix(".dcbar") ? RepositoryKind.DCBA : RepositoryKind.USM;
  519. }
  520. }
  521. }