insta 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1723,11 +1723,30 @@ export function acquireLockFile(path, now, staleMs) {
1723
1723
  // ask whether the holder still exists.
1724
1724
  const token = `${process.pid}:${randomUUID()}`;
1725
1725
  const release = () => {
1726
+ // The claim a takeover of this token takes, so a release and a takeover never interleave.
1727
+ const claim = takeoverClaim(path, token);
1728
+ let claimed = false;
1729
+ try {
1730
+ linkSync(path, claim);
1731
+ claimed = true;
1732
+ }
1733
+ catch (e) {
1734
+ // Leave the lock unless the disk has no hardlinks, where no takeover can claim it either.
1735
+ if (!LINK_UNSUPPORTED.has(String(e?.code)))
1736
+ return;
1737
+ }
1726
1738
  try {
1727
1739
  if (readFileSync(path, 'utf8') === token)
1728
1740
  unlinkSync(path);
1729
1741
  }
1730
1742
  catch { /* already gone, or taken over by someone else */ }
1743
+ finally {
1744
+ if (claimed)
1745
+ try {
1746
+ unlinkSync(claim);
1747
+ }
1748
+ catch { /* the inode keeps its other name */ }
1749
+ }
1731
1750
  };
1732
1751
  const take = () => {
1733
1752
  try {
@@ -1859,21 +1878,22 @@ function processAlive(pid) {
1859
1878
  * `path` keeps its inode throughout, so there is no moment at which the lock
1860
1879
  * at `path` can have become somebody else's between the check and the act:
1861
1880
  * the holder judged dead cannot release, every other breaker is behind the
1862
- * claim, and a release by anyone else checks for its own token first. Should
1863
- * the old holder's release ever run, it reads a foreign token and leaves the
1864
- * file alone.
1881
+ * claim, and a release by anyone else checks for its own token first.
1882
+ * The old holder's release takes this same claim, so it never lands inside a takeover.
1883
+ * A release that cannot take it leaves the lock, to be broken like an abandoned one.
1865
1884
  *
1866
1885
  * Residual, and deliberately not "fixed": a breaker killed between the link
1867
- * and its write leaves the claim behind, and that one token can then no
1868
- * longer be broken (removing the claim by hand is the way out, and the busy
1869
- * message names the lock). Every scheme for reaping an abandoned claim needs
1886
+ * and its write, or a release killed before its unlink, leaves the claim
1887
+ * behind, and that one token can then no longer be broken or released
1888
+ * (removing the claim by hand is the way out, and the busy message names
1889
+ * the lock). Every scheme for reaping an abandoned claim needs
1870
1890
  * to decide the claim is dead and then remove it -- the same check-then-act
1871
1891
  * this function exists to eliminate, one level up. A claim left behind AFTER
1872
1892
  * the write is inert: the lock now carries the breaker's token, and the next
1873
1893
  * breaker keys its claim on that. A wedged lock is recoverable and says so;
1874
1894
  * two writers in the same section silently lose the user's config. */
1875
1895
  function breakStaleLock(path, stale, mine) {
1876
- const claim = `${path}.stale.${createHash('sha256').update(stale).digest('hex').slice(0, 32)}`;
1896
+ const claim = takeoverClaim(path, stale);
1877
1897
  try {
1878
1898
  linkSync(path, claim);
1879
1899
  }
@@ -1906,6 +1926,12 @@ function breakStaleLock(path, stale, mine) {
1906
1926
  catch { /* the inode keeps its other name */ }
1907
1927
  }
1908
1928
  }
1929
+ /** link() errors from a disk without hardlinks, where a release may skip its claim. */
1930
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS']);
1931
+ /** The hardlink that arbitrates breaking or releasing the lock `token` names. */
1932
+ function takeoverClaim(path, token) {
1933
+ return `${path}.stale.${createHash('sha256').update(token).digest('hex').slice(0, 32)}`;
1934
+ }
1909
1935
  export async function computeSSH(serviceName, opts, deps = {}) {
1910
1936
  if (opts.ensureCert !== undefined)
1911
1937
  return ensureCertForAlias(opts.ensureCert);
@@ -65,6 +65,8 @@ function serviceText(s) {
65
65
  const bits = [
66
66
  kind,
67
67
  s.port != null ? `port ${s.port}` : undefined,
68
+ // Only a web service has one, and a platform that does not serve the field says nothing.
69
+ s.type === 'web' && s.healthcheck !== undefined ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined,
68
70
  s.image ? `image ${s.image}` : undefined,
69
71
  // A null mount path on a volume is the default /data.
70
72
  s.volume ? `volume at ${s.mountPath ?? '/data'}` : undefined,
@@ -32,6 +32,7 @@ export function templateListLines(templates) {
32
32
  export function normalizeInfoServices(raw) {
33
33
  const one = (name, s) => ({
34
34
  name, type: s?.type, port: s?.port,
35
+ healthcheck: typeof s?.healthcheck === 'string' ? s.healthcheck : undefined,
35
36
  volumeGib: s?.volumeGib ?? s?.volume?.size,
36
37
  volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null,
37
38
  mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined,
@@ -91,7 +92,9 @@ export function templateInfoLines(t, bold = (s) => s) {
91
92
  // A size only when the registry still carries one: a manifest names no size any more, so
92
93
  // "persistent /data" is all there is to say until the service exists.
93
94
  const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined;
94
- const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, disk].filter(Boolean);
95
+ // Only a web service has a path to probe, and a manifest that names none has no health check.
96
+ const health = s.type === 'web' ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined;
97
+ const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, health, disk].filter(Boolean);
95
98
  return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`;
96
99
  });
97
100
  lines.push(`services (${services.length}): ${summary.join(', ')}`);
package/dist/index.js CHANGED
@@ -556,7 +556,8 @@ tpl.command('create').description("Create a community template draft from the li
556
556
  .option('--json')
557
557
  .action(guard((o) => templateAuthor.templateCreate(o)));
558
558
  tpl.command('drafts').description("List your org's community templates, drafts and published, with their status")
559
- .option('--org <id>', "target org (default: linked project's org)").option('--json')
559
+ .option('--org <id>', "target org (default: linked project's org)")
560
+ .option('--json', 'print one summary per template as the platform serves it, with no services, variables or publish requirements. insta template draft <code> --json prints one draft in full')
560
561
  .action(guard((o) => templateAuthor.templateDrafts(o)));
561
562
  tpl.command('draft <code>').description('Show one community template draft: its services, every variable with its choice, and the publish requirements')
562
563
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
@@ -14,6 +14,8 @@ export const MANIFEST_FILE = 'insta.template.yaml';
14
14
  const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
15
15
  export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
16
16
  const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/;
17
+ // The platform's HEALTHCHECK_RE (src/provisioning/templateManifest.ts), verbatim.
18
+ const HEALTHCHECK_RE = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/;
17
19
  // The compute vocabulary every CLI has known. Any other type is the platform's to accept or refuse.
18
20
  const COMPUTE_TYPES = ['web', 'worker'];
19
21
  // Datastores that never gain a url or host. Storage is left off because a public bucket may get an address.
@@ -116,10 +118,16 @@ export function validateManifest(m) {
116
118
  if (svc.alwaysOn === false)
117
119
  problems.push(`${where}.alwaysOn: a worker cannot scale to zero — nothing is routed to it, so nothing would wake it; remove alwaysOn or set it true`);
118
120
  }
119
- if (svc.type === 'web' && !svc.healthcheck)
120
- problems.push(`${where}: web services must declare a healthcheck path`);
121
- if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
122
- problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
121
+ // Optional on a web service, and a given path must pass the platform's grammar.
122
+ if (svc.type !== 'worker' && svc.healthcheck !== undefined) {
123
+ const healthcheck = scalarString(svc.healthcheck);
124
+ if (healthcheck === null)
125
+ problems.push(`${where}.healthcheck must be a string`);
126
+ else if (!healthcheck.startsWith('/'))
127
+ problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
128
+ else if (!HEALTHCHECK_RE.test(healthcheck))
129
+ problems.push(`${where}: healthcheck must be a single-slash absolute path on the service itself (no '//host', scheme, backslash or control characters), got: ${healthcheck}`);
130
+ }
123
131
  // Sizing is the platform's, capped for the org's plan. Same answers the
124
132
  // publish endpoint gives, said here so an author does not upload to find out. Read as unknown:
125
133
  // the type above admits only what is SUPPORTED, and the document is a cast over YAML.parse, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [