diff --git a/README.md b/README.md index e601a6f..1fb26eb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular `app.yaml` is the single source for everything that names the app: its id, display name, family (`alphasynthesis` or `alphasynbio`), Matomo site, backend job type, Figma -frames and landing-page cards. The comments in the file describe each field. +frames, landing-page cards, and whether it has launched. The comments in the file describe each field. After editing it, run: @@ -31,6 +31,11 @@ feedback address) and every file it manages. Never edit a generated file by hand. CI runs `npm run app:sync -- --check` and fails if any is out of date with `app.yaml`. +Until `launched: true`, `src/index.html` carries ``, +so the unlisted prod site stays out of search results. Every non-prod deployment +(staging, local) also shows an internal-testing banner linking to the released tool, +driven by `environment` and `prodUrl` in the chart's runtime config. + The family selects the header branding (`src/app/family-branding.ts`) and the deployment conventions (prod domain, namespace). Everything else is shared. diff --git a/app.yaml b/app.yaml index 54244e6..b6ba010 100644 --- a/app.yaml +++ b/app.yaml @@ -39,5 +39,9 @@ landing: groups: [] alphasynbio: null +# Whether the app has launched (its landing-page card is live). Until then prod pages +# carry , so the unlisted prod site isn't indexed. +launched: false + # The frontend-template commit this app was created from. Null in the template itself. template_commit: null diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index d686ed0..eb23a85 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -57,6 +57,22 @@ spec: name: {{ .Release.Name }}-config key: frontendOnly + - name: ENVIRONMENT + valueFrom: + configMapKeyRef: + name: {{ .Release.Name }}-config + key: environment + # Optional: config without it means no banner, not a failed pod + optional: true + + - name: PROD_URL + valueFrom: + configMapKeyRef: + name: {{ .Release.Name }}-config + key: prodUrl + # Optional: config without it means no banner, not a failed pod + optional: true + ports: - containerPort: 80 imagePullPolicy: Always diff --git a/chart/values.prod.yaml b/chart/values.prod.yaml index 603e634..59f0da8 100644 --- a/chart/values.prod.yaml +++ b/chart/values.prod.yaml @@ -18,6 +18,8 @@ config: signOutUrl: https://auth.platform.moleculemaker.org/oauth2/sign_out?rd=https%3A%2F%2Ftest.platform.moleculemaker.org%2Fconfiguration userInfoUrl: https://auth.platform.moleculemaker.org/oauth2/userinfo frontendOnly: "false" + environment: prod + prodUrl: https://test.platform.moleculemaker.org extraDeploy: - apiVersion: traefik.io/v1alpha1 kind: Middleware diff --git a/chart/values.staging.yaml b/chart/values.staging.yaml index d81b8c3..945f693 100644 --- a/chart/values.staging.yaml +++ b/chart/values.staging.yaml @@ -15,3 +15,5 @@ config: signOutUrl: https://mmli2.ncsa.illinois.edu/oauth2/sign_out?rd=https%3A%2F%2Ftest.frontend.staging.mmli2.ncsa.illinois.edu%2Fconfiguration userInfoUrl: https://mmli2.ncsa.illinois.edu/oauth2/userinfo frontendOnly: "false" + environment: staging + prodUrl: https://test.platform.moleculemaker.org diff --git a/chart/values.yaml b/chart/values.yaml index 33cec01..945616c 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -12,4 +12,6 @@ config: signOutUrl: https://mmli2.ncsa.illinois.edu/oauth2/sign_out?rd=https%3A%2F%2Ftest.proxy.localhost%2Fconfiguration userInfoUrl: https://mmli2.ncsa.illinois.edu/oauth2/userinfo frontendOnly: "false" + environment: local + prodUrl: https://test.platform.moleculemaker.org extraDeploy: [] diff --git a/scripts/app-sync.mjs b/scripts/app-sync.mjs index bda7862..7f28bbb 100644 --- a/scripts/app-sync.mjs +++ b/scripts/app-sync.mjs @@ -10,7 +10,8 @@ // - Managed lines in hand-written files end with `# app:sync `; only the value // before that comment is replaced, with the derived value named by . Every // tracked file containing such a tag is managed (see managedFiles). -// - The in src/index.html, so the raw page names the app before it boots. +// - The <title> and robots meta in src/index.html, so the raw page names the app and +// stays out of search results until app.yaml says it has launched. import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -77,6 +78,7 @@ export function validate(app) { errors.push(`family must be one of: ${Object.keys(FAMILIES).join(', ')}`); } if (!Number.isInteger(app?.matomo_site_id)) errors.push('matomo_site_id must be an integer'); + if (typeof app?.launched !== 'boolean') errors.push('launched must be true or false'); return errors; } @@ -96,15 +98,19 @@ export function derive(app) { }; } -function oauthConfig(env, host, backend) { +// `environment` drives the internal-testing banner: anything but prod shows it, +// linking to `prodUrl` +function runtimeConfig(auth, environment, host, backend, prodHost) { const rd = encodeURIComponent(`https://${host}/configuration`); return { hostname: backend, basePath: '', - signInUrl: `${AUTH[env]}/start?rd=${rd}`, - signOutUrl: `${AUTH[env]}/sign_out?rd=${rd}`, - userInfoUrl: `${AUTH[env]}/userinfo`, + signInUrl: `${AUTH[auth]}/start?rd=${rd}`, + signOutUrl: `${AUTH[auth]}/sign_out?rd=${rd}`, + userInfoUrl: `${AUTH[auth]}/userinfo`, frontendOnly: 'false', + environment, + prodUrl: `https://${prodHost}`, }; } @@ -145,14 +151,14 @@ export function render(app) { 'chart/values.yaml': toYaml({ ingress: { hostname: d.local_host, tls: false, annotations: {} }, controller: { image: `${d.image}:main` }, - config: oauthConfig('staging', d.local_host, BACKEND.staging), + config: runtimeConfig('staging', 'local', d.local_host, BACKEND.staging, d.prod_host), extraDeploy: [], }), 'chart/values.staging.yaml': toYaml({ ingress: { hostname: d.staging_host, tls: true, annotations: TLS_ANNOTATIONS }, controller: { image: `${d.image}:staging` }, - config: oauthConfig('staging', d.staging_host, BACKEND.staging), + config: runtimeConfig('staging', 'staging', d.staging_host, BACKEND.staging, d.prod_host), }), 'chart/values.prod.yaml': toYaml({ @@ -166,7 +172,7 @@ export function render(app) { }, }, controller: { image: `${d.image}:main` }, - config: oauthConfig('prod', d.prod_host, BACKEND.prod), + config: runtimeConfig('prod', 'prod', d.prod_host, BACKEND.prod, d.prod_host), extraDeploy: [{ apiVersion: 'traefik.io/v1alpha1', kind: 'Middleware', @@ -202,11 +208,23 @@ export function updateManagedLines(text, derived, file) { }).join('\n'); } -export function updateTitle(html, name) { - const escaped = name.replace(/&/g, '&').replace(/</g, '<'); - if (!/<title>[^<]*<\/title>/.test(html)) throw new Error('src/index.html has no <title>'); - // A function, so `$&` and friends in the name are inserted literally - return html.replace(/<title>[^<]*<\/title>/, () => `<title>${escaped}`); +const NOINDEX = /\n[ \t]*/; + +export function updateIndexHtml(html, app) { + const escaped = app.name.replace(/&/g, '&').replace(/[^<]*<\/title>/; + if (!title.test(html)) throw new Error('src/index.html has no '); + // Functions, so `$&` and friends in the name are inserted literally + let out = html.replace(NOINDEX, () => ''); + // Any other robots or noindex tag (any crawler, quoted or not) would survive launch + // and keep the site out of search + if (/<meta\b[^>]*\b(name=["']?robots\b|content=["']?[^"'>]*noindex)/i.test(out)) { + throw new Error('src/index.html has a robots meta tag app:sync did not write; remove it ' + + '(indexing is controlled by `launched` in app.yaml)'); + } + out = out.replace(title, (_, indent) => `${indent}<title>${escaped}` + + (app.launched ? '' : `\n${indent}`)); + return out; } export function outputs(app, readFile) { @@ -215,7 +233,7 @@ export function outputs(app, readFile) { for (const file of managedFiles()) { files[file] = updateManagedLines(readFile(file), derived, file); } - files['src/index.html'] = updateTitle(readFile('src/index.html'), app.name); + files['src/index.html'] = updateIndexHtml(readFile('src/index.html'), app); return files; } @@ -243,7 +261,7 @@ function main(argv) { for (const file of Object.keys(render(app))) console.log(` ${file}`); console.log('\nFiles with managed lines (# app:sync ):'); for (const file of managedFiles()) console.log(` ${file}`); - console.log(' src/index.html ()'); + console.log(' src/index.html (<title>, robots meta)'); return 0; } diff --git a/scripts/app-sync.test.mjs b/scripts/app-sync.test.mjs index 2327abb..fd1494f 100644 --- a/scripts/app-sync.test.mjs +++ b/scripts/app-sync.test.mjs @@ -3,13 +3,19 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { parse } from 'yaml'; -import { FAMILIES, derive, managedFiles, render, updateManagedLines, updateTitle, validate } from './app-sync.mjs'; +import { FAMILIES, derive, managedFiles, render, updateIndexHtml, updateManagedLines, validate } from './app-sync.mjs'; const fixture = (name) => parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')); const generated = (app, file) => parse(render(app)[file]); +// The live apps predate the staging banner's config keys +const withoutBannerConfig = (values) => { + delete values.config.environment; + delete values.config.prodUrl; + return values; +}; -const EZSPEC = { id: 'ezspecificity', name: 'EZSpecificity', family: 'alphasynthesis', matomo_site_id: 1 }; -const MUTAGENESIS = { id: 'mutagenesis', name: 'Mutagenesis', family: 'alphasynbio', matomo_site_id: 21 }; +const EZSPEC = { id: 'ezspecificity', name: 'EZSpecificity', family: 'alphasynthesis', matomo_site_id: 1, launched: true }; +const MUTAGENESIS = { id: 'mutagenesis', name: 'Mutagenesis', family: 'alphasynbio', matomo_site_id: 21, launched: true }; // Images are the expected difference from the live apps: the template pushes every app // to public ghcr with pr-N/staging and main tags. ezspecificity is still on Docker Hub, @@ -19,7 +25,7 @@ const TAG = { staging: 'staging', prod: 'main' }; test('alphasynthesis chart values match ezspecificity-frontend as deployed, apart from the image', () => { for (const env of ['staging', 'prod']) { const expected = fixture(`ezspecificity.values.${env}.yaml`); - const actual = generated(EZSPEC, `chart/values.${env}.yaml`); + const actual = withoutBannerConfig(generated(EZSPEC, `chart/values.${env}.yaml`)); assert.equal(actual.controller.image, `ghcr.io/moleculemaker/ezspecificity-frontend:${TAG[env]}`); delete expected.controller; delete actual.controller; @@ -33,7 +39,7 @@ test('alphasynbio chart values match mutagenesis-frontend, apart from the image delete expected.imagePullSecrets; delete expected.imagePullSecret; delete expected.controller; - const actual = generated(MUTAGENESIS, `chart/values.${env}.yaml`); + const actual = withoutBannerConfig(generated(MUTAGENESIS, `chart/values.${env}.yaml`)); assert.equal(actual.controller.image, `ghcr.io/ibiofoundry/mutagenesis-frontend:${TAG[env]}`); delete actual.controller; assert.deepEqual(actual, expected, env); @@ -71,13 +77,16 @@ test('an unknown managed-line key is an error, not a silent skip', () => { test('validation rejects a bad slug, an unknown family and a missing site id', () => { assert.deepEqual(validate(EZSPEC), []); - const errors = validate({ id: 'EZ Kinetics', name: 'x', family: 'other', matomo_site_id: null }); + const errors = validate({ id: 'EZ Kinetics', name: 'x', family: 'other', matomo_site_id: null, launched: false }); assert.equal(errors.length, 3); }); +const LAUNCHED = { name: 'x', launched: true }; + test('the page title is the app name, escaped', () => { - assert.equal(updateTitle('<head><title>test', 'A & '), 'A & <B>'); - assert.throws(() => updateTitle('', 'x'), /no /); + assert.equal(updateIndexHtml('<head><title>test', { name: 'A & ', launched: true }), + 'A & <B>'); + assert.throws(() => updateIndexHtml('', LAUNCHED), /no /); }); test('a tagged line the rewriter cannot parse is an error, not a silent skip', () => { @@ -100,9 +109,10 @@ test('a "- key: value" list item keeps its key', () => { test('replacement patterns in the name are inserted literally', () => { const html = '<head><title>old'; - const once = updateTitle(html, "A $& B $` C $' D"); + const app = { name: "A $& B $` C $' D", launched: true }; + const once = updateIndexHtml(html, app); assert.equal(once, "A $& B $` C $' D"); - assert.equal(updateTitle(once, "A $& B $` C $' D"), once, 'stable on re-sync'); + assert.equal(updateIndexHtml(once, app), once, 'stable on re-sync'); }); test('the id must leave room for derived resource names within a 63-character label', () => { @@ -130,6 +140,43 @@ test('no tagged files is an empty list, not an error; other git failures still t assert.throws(() => managedFiles(notARepo), /not a git repository/); }); +test('staging and local config turn the banner on and link prod; prod config turns it off', () => { + assert.deepEqual( + [generated(EZSPEC, 'chart/values.yaml').config, generated(EZSPEC, 'chart/values.staging.yaml').config, + generated(EZSPEC, 'chart/values.prod.yaml').config].map((c) => [c.environment, c.prodUrl]), + [['local', 'https://ezspecificity.platform.moleculemaker.org'], + ['staging', 'https://ezspecificity.platform.moleculemaker.org'], + ['prod', 'https://ezspecificity.platform.moleculemaker.org']]); +}); + +test('noindex is in the page until the app is launched, once, and stable on re-sync', () => { + const html = '\n t\n \n'; + const unlaunched = updateIndexHtml(html, { name: 't', launched: false }); + assert.equal(unlaunched.match(/name="robots" content="noindex"/g).length, 1); + assert.equal(updateIndexHtml(unlaunched, { name: 't', launched: false }), unlaunched); + assert.equal(updateIndexHtml(unlaunched, { name: 't', launched: true }), html); +}); + +test('launched must be true or false', () => { + assert.equal(validate({ ...EZSPEC, launched: 'yes' }).length, 1); +}); + +test('a robots meta tag that app:sync did not write is an error, launched or not', () => { + const variants = [ + '', + '', + '', + '', + '', + ]; + for (const tag of variants) { + const html = `\n t\n ${tag}\n`; + for (const launched of [true, false]) { + assert.throws(() => updateIndexHtml(html, { name: 't', launched }), /robots/, `${tag} launched=${launched}`); + } + } +}); + test('local chart values pin a tag that is actually pushed', () => { assert.equal(generated(EZSPEC, 'chart/values.yaml').controller.image, 'ghcr.io/moleculemaker/ezspecificity-frontend:main'); }); diff --git a/src/app/app.component.html b/src/app/app.component.html index 08692d9..c1174d5 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,5 @@ + +