From cd93bdf101b50d5e05192c053b2dbce6782242c7 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Mon, 5 Oct 2026 23:39:33 -0700 Subject: [PATCH 1/2] feat(template): leave service types and fields to the platform validateManifest stops refusing a type or a field it does not know, so an old CLI never blocks a newer platform. It keeps the compute rules for web and worker, the reference checks and the two authoring lints. template info shows a database's Postgres major and whether a bucket is public, and the deploy help says a storage bucket takes no region. --- src/commands/template.ts | 13 ++- src/index.ts | 2 +- src/template-manifest.ts | 42 +++------- test/template.test.ts | 169 ++++++++++++++++++++++++++------------- 4 files changed, 138 insertions(+), 88 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 1cfb328..4cdaac2 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -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[] { @@ -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).map(([name, s]) => one(name, s)) @@ -79,6 +81,13 @@ 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 +} + // `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[] { @@ -96,7 +105,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(', ')}`) diff --git a/src/index.ts b/src/index.ts index c1f8a8f..e6974c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -567,7 +567,7 @@ tpl.command('info ').description('Show a template: version, upstream pin, .option('--json').action(guard((code, o) => template.templateInfo(code, o))) tpl.command('deploy ').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//[/tree/[/]]) 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 ', 'target branch (default: current)') - .option('--region ', 'region for every service the template creates, e.g. us-east (see `insta config regions`)') + .option('--region ', 'region for every service the template creates (a storage bucket has none), e.g. us-east (see `insta config regions`)') .option('--set ', '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') diff --git a/src/template-manifest.ts b/src/template-manifest.ts index a24bcd9..90c8794 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -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. @@ -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 @@ -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}$/ @@ -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 - 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).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 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 @@ -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]}.}} under env.platform`) } } diff --git a/test/template.test.ts b/test/template.test.ts index 5b38b20..a8884a0 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -84,13 +84,10 @@ describe('validateManifest', () => { const m = { ...MANIFEST, services: { app: { type: 'worker', image: 'nginx:latest' } } } expect(validateManifest(m).join('\n')).toMatch(/not a pin/) }) - // The platform's service model (templateManifest.ts): type is web|worker|postgres|redis|mysql| - // mongodb, image XOR build. `lambda` (not a managed type — see services.ts's own fixture) stands - // in for an unknown type so this stays about the enum + image/build rules, not the bare-datastore ones. - it('requires a known type and exactly one of image/build', () => { - const m: TemplateManifest = { code: 'x', version: '1', services: { a: { type: 'lambda' as any, image: 'a:1', build: 'b' }, b: {} } } + // The CLI judges only the compute vocabulary, web and worker. image XOR build is its rule. + it('requires exactly one of image/build on a web or worker service', () => { + const m: TemplateManifest = { code: 'x', version: '1', services: { a: { type: 'web', image: 'a:1', build: 'b', healthcheck: '/' }, b: { type: 'worker' } } } const problems = validateManifest(m) - expect(problems).toContain('services.a.type must be one of web, worker, postgres, redis, mysql, mongodb') expect(problems).toContain('services.a: image and build are mutually exclusive') expect(problems).toContain('services.b: one of image or build is required') }) @@ -106,81 +103,84 @@ describe('validateManifest', () => { expect(validateManifest(m)).toEqual([]) }) - // Bare means bare: the platform owns the image, port, sizing, credentials and env, and silently - // ignores anything a manifest sets. Naming the field here beats being ignored server-side. - it('refuses a postgres service that tries to configure itself', () => { + // Everything but web and worker is the platform's to judge, so the CLI never blocks a type or a + // field it merely does not know. An old CLI stays usable against a newer platform. + it('sends a postgres service that tries to configure itself on to the platform to refuse', () => { const m: TemplateManifest = { code: 'x', version: '1', services: { db: { type: 'postgres', image: 'postgres:16', port: 5432, volume: true } }, } - const problems = validateManifest(m).join('\n') - expect(problems).toMatch(/services\.db\.image: a postgres service is platform-managed and carries no image/) - expect(problems).toMatch(/services\.db\.port: .* carries no port/) - expect(problems).toMatch(/services\.db\.volume: .* carries no volume/) - // The bare branch returns before the image/build rules, so it must not also demand an image. - expect(problems).not.toMatch(/one of image or build is required/) + expect(validateManifest(m)).toEqual([]) }) - it('refuses env on a postgres service, but tolerates an empty shell', () => { + it('leaves env on a postgres service to the platform, and still accepts the empty shell', () => { const withEnv: TemplateManifest = { code: 'x', version: '1', services: { db: { type: 'postgres', env: { fixed: { A: '1' } } } }, } - expect(validateManifest(withEnv).join('\n')).toMatch(/services\.db\.env: a postgres service is platform-managed and carries no env/) - - // A normalized manifest round-trips through the platform carrying empty groups; accepting the - // shell means a published manifest can be re-validated locally without edits. + expect(validateManifest(withEnv)).toEqual([]) const shell: TemplateManifest = { code: 'x', version: '1', services: { db: { type: 'postgres', env: { fixed: {}, generated: {}, required: {}, optional: {} } } }, } expect(validateManifest(shell)).toEqual([]) }) - // The three managed datastores are declared bare, exactly as postgres is. The platform owns their - // image, port, sizing and credentials, so naming any of them here could only drift from the catalog. - // All eight bare-disallowed fields are asserted (not a subset) so a future edit that drops a name - // from that array ships with a red test, not a silently-narrower rule. - it('accepts a bare managed datastore and refuses every field the platform owns', () => { - for (const type of ['postgres', 'redis', 'mysql', 'mongodb'] as const) { + + it('judges no field of a datastore or a bucket, for every type', () => { + for (const type of ['postgres', 'redis', 'mysql', 'mongodb', 'storage'] 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', 'command', 'mountPath']) { - const value = field === 'mountPath' ? '/x' : true + for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath', 'env']) { + const value = field === 'mountPath' ? '/x' : field === 'env' ? { fixed: { A: '1' } } : 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}`) + expect(validateManifest(m), `${type}.${field}`).toEqual([]) } } }) - it('names every accepted type when the type is wrong', () => { - const m = { code: 'x', version: '1', services: { a: { type: 'redys', image: 'a:1' } } } as unknown as TemplateManifest - expect(validateManifest(m).join('\n')).toContain('type must be one of web, worker, postgres, redis, mysql, mongodb') + it('accepts a postgres with a pgVersion and a public bucket bound into a web service', () => { + const m: TemplateManifest = { + code: 'x', version: '1', + services: { + db: { type: 'postgres', pgVersion: 17 }, + files: { type: 'storage', public: true }, + app: { + type: 'web', image: 'a:1', healthcheck: '/', + env: { platform: { DATABASE_URL: '${{services.db.DATABASE_URL}}', S3_KEY: '${{services.files.AWS_ACCESS_KEY_ID}}' } }, + }, + }, + } + expect(validateManifest(m)).toEqual([]) }) - // A YAML array like `type: [redis]` stringifies to exactly "redis", so a type check that reads - // it through String() would pass both the enum check and the managed-type branch, and ship the - // unchanged array to the platform. The type must be read as a real string instead. - it('rejects an array type instead of coercing it into a matching string', () => { - const m = { code: 'x', version: '1', services: { a: { type: ['redis'] } } } as unknown as TemplateManifest - const problems = validateManifest(m) - expect(problems).toContain('services.a.type must be one of web, worker, postgres, redis, mysql, mongodb') - // Must not have been read as bare-managed-redis (which would produce no other problems). - expect(problems.join('\n')).not.toMatch(/platform-managed/) + it('does not judge a type it does not know, an array type included', () => { + for (const type of ['redys', 'lambda', ['redis']]) { + const m = { code: 'x', version: '1', services: { a: { type, image: 'a:1' } } } as unknown as TemplateManifest + expect(validateManifest(m), JSON.stringify(type)).toEqual([]) + } }) - // The env bare-shell rule generalized from postgres-only to all four managed types — the very - // line this task's diff moved — so it needs its own evidence for redis, mysql and mongodb, not - // just the pre-existing postgres-only test above. - it('refuses env on any managed datastore, but tolerates the exact empty shell, for every type', () => { - for (const type of ['postgres', 'redis', 'mysql', 'mongodb'] as const) { - const withEnv = { code: 'x', version: '1', services: { db: { type, env: { fixed: { A: '1' } } } } } as unknown as TemplateManifest - expect(validateManifest(withEnv).join('\n')).toContain(`a ${type} service is platform-managed and carries no env`) - - const shell = { - code: 'x', version: '1', - services: { db: { type, env: { fixed: {}, generated: {}, platform: {}, required: {}, optional: {} } } }, - } as unknown as TemplateManifest - expect(validateManifest(shell)).toEqual([]) + // `type: [redis]` stringifies to exactly "redis", so a read through String() would take it for a datastore. + it('does not read an array type as a datastore when it checks a url ref', () => { + const m = { + code: 'x', version: '1', + services: { + store: { type: ['redis'] }, + app: { type: 'worker', image: 'a:1', env: { fixed: { TARGET: '${services.store.url}' } } }, + }, + } as unknown as TemplateManifest + expect(validateManifest(m)).toEqual([]) + }) + + // A public bucket may get an address later, so the platform and not this CLI decides on its url or host. + it('leaves a fixed-value url or host ref to a bucket to the platform', () => { + const m: TemplateManifest = { + code: 'x', version: '1', + services: { + files: { type: 'storage', public: true }, + app: { type: 'worker', image: 'a:1', env: { fixed: { TARGET: '${services.files.url}' } } }, + }, } + expect(validateManifest(m)).toEqual([]) }) // A managed datastore has no url/host: the platform refuses a fixed-value ref to one @@ -331,6 +331,17 @@ describe('parseManifestYaml', () => { const m = parseManifestYaml(['code: demo', 'version: "1.0"', 'services:', ' app:', ' type: worker', ' image: nginx:1.27'].join('\n')) expect(m.code).toBe('demo') }) + it('returns a storage service and a key it does not know exactly as written', () => { + const m = parseManifestYaml([ + 'code: demo', 'version: "1.0"', 'services:', + ' files:', ' type: storage', ' public: true', + ' db:', ' type: postgres', ' pgVersion: 17', ' flavour: spicy', + ].join('\n')) + expect(m.services).toEqual({ + files: { type: 'storage', public: true }, + db: { type: 'postgres', pgVersion: 17, flavour: 'spicy' }, + }) + }) it('lists every problem, prefixed with the source file', () => { expect(() => parseManifestYaml('code: demo\n', 'x/insta.template.yaml')).toThrow(/x\/insta\.template\.yaml is not deployable:[\s\S]*version is required[\s\S]*at least one service/) }) @@ -404,6 +415,35 @@ describe('templateInfoLines', () => { expect(templateInfoLines(customMountTpl)).toContain('services (1): agent (web, port 7681, persistent /app/storage)') }) + it('says the Postgres version and whether a bucket is public', () => { + const t = { + ...tpl, + services: { + app: { type: 'web', port: 8000 }, + db: { type: 'postgres', pgVersion: 17 }, + assets: { type: 'storage', public: true }, + scratch: { type: 'storage' }, + }, + } + expect(templateInfoLines(t)).toContain( + 'services (4): app (web, port 8000), db (postgres 17), assets (storage, public), scratch (storage, private)', + ) + }) + it('names a postgres with no version plainly, and ignores pgVersion and public on other types', () => { + const t = { ...tpl, services: { db: { type: 'postgres' }, app: { type: 'web', port: 80, pgVersion: 17, public: true } } } + expect(templateInfoLines(t)).toContain('services (2): db (postgres), app (web, port 80)') + }) + it('carries pgVersion and public only when they are the right kind of value', () => { + expect(normalizeInfoServices({ + db: { type: 'postgres', pgVersion: 17 }, + files: { type: 'storage', public: true }, + odd: { type: 'postgres', pgVersion: '17', public: 'yes' }, + })).toEqual([ + { name: 'db', type: 'postgres', pgVersion: 17, volume: false }, + { name: 'files', type: 'storage', public: true, volume: false }, + { name: 'odd', type: 'postgres', volume: false }, + ]) + }) it('renders header fields, a services summary, and grouped variables', () => { const lines = templateInfoLines(tpl) expect(lines[0]).toBe('plausible — Plausible') @@ -650,6 +690,27 @@ describe('templateDeploy', () => { expect(stdout.join('')).toContain('deploying local template plausible-fork@1.0') }) + // The platform is the authority on fields: a storage service, a pgVersion and a key the CLI has + // never heard of all travel exactly as written, and the platform's answer decides. + it('sends a public bucket, a pgVersion and an unknown service key verbatim', async () => { + const root = mkdtempSync(join(tmpdir(), 'insta-tpl-')) + mkdirSync(join(root, 'bucket')) + writeFileSync(join(root, 'bucket', 'insta.template.yaml'), [ + 'code: bucket', 'version: "1.0"', 'services:', + ' files:', ' type: storage', ' public: true', + ' db:', ' type: postgres', ' pgVersion: 17', ' flavour: spicy', + ' app:', ' type: worker', ' image: nginx:1.27', '', + ].join('\n')) + const { api, posts } = fakeApi() + await templateDeploy(join(root, 'bucket'), {}, { api, project: PROJECT, wait: NO_WAIT }) + expect(posts).toHaveLength(1) + expect(posts[0].manifest.services).toEqual({ + files: { type: 'storage', public: true }, + db: { type: 'postgres', pgVersion: 17, flavour: 'spicy' }, + app: { type: 'worker', image: 'nginx:1.27' }, + }) + }) + // --json is a contract: stdout must parse as ONE document, so no progress line may precede it. it('--json prints a single parseable JSON document in local mode', async () => { const root = manifestDir('tpl') From 4e2051d0eef539181f5d4b19e37026c6b6f9cf11 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 6 Oct 2026 10:02:08 -0700 Subject: [PATCH 2/2] feat(template): template deploy says which buckets are public Only `template info` said a bucket is public, so a person running `insta template deploy ` was never told the deploy opens one to anyone. Print one line per public bucket, with the accepted deploy line and before the progress lines: files: public bucket, anyone can read its files (anonymous public-read) The services come from what the command already has: the local or fetched manifest, or the registry detail it reads for the variables. --json keeps stdout to one document and a gated deploy stays silent, as the other human lines do. --- src/commands/template.ts | 16 ++++++++- test/template.test.ts | 75 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 4cdaac2..bf6f7e9 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -88,6 +88,13 @@ function infoKind(s: InfoService): string | undefined { 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[] { @@ -376,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 to branch " line below, @@ -389,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. @@ -430,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) diff --git a/test/template.test.ts b/test/template.test.ts index a8884a0..00bc892 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -8,7 +8,7 @@ import { } from '../src/template-manifest.js' import { templateListLines, templateInfoLines, normalizeInfoServices, normalizeInfoVariables, - parseSetFlags, resolveVariables, missingVariablesFrom, looksLikePath, deployMode, templateDeploy, + parseSetFlags, resolveVariables, missingVariablesFrom, looksLikePath, deployMode, templateDeploy, publicBucketLines, stepIndexFor, deploymentUrls, serviceStateLines, partialMessage, watchDeployment, DEPLOY_STEPS, } from '../src/commands/template.js' import { ApiError } from '../src/api.js' @@ -483,6 +483,21 @@ describe('templateInfoLines', () => { }) }) +describe('publicBucketLines', () => { + it('names each public bucket, whichever shape the services arrive in', () => { + const line = (name: string) => `${name}: public bucket, anyone can read its files (anonymous public-read)` + const map = { media: { type: 'storage', public: true }, docs: { type: 'storage', public: true }, quiet: { type: 'storage', public: false }, scratch: { type: 'storage' } } + expect(publicBucketLines(map)).toEqual([line('media'), line('docs')]) + expect(publicBucketLines(Object.entries(map).map(([name, s]) => ({ name, ...s })))).toEqual([line('media'), line('docs')]) + }) + + it('says nothing for a service that is not a bucket, or for no services at all', () => { + expect(publicBucketLines({ app: { type: 'web', public: true }, db: { type: 'postgres', public: true } })).toEqual([]) + expect(publicBucketLines({ files: { type: 'storage', public: 'yes' } })).toEqual([]) + expect(publicBucketLines(undefined)).toEqual([]) + }) +}) + describe('parseSetFlags', () => { it('parses NAME=value pairs, last occurrence winning; values may contain =', () => { expect(parseSetFlags(['A=1', 'B_2=x=y', 'A=2'])).toEqual({ A: '2', B_2: 'x=y' }) @@ -632,13 +647,14 @@ function fakeApi( deployment: any = { status: 'succeeded', services: [{ name: 'app', state: 'healthy', url: 'https://app.example' }] }, postResult: { status: number; body: any } = { status: 200, body: { deploymentId: 'dep_1' } }, templateVars: unknown = { required: [], optional: [] }, + templateServices?: unknown, ) { const posts: any[] = [] const polls: string[] = [] const pollScopes: unknown[] = [] const api = { request: async (_m: string, path: string, _body?: unknown, opts?: unknown) => { - if (path.startsWith('/templates/')) return { template: { code: 'plausible', variables: templateVars } } + if (path.startsWith('/templates/')) return { template: { code: 'plausible', variables: templateVars, ...(templateServices ? { services: templateServices } : {}) } } if (path.startsWith('/template-deployments/')) { polls.push(path); pollScopes.push(opts); return deployment } throw new Error(`unexpected GET ${path}`) }, @@ -711,6 +727,61 @@ describe('templateDeploy', () => { }) }) + // Only `template info` used to say a bucket is public, so the person deploying never heard it. + const PUBLIC_LINE = 'files: public bucket, anyone can read its files (anonymous public-read)' + const BUCKETS = { + files: { type: 'storage', public: true }, + scratch: { type: 'storage' }, + db: { type: 'postgres', pgVersion: 17 }, + app: { type: 'worker', image: 'nginx:1.27' }, + } + + it('says which buckets are public when a local manifest deploys, and nothing about a private one', async () => { + const root = mkdtempSync(join(tmpdir(), 'insta-tpl-')) + mkdirSync(join(root, 'bucket')) + writeFileSync(join(root, 'bucket', 'insta.template.yaml'), [ + 'code: bucket', 'version: "1.0"', 'services:', + ' files:', ' type: storage', ' public: true', + ' scratch:', ' type: storage', + ' app:', ' type: worker', ' image: nginx:1.27', '', + ].join('\n')) + const { api } = fakeApi() + await templateDeploy(join(root, 'bucket'), {}, { api, project: PROJECT, wait: NO_WAIT }) + const out = stdout.join('') + expect(out.split('\n').filter((l) => l.includes('public bucket'))).toEqual([PUBLIC_LINE]) + expect(out).not.toContain('scratch:') + // It rides with the accepted deploy, ahead of the progress lines. + expect(out.indexOf(PUBLIC_LINE)).toBeGreaterThan(out.indexOf('deploying template bucket to branch main')) + expect(out.indexOf(PUBLIC_LINE)).toBeLessThan(out.indexOf('create services')) + }) + + it('says it for a registry template too, from the detail the command already reads', async () => { + const { api } = fakeApi(undefined, undefined, undefined, BUCKETS) + await templateDeploy('plausible', {}, { api, project: PROJECT, wait: NO_WAIT }) + expect(stdout.join('').split('\n').filter((l) => l.includes('public bucket'))).toEqual([PUBLIC_LINE]) + }) + + it('says it for a fetched GitHub manifest', async () => { + const { api } = fakeApi() + const manifest = { code: 'bot', version: '1.4.0', services: BUCKETS } as TemplateManifest + await templateDeploy('https://github.com/acme/tpl', {}, { + api, project: PROJECT, wait: NO_WAIT, + fetchGitHub: async () => ({ source: { repo: 'acme/tpl', ref: 'main', path: '', commit: '9'.repeat(40) }, manifest }), + }) + expect(stdout.join('').split('\n').filter((l) => l.includes('public bucket'))).toEqual([PUBLIC_LINE]) + }) + + it('keeps the line out of --json output, and out of a deploy that is only gated', async () => { + const { api } = fakeApi(undefined, undefined, undefined, BUCKETS) + await templateDeploy('plausible', { json: true }, { api, project: PROJECT, wait: NO_WAIT }) + expect(stdout.join('')).not.toContain('public bucket') + expect(JSON.parse(stdout.join(''))).toMatchObject({ status: 'succeeded' }) + stdout.length = 0 + const gated = fakeApi(undefined, GATED, undefined, BUCKETS) + await templateDeploy('plausible', {}, { api: gated.api, project: PROJECT, wait: NO_WAIT }) + expect(stdout.join('')).toBe('') + }) + // --json is a contract: stdout must parse as ONE document, so no progress line may precede it. it('--json prints a single parseable JSON document in local mode', async () => { const root = manifestDir('tpl')