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
29 changes: 26 additions & 3 deletions src/commands/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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; mountPath?: string }
type InfoService = { name: string; type?: string; port?: number; volumeGib?: number; volume?: boolean; mountPath?: string; pgVersion?: number; public?: boolean }

// The info endpoint may list services as an array or keep the manifest's map shape — render both.
export function normalizeInfoServices(raw: unknown): InfoService[] {
Expand All @@ -51,6 +51,8 @@ export function normalizeInfoServices(raw: unknown): InfoService[] {
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,
pgVersion: typeof s?.pgVersion === 'number' ? s.pgVersion : undefined,
public: typeof s?.public === 'boolean' ? s.public : 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 @@ -79,6 +81,20 @@ export type TemplateInfo = {
variables?: unknown
}

// The type plus what the manifest fixes about it: a Postgres major, or whether anyone can read a bucket.
function infoKind(s: InfoService): string | undefined {
if (s.type === 'postgres' && s.pgVersion !== undefined) return `postgres ${s.pgVersion}`
if (s.type === 'storage') return `storage, ${s.public ? 'public' : 'private'}`
return s.type && s.type !== 'compute' ? s.type : undefined
}

// One line per bucket the template opens to the world, so a deploy never makes one public unannounced.
export function publicBucketLines(services: unknown): string[] {
return normalizeInfoServices(services)
.filter((s) => s.type === 'storage' && s.public === true)
.map((s) => `${s.name}: public bucket, anyone can read its files (anonymous public-read)`)
}

// `bold` is injected so the renderer stays pure (tests pass identity; the command passes ANSI
// bold on a TTY).
export function templateInfoLines(t: TemplateInfo, bold: (s: string) => string = (s) => s): string[] {
Expand All @@ -96,7 +112,7 @@ export function templateInfoLines(t: TemplateInfo, bold: (s: string) => string =
// 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 ${s.mountPath ?? '/data'}` : undefined
const bits = [s.type && s.type !== 'compute' ? s.type : undefined, s.port ? `port ${s.port}` : undefined, disk].filter(Boolean)
const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, disk].filter(Boolean)
return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`
})
lines.push(`services (${services.length}): ${summary.join(', ')}`)
Expand Down Expand Up @@ -367,10 +383,12 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts =
let manifest: TemplateManifest | undefined
let source: GitHubSource | undefined
let vars: TemplateVar[]
let services: unknown // what the template declares, for the one thing the deployer must be told
if (mode.kind === 'github') {
const fetched = await (deps.fetchGitHub ?? ((t: GitHubTarget) => fetchGitHubTemplate(t)))(mode.target)
manifest = fetched.manifest
source = fetched.source
services = manifest.services
vars = collectManifestVariables(manifest)
// Exactly ONE line in front of today's output. A second "deploying template …" line
// would read as a duplicate of the "deploying template <code> to branch <branch>" line below,
Expand All @@ -380,12 +398,14 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts =
}
} else if (mode.kind === 'local') {
manifest = loadTemplateManifest(mode.dir) // parse + local validation (pinned images, described vars)
services = manifest.services
vars = collectManifestVariables(manifest)
if (!quiet) info(`deploying local template ${manifest.code}@${manifest.version}`)
} else {
// Learn the variable set up front from the registry so prompting happens before the POST.
const tpl = await api.request('GET', `/templates/${encodeURIComponent(mode.code)}`)
vars = normalizeInfoVariables((tpl.template ?? tpl).variables)
services = (tpl.template ?? tpl).services
}

// --json asked for parseable output, so a caller that happens to own a TTY still gets the error.
Expand Down Expand Up @@ -421,7 +441,10 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts =
const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id
const acceptedRegion = (res.body.deployment ?? res.body).region
const codeLabel = manifest?.code ?? target
if (!quiet) info(`deploying template ${codeLabel} to branch ${branchName}${acceptedRegion ? ` in ${acceptedRegion}` : ''} (${deploymentId})`)
if (!quiet) {
info(`deploying template ${codeLabel} to branch ${branchName}${acceptedRegion ? ` in ${acceptedRegion}` : ''} (${deploymentId})`)
for (const line of publicBucketLines(services)) info(line)
}
// The poll route is keyed by deployment id, not project: name the project so agent mode signs
// with the project-bound session (a bootstrap session is rejected as "for a different project").
const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => {} : info, deps.wait)
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ tpl.command('info <code>').description('Show a template: version, upstream pin,
.option('--json').action(guard((code, o) => template.templateInfo(code, o)))
tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a branch — a registry code, a local directory containing insta.template.yaml (a path-looking target is always read as a directory), or a github.com URL (https://github.com/<owner>/<repo>[/tree/<ref>[/<dir>]]) whose manifest is fetched with your own git credentials. Missing required variables are prompted for on a terminal; generator-backed (secret:N) and defaulted ones are resolved by the platform')
.option('--branch <b>', 'target branch (default: current)')
.option('--region <region>', 'region for every service the template creates, e.g. us-east (see `insta config regions`)')
.option('--region <region>', 'region for every service the template creates (a storage bucket has none), e.g. us-east (see `insta config regions`)')
.option('--set <NAME=value>', 'set a template variable (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[])
.option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
.option('--json')
Expand Down
42 changes: 11 additions & 31 deletions src/template-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Local insta.template.yaml parsing + validation for `insta template deploy ./dir` — the CLI
// twin of the platform's src/provisioning/templateManifest.ts (THE authority; the executor
// revalidates every manifest). Local checks exist to fail fast with a file the author can act
// on, before anything travels, so the rules here mirror the server's exactly — plus two
// on, before anything travels, so the rules here mirror the server's for web and worker services and leave every other type to it — plus two
// authoring lints the server does not enforce: images must be pinned, and required variables
// need a description unless a generator answers for the user. Everything here is pure over the
// parsed document (unit-tested); only loadTemplateManifest touches disk.
Expand All @@ -23,7 +23,9 @@ export type ManifestEnv = {
}

export type ManifestService = {
type?: string // web | worker | postgres | redis | mysql | mongodb (the four managed types are declared bare: no image, port, volume or env)
type?: string // web and worker are judged here, every other type (postgres, redis, mysql, mongodb, storage) is the platform's call
pgVersion?: number // postgres only, a Postgres major the platform offers, checked by the platform
public?: boolean // storage only, anonymous public-read, checked by the platform
image?: string
build?: string
port?: number
Expand Down Expand Up @@ -64,9 +66,10 @@ const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/
export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/
const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/

// The platform provisions these and owns everything about them (platform templateManifest.ts).
const MANAGED_TYPES = ['postgres', 'redis', 'mysql', 'mongodb']
const MANIFEST_TYPES = ['web', 'worker', ...MANAGED_TYPES]
// The compute vocabulary every CLI has known. Any other type is the platform's to accept or refuse.
const COMPUTE_TYPES = ['web', 'worker']
// Datastores that never gain a url or host. Storage is left off because a public bucket may get an address.
const NO_ADDRESS_TYPES = ['postgres', 'redis', 'mysql', 'mongodb']

// What counts as a digest is the PLATFORM's call, not ours: registry.ts's DIGEST regex, verbatim.
const DIGEST = /^sha256:[a-f0-9]{64}$/
Expand Down Expand Up @@ -128,31 +131,8 @@ export function validateManifest(m: TemplateManifest): string[] {
const where = `services.${name}`
// typeof, not String(): a YAML array like [redis] would otherwise stringify to its lone element.
const type = typeof svc.type === 'string' ? svc.type : undefined
if (!type || !MANIFEST_TYPES.includes(type)) {
problems.push(`${where}.type must be one of ${MANIFEST_TYPES.join(', ')}`)
}
// A managed datastore is BARE: the platform owns its image, port, sizing, credentials and env,
// so every other rule below would be asking about fields it must not carry. Mirrors the
// 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', '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} })`)
}
}
const groups = ['fixed', 'generated', 'platform', 'required', 'optional']
const envShell = bare.env
if (envShell !== undefined) {
const emptyShell = !!envShell && typeof envShell === 'object' && !Array.isArray(envShell)
&& Object.entries(envShell as Record<string, unknown>).every(([g, v]) =>
groups.includes(g) && !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0)
if (!emptyShell) {
problems.push(`${where}.env: a ${type} service is platform-managed and carries no env — declare it bare ({ type: ${type} })`)
}
}
continue
}
// Every other type, and every field and env on it, is the platform's to judge. It answers 400 with its own list.
if (!type || !COMPUTE_TYPES.includes(type)) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A service without a type now passes local validation because this branch continues when type is undefined. Add a missing-type error before skipping unrecognized types so incomplete manifests still fail locally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/template-manifest.ts, line 135:

