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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 `<meta name="robots" content="noindex">`,
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.

Expand Down
4 changes: 4 additions & 0 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@ landing:
groups: []
alphasynbio: null

# Whether the app has launched (its landing-page card is live). Until then prod pages
# carry <meta name="robots" content="noindex">, 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
16 changes: 16 additions & 0 deletions chart/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ spec:
name: {{ .Release.Name }}-config
key: frontendOnly

- name: ENVIRONMENT

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note (not reproduced; I have no cluster or helm here). These configMapKeyRefs aren't optional. If the new chart template is deployed with values that lack environment or prodUrl, for example an existing app's own values or a separate deploy repo, the pod fails to start (CreateContainerConfigError). It wouldn't fall back to "unset = no banner". In this repo the template and the generated values always change together, and the existing keys follow the same pattern, so I'm not raising it as a defect. optional: true on the two new keys would make the "unset shows nothing" design hold for the chart as well as for the image.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Both refs are optional: true in d286b76. An unset key becomes "" through envsubst, which shows no banner.

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
2 changes: 2 additions & 0 deletions chart/values.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions chart/values.staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
48 changes: 33 additions & 15 deletions scripts/app-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
// - Managed lines in hand-written files end with `# app:sync <key>`; only the value
// before that comment is replaced, with the derived value named by <key>. Every
// tracked file containing such a tag is managed (see managedFiles).
// - The <title> 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';
Expand Down Expand Up @@ -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;
}

Expand All @@ -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}`,
};
}

Expand Down Expand Up @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -202,11 +208,23 @@ export function updateManagedLines(text, derived, file) {
}).join('\n');
}

export function updateTitle(html, name) {
const escaped = name.replace(/&/g, '&amp;').replace(/</g, '&lt;');
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}</title>`);
const NOINDEX = /\n[ \t]*<meta name="robots" content="noindex" \/>/;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

launched: true only removes the exact tag that sync wrote. Any other robots noindex tag survives, so a launched site can stay out of search results without anyone noticing. (Reproduced.) NOINDEX matches only <meta name="robots" content="noindex" />. In a probe with launched: true, each of these was left in index.html, and re-syncing kept it, so --check passes:

  • <meta name="robots" content="noindex"> (no self-closing slash)
  • <meta name="robots" content="noindex, nofollow" />
  • <meta content="noindex" name="robots" />

A hand edit like adding nofollow is the obvious way to end up there, and the result is a site that has launched but never appears in search. Possible fix: when launched is true, throw if index.html still contains any <meta name="robots" …noindex…> that the regex can't remove. That's the same "fail rather than skip" rule #21 adopted for tags.

Minor, same regex: if two copies of the exact tag exist, re-syncing keeps both (stable at 2). That's harmless.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reproduced with your three variants, then fixed in d286b76. After removing its own tag, app:sync now refuses any remaining <meta name="robots">, launched or not, and says indexing is controlled by launched. A test covers all three variants in both states.


export function updateIndexHtml(html, app) {
const escaped = app.name.replace(/&/g, '&amp;').replace(/</g, '&lt;');
const title = /([ \t]*)<title>[^<]*<\/title>/;
if (!title.test(html)) throw new Error('src/index.html has no <title>');
// 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}</title>`
+ (app.launched ? '' : `\n${indent}<meta name="robots" content="noindex" />`));
return out;
}

export function outputs(app, readFile) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 <key>):');
for (const file of managedFiles()) console.log(` ${file}`);
console.log(' src/index.html (<title>)');
console.log(' src/index.html (<title>, robots meta)');
return 0;
}

Expand Down
67 changes: 57 additions & 10 deletions scripts/app-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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</title></head>', 'A & <B>'), '<head><title>A &amp; &lt;B></title></head>');
assert.throws(() => updateTitle('<head></head>', 'x'), /no <title>/);
assert.equal(updateIndexHtml('<head><title>test</title></head>', { name: 'A & <B>', launched: true }),
'<head><title>A &amp; &lt;B></title></head>');
assert.throws(() => updateIndexHtml('<head></head>', LAUNCHED), /no <title>/);
});

test('a tagged line the rewriter cannot parse is an error, not a silent skip', () => {
Expand All @@ -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</title></head>';
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, "<head><title>A $&amp; B $` C $' D</title></head>");
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', () => {
Expand Down Expand Up @@ -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 = '<head>\n <title>t</title>\n <base href="/" />\n</head>';
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 = [
'<meta name="robots" content="noindex">',
'<meta name="robots" content="noindex, nofollow" />',
'<meta content="noindex" name="robots" />',
'<meta name=robots content=noindex>',
'<meta name="googlebot" content="noindex" />',
];
for (const tag of variants) {
const html = `<head>\n <title>t</title>\n ${tag}\n</head>`;
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');
});
2 changes: 2 additions & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<app-staging-banner></app-staging-banner>

<header>
<div class="logo">
<img [src]="branding.logo.src" [width]="branding.logo.width" [height]="branding.logo.height" alt="" />
Expand Down
2 changes: 2 additions & 0 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RouterOutlet } from "@angular/router";

import { APP_IDENTITY } from "./app-identity";
import { BRANDING } from "./family-branding";
import { StagingBannerComponent } from "./components/staging-banner/staging-banner.component";

@Component({
selector: "app-root",
Expand All @@ -17,6 +18,7 @@ import { BRANDING } from "./family-branding";
MenuModule,
NgIf,
RouterOutlet,
StagingBannerComponent,
],
})
export class AppComponent {
Expand Down
49 changes: 49 additions & 0 deletions src/app/components/staging-banner/staging-banner.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { Meta } from '@angular/platform-browser';

import { EnvironmentService } from '~/app/services/environment.service';
import { StagingBannerComponent } from './staging-banner.component';

function render(config: { environment?: string; prodUrl?: string } | undefined) {
TestBed.configureTestingModule({
imports: [StagingBannerComponent],
providers: [{ provide: EnvironmentService, useValue: { getEnvConfig: () => config } }],
});
const fixture = TestBed.createComponent(StagingBannerComponent);
fixture.detectChanges();
return { el: fixture.nativeElement as HTMLElement, meta: TestBed.inject(Meta) };
}

describe('StagingBannerComponent', () => {
afterEach(() => document.querySelector('meta[name="robots"]')?.remove());

it('on staging, says so, links the released tool and asks not to be indexed', () => {
const { el, meta } = render({ environment: 'staging', prodUrl: 'https://x.platform.moleculemaker.org' });

expect(el.textContent).toContain('internal testing build');
expect(el.querySelector('a')?.getAttribute('href')).toBe('https://x.platform.moleculemaker.org');
expect(meta.getTag('name="robots"')?.content).toBe('noindex');
});

it('locally, shows the banner without a link when there is no prod URL', () => {
const { el } = render({ environment: 'local', prodUrl: '' });

expect(el.textContent).toContain('internal testing build');
expect(el.querySelector('a')).toBeNull();
});

it('on prod, shows nothing and adds no robots tag', () => {
const { el, meta } = render({ environment: 'prod', prodUrl: 'https://x.platform.moleculemaker.org' });

expect(el.textContent?.trim()).toBe('');
expect(meta.getTag('name="robots"')).toBeNull();
});

it('with no environment configured, shows nothing', () => {
expect(render({}).el.textContent?.trim()).toBe('');
});

it('before any config has loaded, shows nothing', () => {
expect(render(undefined).el.textContent?.trim()).toBe('');
});
});
Loading
Loading