insta 0.1.21 → 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.
@@ -624,7 +624,7 @@ export function removeTarget(count, service, opts) {
624
624
  if (opts.region !== undefined)
625
625
  throw new Error('--region does not apply to --remove');
626
626
  if (service !== undefined) {
627
- throw new Error(count !== undefined && count.trim() !== '' && Number.isFinite(Number(count)) ? '--remove lowers the replica count by one; pass no count' : `unexpected argument: ${service} (usage: insta compute scale [service] --remove <instance>)`);
627
+ throw new Error(count !== undefined && count.trim() !== '' && Number.isFinite(Number(count)) ? '--remove names one instance; pass no count' : `unexpected argument: ${service} (usage: insta compute scale [service] --remove <instance>)`);
628
628
  }
629
629
  return count;
630
630
  }
@@ -634,7 +634,7 @@ export async function computeScale(count, serviceName, opts) {
634
634
  if (opts.remove !== undefined)
635
635
  return computeRemoveInstance(removeTarget(count, serviceName, { remove: opts.remove, region: opts.region }), opts.remove, opts);
636
636
  if (count === undefined)
637
- throw new Error('a replica count is required (or --remove <instance> to drop one named instance)');
637
+ throw new Error('a replica cap is required (or --remove <instance> to drop one named instance)');
638
638
  const machineCount = parseCount(count);
639
639
  const api = await ApiClient.load();
640
640
  const p = await requireProject();
@@ -646,7 +646,7 @@ export async function computeScale(count, serviceName, opts) {
646
646
  return;
647
647
  if (opts.json)
648
648
  return printJson(res.body.service);
649
- info(`scaled compute ${svc.name} to ${machineCount} replica(s)${opts.region ? ` in ${opts.region}` : ''}`);
649
+ info(`set compute ${svc.name} replica cap to ${machineCount}${opts.region ? ` in ${opts.region}` : ''}`);
650
650
  }
651
651
  // `insta compute scale [service] --remove <instance>` — DELETE /services/:id/instances/:instance.
652
652
  async function computeRemoveInstance(serviceName, instance, opts) {
@@ -660,7 +660,7 @@ async function computeRemoveInstance(serviceName, instance, opts) {
660
660
  return;
661
661
  if (opts.json)
662
662
  return printJson(res.body.service);
663
- info(`removed ${instance} from compute ${svc.name}; ${res.body.service?.machine_count ?? '?'} replica(s) remain`);
663
+ info(`removed ${instance} from compute ${svc.name}; replica cap is now ${res.body.service?.machine_count ?? '?'}`);
664
664
  }
665
665
  // ---- limits (the resource ceiling; paid plans) ----
666
666
  // Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb".
@@ -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
@@ -375,9 +375,9 @@ compute.command('restart [service]').description("Restart a compute service by r
375
375
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
376
376
  compute.command('status [service]').description("Show a compute service's desired vs. live state")
377
377
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
378
- compute.command('scale [count] [service]').description('Set a compute service same-region replica count, 1 to 10 (paid plans only). --remove <instance> instead drops that one instance and runs one fewer; instance ids are the `instance` field of `insta compute logs --json`. The last instance cannot be removed')
378
+ compute.command('scale [count] [service]').description('Set a compute service same-region replica cap, 1 to 10 (paid plans only). A scale-to-zero service runs 1 up to the cap on demand; an always-on service runs exactly the cap, so turn always-on on to hold a fixed count. --remove <instance> instead drops that one instance: an always-on service runs one fewer, a scale-to-zero one keeps its cap; instance ids are the `instance` field of `insta compute logs --json`. The last instance cannot be removed')
379
379
  .option('--region <region>', 'region to scale in (default: the service region)')
380
- .option('--remove <instance>', 'drop this instance (inst-<12 hex>) and run one fewer replica')
380
+ .option('--remove <instance>', 'drop this instance (inst-<12 hex>)')
381
381
  .option('--json').option('--branch <branch>', 'branch (default: current)')
382
382
  .action(guard((count, service, o) => computeCmd.computeScale(count, service, o)));
383
383
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
@@ -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.21",
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": [