<comment>A service without a `type` now passes local validation because this branch continues when `type` is undefined. Add a missing-type error before skipping unrecognized types so incomplete manifests still fail locally.</comment>

<file context>
@@ -128,31 +131,8 @@ export function validateManifest(m: TemplateManifest): string[] {
-      continue
-    }
+    // Every other type, and every field and env on it, is the platform's to judge. It answers 400 with its own list.
+    if (!type || !COMPUTE_TYPES.includes(type)) continue
     if (svc.image && svc.build) problems.push(`${where}: image and build are mutually exclusive`)
     if (!svc.image && !svc.build) problems.push(`${where}: one of image or build is required`)
</file context>
Suggested change
if (!type || !COMPUTE_TYPES.includes(type)) continue
if (svc.type === undefined) {
problems.push(`${where}.type is required`)
continue
}
if (!type || !COMPUTE_TYPES.includes(type)) continue

if (svc.image && svc.build) problems.push(`${where}: image and build are mutually exclusive`)
if (!svc.image && !svc.build) problems.push(`${where}: one of image or build is required`)
// A parsed YAML document holds whatever the author typed, so both scalars are type-checked the
Expand Down Expand Up @@ -228,7 +208,7 @@ export function validateManifest(m: TemplateManifest): string[] {
if (!svcRef) continue
const target = services[svcRef[1]!]
const targetType = target && typeof target.type === 'string' ? target.type : undefined
if (targetType && MANAGED_TYPES.includes(targetType)) {
if (targetType && NO_ADDRESS_TYPES.includes(targetType)) {
problems.push(`services.${name}.env.fixed.${varName}: '${svcRef[1]}' is a managed ${targetType}, so it has no url or host. Use its platform credentials instead: \${{services.${svcRef[1]}.<KEY>}} under env.platform`)
}
}
Expand Down
Loading
Loading