Repository: elastic/synthetics (ts)

Issue:
yaml anchor support for lightweight monitors
We have a large list of similar http lightweight monitors, that need e.g. the same checks. To be DRY it would be nice if yaml anchors would be supported to be able to do something like this:

```yaml
defaults: &defaults
  type: http
  schedule: '@every 5m'
  timeout: 60s
  private_locations:
    - some-location-dev
    - some-location-staging
  check.response:
    json:
      - description: is active
        expression: 'status == "active"'
      - description: is CapabilityStatement
        expression: 'resourceType == "CapabilityStatement"'

heartbeat.monitors:
- name: Monitors are awesome
  id: awesomemonitor1
  urls: "https://some-unique-url/endpoint1"
  << : *defaults
- name: Another awesome monitors
  id: awesomemonitor2
  urls: "https://some-other-unique-url/and-different-endpoint"
  << : *defaults

```

Pull request:
feat: support yml anchors during push command
+ fix #888 
+ YAML anchors help with DIY principle when same multiple monitors have similar structure. When users are maintaining a large number of monitors, this becomes super handy. 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 77a0d7c..a63b44f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
     branches:
       - main
 
+permissions:
+  contents: read
+
 jobs:
 
   lint:
diff --git a/.github/workflows/dist-tag.yml b/.github/workflows/dist-tag.yml
index 7582772..ef165a5 100644
--- a/.github/workflows/dist-tag.yml
+++ b/.github/workflows/dist-tag.yml
@@ -12,6 +12,9 @@ on:
         description: Dist tag to use.
         required: true
 
+permissions:
+  contents: read
+
 jobs:
   dist-tag:
     env:
diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml
index e2e3d22..eaaaffa 100644
--- a/.github/workflows/pr-title-lint.yml
+++ b/.github/workflows/pr-title-lint.yml
@@ -9,6 +9,9 @@ on:
       - synchronize
       - edited
 
+permissions:
+  contents: read
+
 jobs:
   pr-title-lint:
     runs-on: ubuntu-latest
diff --git a/src/push/monitor.ts b/src/push/monitor.ts
index f261b0a..f1683e8 100644
--- a/src/push/monitor.ts
+++ b/src/push/monitor.ts
@@ -25,7 +25,7 @@
 
 import { mkdir, rm, readFile } from 'fs/promises';
 import { extname, join } from 'path';
-import { LineCounter, parseDocument, YAMLSeq, YAMLMap } from 'yaml';
+import { LineCounter, parseDocument, Document, YAMLSeq, YAMLMap } from 'yaml';
 import { bold, red } from 'kleur/colors';
 import { Bundler } from './bundler';
 import { SYNTHETICS_PATH, totalist, indent, warn } from '../helpers';
@@ -188,8 +188,9 @@ export async function createLightweightMonitors(
     const lineCounter = new LineCounter();
     const parsedDoc = parseDocument(content, {
       lineCounter,
+      merge: true,
       keepSourceTokens: true,
-    });
+    }) as Document.Parsed;
     // Skip other yml files that are not relevant
     const monitorSeq = parsedDoc.get('heartbeat.monitors') as YAMLSeq<YAMLMap>;
     if (!monitorSeq) {
@@ -203,21 +204,29 @@ export async function createLightweightMonitors(
       warnOnce = true;
     }
 
+    // Store the offsets of each monitor in the sequence to construct the source
+    // location later for capturing the error
+    const offsets = [];
     for (const monNode of monitorSeq.items) {
+      offsets.push(monNode.srcToken.offset);
+    }
+
+    const mergedConfig = parsedDoc.toJS()['heartbeat.monitors'];
+    for (let i = 0; i < mergedConfig.length; i++) {
+      const monitor = mergedConfig[i];
       // Skip browser monitors from the YML files
-      if (monNode.get('type') === 'browser') {
+      if (monitor['type'] === 'browser') {
         continue;
       }
-      const config = monNode.toJSON();
-      const { line, col } = lineCounter.linePos(monNode.srcToken.offset);
+      const { line, col } = lineCounter.linePos(offsets[i]);
       try {
-        const mon = buildMonitorFromYaml(config, options);
+        const mon = buildMonitorFromYaml(monitor, options);
         mon.setSource({ file, line, column: col });
         monitors.push(mon);
       } catch (e) {
         let outer = bold(`Aborted: ${e}\n`);
         outer += indent(
-          `* ${config.id || config.name} - ${file}:${line}:${col}\n`
+          `* ${monitor.id || monitor.name} - ${file}:${line}:${col}\n`
         );
         throw red(outer);
       }


Test patch:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 5df65af..9c650f5 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -8,6 +8,9 @@ on:
     branches:
       - main
 
+permissions:
+  contents: read
+
 jobs:
   test:
     runs-on: ubuntu-latest
diff --git a/.github/workflows/test-reporter.yml b/.github/workflows/test-reporter.yml
index b470d9a..cbc9628 100644
--- a/.github/workflows/test-reporter.yml
+++ b/.github/workflows/test-reporter.yml
@@ -9,6 +9,11 @@ on:
     types:
       - completed
 
+permissions:
+  contents: read
+  actions: read
+  checks: write
+
 jobs:
   report:
     runs-on: ubuntu-latest
diff --git a/__tests__/push/monitor.test.ts b/__tests__/push/monitor.test.ts
index 55ba2fa..eabb026 100644
--- a/__tests__/push/monitor.test.ts
+++ b/__tests__/push/monitor.test.ts
@@ -347,6 +347,63 @@ heartbeat.monitors:
         },
       });
     });
+
+    it('support anchors', async () => {
+      await writeHBFile(`
+http-type: &http-type
+  type: http
+schedule5: &schedule-5
+  schedule: '@every 5m'
+team-tag: &team-tag
+  tags:
+    - team1
+    - team2
+
+heartbeat.monitors:
+- id: http1
+  name: "http1"
+  schedule: '@every 1m'
+  <<: *http-type
+  <<: *team-tag
+  urls: "https://blog.elastic.co"
+- id: http2
+  name: "http2"
+  <<: *http-type
+  <<: *schedule-5
+  urls: "https://elastic.co"
+- id: tcp1
+  name: "tcp1"
+  type: tcp
+  <<: *schedule-5
+  hosts: ["elastic.co:443"]
+      `);
+      const [mon1, mon2, mon3] = await createLightweightMonitors(
+        PROJECT_DIR,
+        opts
+      );
+      expect(mon1.config).toMatchObject({
+        id: 'http1',
+        name: 'http1',
+        schedule: 1,
+        type: 'http',
+        tags: ['team1', 'team2'],
+        urls: 'https://blog.elastic.co',
+      });
+      expect(mon2.config).toMatchObject({
+        id: 'http2',
+        name: 'http2',
+        schedule: 5,
+        type: 'http',
+        urls: 'https://elastic.co',
+      });
+      expect(mon3.config).toMatchObject({
+        id: 'tcp1',
+        name: 'tcp1',
+        schedule: 5,
+        type: 'tcp',
+        hosts: ['elastic.co:443'],
+      });
+    });
   });
 
   describe('parseAlertConfig', () => {