Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "insta",
"version": "0.1.16",
"version": "0.1.17",
"type": "module",
"description": "InstaCloud CLI — a thin client of the platform control-plane API.",
"keywords": [
Expand Down
5 changes: 3 additions & 2 deletions src/commands/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@ export function templateListLines(templates: TemplateIndexEntry[]): string[] {
// `volume` is the boolean a manifest declares now; `volumeGib` is a size a registry published
// before sizing moved to the platform. Both are read: the catalog serves whichever the row carries,
// and dropping the size on its own would quietly stop saying the service HAS a disk.
type InfoService = { name: string; type?: string; port?: number; volumeGib?: number; volume?: boolean }
type InfoService = { name: string; type?: string; port?: number; volumeGib?: number; volume?: boolean; mountPath?: string }

// The info endpoint may list services as an array or keep the manifest's map shape — render both.
export function normalizeInfoServices(raw: unknown): InfoService[] {
const one = (name: string, s: any): InfoService => ({
name, type: s?.type, port: s?.port,
volumeGib: s?.volumeGib ?? s?.volume?.size,
volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null,
mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined,
})
if (Array.isArray(raw)) return raw.map((s: any) => one(s?.name ?? '?', s))
if (raw && typeof raw === 'object') return Object.entries(raw as Record<string, any>).map(([name, s]) => one(name, s))
Expand Down Expand Up @@ -94,7 +95,7 @@ export function templateInfoLines(t: TemplateInfo, bold: (s: string) => string =
const summary = services.map((s) => {
// A size only when the registry still carries one: a manifest names no size any more, so
// "persistent /data" is all there is to say until the service exists.
const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? 'persistent /data' : undefined
const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined
const bits = [s.type && s.type !== 'compute' ? s.type : undefined, s.port ? `port ${s.port}` : undefined, disk].filter(Boolean)
return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`
})
Expand Down
13 changes: 12 additions & 1 deletion src/template-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export type ManifestService = {
port?: number
healthcheck?: string
volume?: boolean // needs a /data disk; the platform owns the size
volumeGib?: number // registry-only field, kept for normalization
alwaysOn?: boolean // idle mode; undeclared = the platform default (scale-to-zero for compute, always-on for a worker)
command?: string // entrypoint command override (runtime field)
mountPath?: string // absolute path for volume mount (runtime field)
env?: ManifestEnv
}

Expand Down Expand Up @@ -133,7 +136,7 @@ export function validateManifest(m: TemplateManifest): string[] {
// platform's own check (provisioning/templateManifest.ts) so an author hears it here.
if (type && MANAGED_TYPES.includes(type)) {
const bare = svc as Record<string, unknown>
for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) {
for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath']) {
if (bare[field] !== undefined) {
problems.push(`${where}.${field}: a ${type} service is platform-managed and carries no ${field} — declare it bare ({ type: ${type} })`)
}
Expand Down Expand Up @@ -184,6 +187,14 @@ export function validateManifest(m: TemplateManifest): string[] {
if (authored.spec !== undefined) {
problems.push(`${where}: compute size is the platform's to choose — remove spec`)
}
// Validate runtime fields: command and mountPath
if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
problems.push(`${where}.command must be a non-empty string`)
}
if (svc.mountPath !== undefined) {
if (svc.volume !== true) problems.push(`${where}.mountPath requires volume: true`)
else if (typeof svc.mountPath !== 'string' || !svc.mountPath.startsWith('/')) problems.push(`${where}.mountPath must be an absolute path`)
}
const env = svc.env ?? {}
for (const group of ['fixed', 'generated', 'required', 'optional'] as const) {
for (const varName of Object.keys(env[group] ?? {})) {
Expand Down
26 changes: 20 additions & 6 deletions test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ describe('validateManifest', () => {
it('accepts a bare managed datastore and refuses every field the platform owns', () => {
for (const type of ['postgres', 'redis', 'mysql', 'mongodb'] as const) {
expect(validateManifest({ code: 'x', version: '1', services: { store: { type } } } as unknown as TemplateManifest)).toEqual([])
for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) {
const m = { code: 'x', version: '1', services: { store: { type, [field]: true } } } as unknown as TemplateManifest
for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath']) {
const value = field === 'mountPath' ? '/x' : true
const m = { code: 'x', version: '1', services: { store: { type, [field]: value } } } as unknown as TemplateManifest
expect(validateManifest(m).join('\n')).toContain(`a ${type} service is platform-managed and carries no ${field}`)
}
}
Expand Down Expand Up @@ -314,6 +315,15 @@ describe('validateManifest', () => {
expect(validateManifest(worker({ healthcheck: '/' })).join('\n')).toMatch(/a worker has no HTTP endpoint/)
expect(validateManifest(worker({ alwaysOn: false })).join('\n')).toMatch(/a worker cannot scale to zero/)
})

it('accepts command and mountPath, and refuses the shapes the platform refuses', () => {
const web = (extra: Record<string, unknown>) =>
({ code: 'x', version: '1', services: { app: { type: 'web', image: 'a:1', healthcheck: '/', ...extra } } }) as unknown as TemplateManifest
expect(validateManifest(web({ command: 'run', volume: true, mountPath: '/app/storage' }))).toEqual([])
expect(validateManifest(web({ command: ' ' })).join('\n')).toMatch(/services\.app\.command must be a non-empty string/)
expect(validateManifest(web({ mountPath: '/a' })).join('\n')).toMatch(/services\.app\.mountPath requires volume: true/)
expect(validateManifest(web({ volume: true, mountPath: 'a' })).join('\n')).toMatch(/services\.app\.mountPath must be an absolute path/)
})
})

describe('parseManifestYaml', () => {
Expand Down Expand Up @@ -390,6 +400,8 @@ describe('templateInfoLines', () => {
expect(templateInfoLines(boolTpl)).toContain('services (1): agent (web, port 7681, persistent /data)')
const sizedTpl = { ...tpl, services: [{ name: 'agent', type: 'web', port: 7681, volumeGib: 10 }] }
expect(templateInfoLines(sizedTpl)).toContain('services (1): agent (web, port 7681, 10Gi volume)')
const customMountTpl = { ...tpl, services: [{ name: 'agent', type: 'web', port: 7681, volume: true, mountPath: '/app/storage' }] }
expect(templateInfoLines(customMountTpl)).toContain('services (1): agent (web, port 7681, persistent /app/storage)')
})

it('renders header fields, a services summary, and grouped variables', () => {
Expand All @@ -416,11 +428,13 @@ describe('templateInfoLines', () => {
bool: { type: 'worker', volume: true },
worker: { type: 'worker', volume: { size: 5 } },
norm: { volumeGib: 3 },
custom: { type: 'web', port: 7681, volume: true, mountPath: '/app/storage' },
})).toEqual([
{ name: 'app', type: 'web', port: 80, volumeGib: undefined, volume: false },
{ name: 'bool', type: 'worker', port: undefined, volumeGib: undefined, volume: true },
{ name: 'worker', type: 'worker', port: undefined, volumeGib: 5, volume: true },
{ name: 'norm', type: undefined, port: undefined, volumeGib: 3, volume: true },
{ name: 'app', type: 'web', port: 80, volumeGib: undefined, volume: false, mountPath: undefined },
{ name: 'bool', type: 'worker', port: undefined, volumeGib: undefined, volume: true, mountPath: undefined },
{ name: 'worker', type: 'worker', port: undefined, volumeGib: 5, volume: true, mountPath: undefined },
{ name: 'norm', type: undefined, port: undefined, volumeGib: 3, volume: true, mountPath: undefined },
{ name: 'custom', type: 'web', port: 7681, volumeGib: undefined, volume: true, mountPath: '/app/storage' },
])
})
it('accepts flat variable arrays too', () => {
Expand Down
Loading