diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 23cbbeb558..35d01167bf 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -44,6 +44,7 @@ ENV AWS_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt
# Add VA Root CA to Docker Certificate Authority (CA) Store so that NODE can use it for requests.
ADD http://crl.pki.va.gov/PKI/AIA/VA/VA-Internal-S2-RCA1-v1.cer /usr/local/share/ca-certificates/
RUN mv /usr/local/share/ca-certificates/VA-Internal-S2-RCA1-v1.cer /usr/local/share/ca-certificates/VA-Internal-S2-RCA1-v1.cer.crt
+ADD http://crl.pki.va.gov/PKI/AIA/VA/VA-Internal-S2-RCA2.cer /usr/local/share/ca-certificates/VA-Internal-S2-RCA2.cer.crt
RUN update-ca-certificates
# Display VA Internal certificates that are now trusted
RUN awk -v cmd='openssl x509 -noout -subject' '/BEGIN/{close(cmd)};{print | cmd}' < /etc/ssl/certs/ca-certificates.crt | grep -i 'VA-Internal'
diff --git a/.github/actionlint.yml b/.github/actionlint.yml
new file mode 100644
index 0000000000..c0a36eae32
--- /dev/null
+++ b/.github/actionlint.yml
@@ -0,0 +1,3 @@
+self-hosted-runner:
+ labels:
+ - asg
diff --git a/.github/emass.json b/.github/emass.json
new file mode 100644
index 0000000000..a48b78793e
--- /dev/null
+++ b/.github/emass.json
@@ -0,0 +1,6 @@
+{
+ "systemID": 1027,
+ "systemName": "Veterans-Facing Services Platform-Va.gov",
+ "systemOwnerName": "VA.gov CMS Team",
+ "systemOwnerEmail": "cmsadmin@va.gov"
+}
diff --git a/.github/workflows/close-stale-pull-requests.yml b/.github/workflows/close-stale-pull-requests.yml
index 1dd8011678..0f72a7dba5 100644
--- a/.github/workflows/close-stale-pull-requests.yml
+++ b/.github/workflows/close-stale-pull-requests.yml
@@ -9,6 +9,6 @@ jobs:
steps:
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
with:
- stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. It will recieve a stale label every day for 14 days before being closed unless it recieves a comment or the stale label is removed.'
+ stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. It will receive a stale label every day for 14 days before being closed unless it receives a comment or the stale label is removed.'
days-before-pr-stale: 120
days-before-pr-close: 14
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000000..80e763dede
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,30 @@
+name: CodeQL
+'on':
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ schedule:
+ - cron: 19 1 * * 4
+ workflow_dispatch: null
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ concurrency: ${{ github.workflow }}-${{ matrix.language }}-${{ github.ref }}
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language:
+ - javascript
+ steps:
+ - name: Run Code Scanning
+ uses: department-of-veterans-affairs/codeql-tools/codeql-analysis@main
+ with:
+ language: ${{ matrix.language }}
diff --git a/.github/workflows/content-release.yml b/.github/workflows/content-release.yml
index d919f0b630..bb3885fc63 100644
--- a/.github/workflows/content-release.yml
+++ b/.github/workflows/content-release.yml
@@ -19,7 +19,7 @@ env:
DRUPAL_ADDRESS: https://prod.cms.va.gov
INSTANCE_TYPE: m6i.4xlarge
MAXIMUM_HEAP: 5000
- # secrets.ACTIONS_RUNNER_DEBUG is set to 'true' when re-running a workflow with debug.
+ # secrets.ACTIONS_RUNNER_DEBUG is set to 'true' when re-running a workflow with debug.
ACTIONS_RUNNER_DEBUG: ${{ secrets.ACTIONS_RUNNER_DEBUG }}
jobs:
@@ -66,8 +66,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- notify-start:
- name: Notify Start
+ notify-start-slack:
+ name: Notify Start (Slack)
runs-on: [self-hosted, asg]
needs: validate-build-status
steps:
@@ -430,7 +430,7 @@ jobs:
aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
url: ${{ env.DRUPAL_ADDRESS }}/api/govdelivery_bulletins/queue?EndTime=${{ needs.build.outputs.vagovprod_buildtime }}&src=gha&runId=${{ github.run_id }}&runNumber=${{ github.run_number }}
method: GET
- # This should not prevent the job from continuing.
+ # A failure here should not prevent the workflow from continuing.
continue-on-error: true
- name: Export deploy end time
@@ -448,14 +448,8 @@ jobs:
- name: Checkout
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- - name: Notify Slack
- uses: department-of-veterans-affairs/platform-release-tools-actions/slack-notify@8c496a4b0c9158d18edcd9be8722ed0f79e8c5b4 #
- continue-on-error: true
- with:
- payload: '{"attachments": [{"color": "#2EB67D","blocks": [{"type": "section","text": {"type": "mrkdwn","text": "content release using ${{ needs.validate-build-status.outputs.TAG }} is complete."}}]}]}'
- channel_id: ${{ env.CONTENT_BUILD_CHANNEL_ID }}
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ - name: Wait for the CMS to be ready
+ uses: ./.github/workflows/wait-for-cms-ready
- name: Notify CMS - Ready
uses: ./.github/workflows/authenticated-cms-request
@@ -465,14 +459,33 @@ jobs:
url: ${{ env.DRUPAL_ADDRESS }}/api/content_release/ready
method: GET
+ notify-success-slack:
+ name: Notify Success (Slack)
+ runs-on: [self-hosted, asg]
+ needs:
+ - validate-build-status
+ - deploy
+
+ steps:
+ - name: Notify Slack
+ uses: department-of-veterans-affairs/platform-release-tools-actions/slack-notify@8c496a4b0c9158d18edcd9be8722ed0f79e8c5b4 #
+ continue-on-error: true
+ with:
+ payload: '{"attachments": [{"color": "#2EB67D","blocks": [{"type": "section","text": {"type": "mrkdwn","text": "content release using ${{ needs.validate-build-status.outputs.TAG }} is complete."}}]}]}'
+ channel_id: ${{ env.CONTENT_BUILD_CHANNEL_ID }}
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+
notify-failure:
name: Notify Failure
runs-on: [self-hosted, asg]
if: |
(failure() && needs.deploy.result != 'success')
needs: deploy
-
steps:
+ - name: Checkout
+ uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e1e17a757e536f70e52b5a12b2e8d1d1c60e04ef # v2.0.0
with:
@@ -480,6 +493,9 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-gov-west-1
+ - name: Wait for the CMS to be ready
+ uses: ./.github/workflows/wait-for-cms-ready
+
- name: Notify CMS - Error
uses: ./.github/workflows/authenticated-cms-request
with:
@@ -488,15 +504,6 @@ jobs:
url: ${{ env.DRUPAL_ADDRESS }}/api/content_release/error
method: GET
- - name: Notify Slack
- uses: department-of-veterans-affairs/platform-release-tools-actions/slack-notify@8c496a4b0c9158d18edcd9be8722ed0f79e8c5b4 #
- continue-on-error: true
- with:
- payload: '{"attachments": [{"color": "#2EB67D","blocks": [{"type": "section","text": {"type": "mrkdwn","text": ":bangbang: Content release using ${{ needs.validate-build-status.outputs.TAG }} has failed."}}]}]}'
- channel_id: ${{ env.CONTENT_BUILD_CHANNEL_ID }}
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-
- name: Get Datadog token from Parameter Store
uses: department-of-veterans-affairs/action-inject-ssm-secrets@d8e6de3bde4dd728c9d732baef58b3c854b8c4bb # latest
with:
@@ -525,6 +532,38 @@ jobs:
-H "DD-API-KEY: ${{ env.GHA_CONTENT_BUILD_DATADOG_API_KEY }}" \
-d @- < event.json
+ notify-failure-slack:
+ name: Notify Failure (Slack)
+ runs-on: [self-hosted, asg]
+ if: |
+ (failure() && needs.deploy.result != 'success')
+ needs: deploy
+ steps:
+ - name: Notify Slack
+ uses: department-of-veterans-affairs/platform-release-tools-actions/slack-notify@8c496a4b0c9158d18edcd9be8722ed0f79e8c5b4
+ continue-on-error: true
+ with:
+ payload: >
+ {
+ "attachments": [
+ {
+ "color": "#2EB67D",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": ":bangbang: Content release using ${{ needs.validate-build-status.outputs.TAG || 'an unknown version' }} has failed."
+ }
+ }
+ ]
+ }
+ ]
+ }
+ channel_id: ${{ env.CONTENT_BUILD_CHANNEL_ID }}
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+
record-metrics:
name: Record metrics in Datadog
runs-on: [self-hosted, asg]
@@ -605,4 +644,3 @@ jobs:
-H "Content-Type: text/json" \
-H "DD-API-KEY: ${{ env.GHA_CONTENT_BUILD_DATADOG_API_KEY }}" \
-d @- < metrics.json
-
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 1ece444c3e..6f382bde1b 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -670,6 +670,8 @@ jobs:
username: api
password: ${{ env.CALLBACK_TOKEN }}
timeout: 10000
+ # A failure here should not prevent the workflow from continuing.
+ continue-on-error: true
jenkins:
name: Run Jenkins CI
diff --git a/.github/workflows/prune-self-hosted-runners.yml b/.github/workflows/prune-self-hosted-runners.yml
new file mode 100644
index 0000000000..d24d76b50e
--- /dev/null
+++ b/.github/workflows/prune-self-hosted-runners.yml
@@ -0,0 +1,48 @@
+name: Prune Self-Hosted Runners
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '27 * * * *' # Hourly at 27 minutes past the hour
+
+jobs:
+
+ prune-self-hosted-runners:
+ name: Prune Old and Idle Self-Hosted Runners
+ runs-on: ubuntu-latest
+
+ steps:
+
+ - name: Checkout
+ uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@e1e17a757e536f70e52b5a12b2e8d1d1c60e04ef # v2.0.0
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: us-gov-west-1
+
+ - name: Get va-vsp-bot token
+ uses: department-of-veterans-affairs/action-inject-ssm-secrets@d8e6de3bde4dd728c9d732baef58b3c854b8c4bb # latest
+ with:
+ ssm_parameter: /devops/VA_VSP_BOT_GITHUB_TOKEN
+ env_variable_name: VA_VSP_BOT_GITHUB_TOKEN
+
+ - name: Install dependencies
+ uses: ./.github/workflows/install
+ with:
+ key: ${{ hashFiles('yarn.lock') }}
+ yarn_cache_folder: ~/.cache/yarn
+ path: |
+ ~/.cache/yarn
+ node_modules
+
+ - name: Run the prune script
+ run: yarn prune-self-hosted-runners
+ env:
+ GITHUB_TOKEN: ${{ env.VA_VSP_BOT_GITHUB_TOKEN }}
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ AWS_DEFAULT_REGION: 'us-gov-west-1'
+ DEBUG: ${{ secrets.ACTIONS_RUNNER_DEBUG }}
diff --git a/Dockerfile b/Dockerfile
index 072fd62061..f67037e1b9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,7 @@ ENV AWS_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt
# Add VA Root CA to Docker Certificate Authority (CA) Store so that NODE can use it for requests.
ADD https://raw.githubusercontent.com/department-of-veterans-affairs/platform-va-ca-certificate/main/VA-Internal-S2-RCA1-v1.cer /usr/local/share/ca-certificates/
RUN openssl x509 -inform DER -in /usr/local/share/ca-certificates/VA-Internal-S2-RCA1-v1.cer -out /usr/local/share/ca-certificates/VA-Internal-S2-RCA1-v1.crt
+ADD https://raw.githubusercontent.com/department-of-veterans-affairs/platform-va-ca-certificate/main/VA-Internal-S2-RCA2.cer /usr/local/share/ca-certificates/VA-Internal-S2-RCA2.cer.crt
RUN update-ca-certificates
RUN mkdir -p /application/content-build
diff --git a/package.json b/package.json
index 0423c4a3d2..58edd2b5be 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,9 @@
"watch:css-sourcemaps": "node --max-old-space-size=5000 --expose-gc script/build-content.js --watch --local-css-sourcemaps",
"watch:review": "node ./script/run-review-instance-api.js",
"list-heading-order-violations": "node script/heading-order-violations.js --dir ./build/vagovdev",
- "prepare": "husky install"
+ "prepare": "husky install",
+ "prune-self-hosted-runners": "node script/prune-self-hosted-runners.js",
+ "prune-self-hosted-runners:dry-run": "export DRY_RUN=true; node script/prune-self-hosted-runners.js"
},
"repository": {
"type": "git",
@@ -205,6 +207,7 @@
"private": true,
"dependencies": {
"@department-of-veterans-affairs/vagov-platform": "^0.0.1",
+ "aws-sdk": "^2.1441.0",
"blob-polyfill": "^4.0.20200601",
"core-js": "^3.17.3",
"diff2html": "^3.4.11",
diff --git a/script/prune-self-hosted-runners.js b/script/prune-self-hosted-runners.js
new file mode 100644
index 0000000000..1943811e6f
--- /dev/null
+++ b/script/prune-self-hosted-runners.js
@@ -0,0 +1,346 @@
+/* eslint-disable prefer-destructuring */
+/* eslint-disable camelcase */
+/* eslint-disable no-await-in-loop */
+/* eslint-disable no-console */
+
+/**
+ * This script performs some lifecycle management tasks on self-hosted runners.
+ *
+ * It is intended to be run as a scheduled cron job in a GHA workflow, though
+ * it can also be run locally.
+ *
+ * Briefly, the problem is this: We have an issue with our self-hosted runners,
+ * which are deployed to EC2 instances controlled by an ASG. If left alone,
+ * they sometimes run out of disk space, or lose connection, or in some other
+ * way lose their usefulness. If we have the max-lifetime of the ASG set, then
+ * they will be killed unceremoniously as soon as they reach that age, without
+ * any regard for jobs that may be running on them at the time.
+ *
+ * It's easier to determine a runner's state, connectivity, idleness, health,
+ * etc from GitHub than it is from within the runner itself, so my solution is
+ * to use that information to prune aging, ailing runners in a safe way, and
+ * rely upon the ASG's lifecycle management only as a last resort.
+ */
+
+const { Octokit } = require('@octokit/rest');
+const AWS = require('aws-sdk');
+
+// Prevent any changes to the infrastructure.
+const DRY_RUN = process.env.DRY_RUN === 'true';
+
+// Increased debugging information.
+const DEBUG = process.env.DEBUG === 'true';
+
+// Number of days an instance is allowed to run before termination.
+//
+// The ASG's max-lifetime is set (as of August 2023) to a value comfortably in
+// excess of this, and it should always at least, say, 5-6 hours more than this
+// value to allow for scheduling vagaries, long-running jobs, etc.
+const THRESHOLD_DAYS = process.env.THRESHOLD_DAYS || 5;
+
+// The maximum number of runners to terminate at a time.
+const TERMINATE_LIMIT = process.env.TERMINATE_LIMIT || 3;
+
+// The owner of the repository.
+const GITHUB_OWNER =
+ process.env.GITHUB_OWNER || 'department-of-veterans-affairs';
+const GITHUB_REPO = process.env.GITHUB_REPO || 'content-build';
+
+// The GitHub token.
+const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
+
+// The name of the ASG.
+const ASG_NAME = 'dsva-vagov-content-build-gha-runners-asg';
+
+// Milliseconds-to-days conversion factor.
+const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
+
+// Set the default AWS region.
+const AWS_DEFAULT_REGION = 'us-gov-west-1';
+AWS.config.update({ region: AWS_DEFAULT_REGION });
+
+// The current time.
+const NOW = new Date();
+
+/**
+ * Log some debugging information.
+ */
+function debug(...args) {
+ if (DEBUG) {
+ console.log(...args);
+ }
+}
+
+/**
+ * Get the current instances in the given ASG.
+ *
+ * A given instance data structure looks like this:
+ *
+ * Instance: {
+ * InstanceId: 'i-0fe66f30785a4190e',
+ * InstanceType: 'm6i.4xlarge',
+ * AvailabilityZone: 'us-gov-west-1c',
+ * LifecycleState: 'InService',
+ * HealthStatus: 'Healthy',
+ * LaunchTemplate: {
+ * LaunchTemplateId: 'lt-001a10f785f2aeafd',
+ * LaunchTemplateName: 'dsva-vagov-content-build-gha-runner-lt',
+ * Version: '81'
+ * },
+ * ProtectedFromScaleIn: true
+ * }
+ */
+async function getASGInstances(asgName) {
+ const asg = new AWS.AutoScaling();
+ const response = await asg
+ .describeAutoScalingGroups({
+ AutoScalingGroupNames: [asgName],
+ })
+ .promise();
+ const result = response.AutoScalingGroups[0].Instances;
+ debug('ASG instances:', result);
+ return result;
+}
+
+/**
+ * Get the current self-hosted runners.
+ *
+ * A given runner data structure looks like this:
+ *
+ * Runner: {
+ * id: 37902,
+ * name: '0WUqD2i-01cfc6179d3ca8ca3',
+ * os: 'Linux',
+ * status: 'online',
+ * busy: true,
+ * labels: [
+ * { id: 1, name: 'self-hosted', type: 'read-only' },
+ * { id: 2, name: 'Linux', type: 'read-only' },
+ * { id: 3, name: 'X64', type: 'read-only' },
+ * { id: 16273, name: 'Ubuntu20', type: 'custom' },
+ * { id: 16274, name: 'asg', type: 'custom' }
+ * ]
+ * }
+ */
+async function getRunners(token, owner, repo) {
+ const octokit = new Octokit({
+ auth: token,
+ });
+ const response = await octokit.request(
+ 'GET /repos/{owner}/{repo}/actions/runners?per_page=100',
+ {
+ owner,
+ repo,
+ },
+ );
+ const result = response.data.runners;
+ debug('Runners:', result);
+ return result;
+}
+
+/**
+ * Get the details of the EC2 instances.
+ */
+async function getInstancesDetails(instances) {
+ const ec2 = new AWS.EC2();
+ const response = await ec2
+ .describeInstances({
+ InstanceIds: instances.map(instance => instance.InstanceId),
+ })
+ .promise();
+ // Merge the instances of all of the reservations into a single array.
+ const instanceDetails = response.Reservations.reduce(
+ (accumulator, reservation) => {
+ return accumulator.concat(reservation.Instances);
+ },
+ [],
+ );
+ // Merge the instance details into the instances.
+ const result = instances.map(instance => {
+ const instanceDetail = instanceDetails.find(
+ details => instance.InstanceId === details.InstanceId,
+ );
+ return {
+ ...instance,
+ ...instanceDetail,
+ };
+ });
+ debug('Instances details:', result);
+ return result;
+}
+
+/**
+ * Determine the list of runners on a given instance.
+ *
+ * Each runner has a name that is composed of (as of 08/2023):
+ * - a random string
+ * - a hyphen
+ * - the instance ID
+ *
+ * Thus any runner containing the instance ID is running on that instance.
+ */
+function getRunnersOnInstance(runners, instanceId) {
+ const result = runners.filter(runner => runner.name.includes(instanceId));
+ debug('Runners on instance:', result);
+ return result;
+}
+
+/**
+ * Determine whether a given instance is old.
+ */
+function isOldInstance(instance, thresholdDays) {
+ const launchTime = instance.LaunchTime;
+ const age = NOW - new Date(launchTime);
+ const result = age > thresholdDays * MILLISECONDS_PER_DAY;
+ debug('Instance age:', age, 'Old?', result);
+ return result;
+}
+
+/**
+ * Determine whether a given instance is idle.
+ */
+function isIdleInstance(instance, runners) {
+ const { InstanceId: instanceId } = instance;
+ const myRunners = getRunnersOnInstance(runners, instanceId);
+ const isBusy = myRunners.some(runner => runner.busy);
+ return !isBusy;
+}
+
+/**
+ * Determine the list of doomed runners.
+ */
+function getDoomedRunners(instances, runners) {
+ const result = [];
+ for (const instance of instances) {
+ const { InstanceId: instanceId } = instance;
+ const myRunners = getRunnersOnInstance(runners, instanceId);
+ result.push(...myRunners);
+ }
+ debug('Doomed runners:', result);
+ return result;
+}
+
+/**
+ * Delete the given runners.
+ */
+async function deleteRunners(runners, token, owner, repo, dryRun) {
+ if (dryRun) {
+ debug('Dry run: Not deleting runners.');
+ return;
+ }
+ const octokit = new Octokit({
+ auth: GITHUB_TOKEN,
+ });
+ for (const runner of runners) {
+ const { id: runnerId } = runner;
+ const response = await octokit
+ .request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', {
+ owner,
+ repo,
+ runner_id: runnerId,
+ })
+ .catch(err => {
+ console.error(err);
+ });
+ debug('Delete runner response:', response);
+ }
+}
+
+/**
+ * Remove the given instances.
+ *
+ * We do this by setting their health status to Unhealthy, which will cause the
+ * ASG to terminate them.
+ */
+async function removeInstances(instances, dryRun) {
+ if (dryRun) {
+ debug('Dry run: Not marking instances as unhealthy.');
+ return;
+ }
+ const asg = new AWS.AutoScaling();
+ for (const instance of instances) {
+ await asg
+ .setInstanceHealth({
+ InstanceId: instance.InstanceId,
+ HealthStatus: 'Unhealthy',
+ ShouldRespectGracePeriod: true,
+ })
+ .promise();
+ }
+}
+
+/**
+ * Determine the list of old, idle instances.
+ *
+ * An instance is considered:
+ * - old if it is older than the threshold age
+ * - busy if it has any busy runners
+ * - idle if it has no busy runners
+ *
+ * Given the instances and runners, we can determine the list of old, idle
+ * instances with the following algorithm:
+ *
+ * 1. For each instance:
+ * a. If the instance is older than the threshold age:
+ * i. If the instance has any busy runners:
+ * - Do nothing.
+ * ii. If the instance has no busy runners:
+ * - Add the instance to the list of old, idle instances.
+ * 2. Return the list of old, idle instances.
+ */
+async function getOldIdleInstances(instances, runners, thresholdDays) {
+ const result = [];
+ for (const instance of instances) {
+ const isOld = isOldInstance(instance, thresholdDays);
+ if (isOld) {
+ const isIdle = isIdleInstance(instance, runners);
+ if (isIdle) {
+ result.push(instance);
+ }
+ }
+ }
+ debug('Old idle instances:', result);
+ return result;
+}
+
+(async () => {
+ // Get all of the instances on the ASG...
+ let instances = await getASGInstances(ASG_NAME);
+
+ // ... and add their details.
+ instances = await getInstancesDetails(instances);
+
+ // Get all of the runners from GitHub.
+ const runners = await getRunners(GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO);
+
+ // Determine the old, idle instances.
+ const oldIdleInstances = await getOldIdleInstances(
+ instances,
+ runners,
+ THRESHOLD_DAYS,
+ );
+
+ if (oldIdleInstances.length === 0) {
+ debug('No old, idle instances to delete!');
+ } else {
+ // Don't kill more than _n_ runners at a time.
+ if (oldIdleInstances.length > TERMINATE_LIMIT) {
+ oldIdleInstances.length = TERMINATE_LIMIT;
+ }
+
+ // Determine the runners we should delete, and delete them.
+ const doomedRunners = getDoomedRunners(oldIdleInstances, runners);
+ debug('Deleting doomed runners:', doomedRunners);
+ deleteRunners(
+ doomedRunners,
+ GITHUB_TOKEN,
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ DRY_RUN,
+ );
+
+ // Mark the instances as unhealthy.
+ debug('Setting old idle instances unhealthy:', oldIdleInstances);
+ removeInstances(oldIdleInstances, DRY_RUN);
+ }
+})();
diff --git a/src/applications/registry.json b/src/applications/registry.json
index 7c2e9271f0..7ab288dbfc 100644
--- a/src/applications/registry.json
+++ b/src/applications/registry.json
@@ -40,7 +40,7 @@
},
{
"path": "pension/application/527EZ",
- "name": "Apply for Veterans Pension"
+ "name": "Review pension benefits application"
}
]
}
@@ -54,6 +54,15 @@
"layout": "page-react.html"
}
},
+ {
+ "appName": "VA Online Scheduling NEW",
+ "entryName": "vaos",
+ "rootUrl": "/my-health/appointments",
+ "template": {
+ "vagovprod": false,
+ "layout": "page-react.html"
+ }
+ },
{
"appName": "Rated Disabilities",
"entryName": "disability-my-rated-disabilities",
@@ -137,13 +146,14 @@
}
},
{
- "appName": "Terms and Conditions",
- "entryName": "terms-and-conditions",
- "rootUrl": "/health-care/medical-information-terms-conditions",
+ "appName": "Terms of use",
+ "entryName": "terms-of-use",
+ "rootUrl": "/terms-of-use",
"template": {
- "title": "Terms and Conditions for Medical Information",
+ "title": "VA online services terms of use",
"layout": "page-react.html",
- "includeBreadcrumbs": true
+ "includeBreadcrumbs": true,
+ "vagovprod": false
}
},
{
@@ -168,7 +178,7 @@
},
{
"path": "burials-and-memorials/application/530",
- "name": "Apply for burial benefits"
+ "name": "Review burial benefits application"
}
]
}
@@ -788,6 +798,31 @@
]
}
},
+ {
+ "appName": "Apply for Personalized Career Planning and Guidance with VA Form 25-8832",
+ "entryName": "25-8832-planning-and-career-guidance",
+ "rootUrl": "/careers-employment/education-and-career-counseling/apply-career-guidance-form-25-8832",
+ "template": {
+ "title": "Personalized Career Planning and Guidance Chapter 36 Form 25-8832",
+ "vagovprod": true,
+ "layout": "page-react.html",
+ "includeBreadcrumbs": true,
+ "breadcrumbs_override": [
+ {
+ "path": "careers-employment",
+ "name": "Careers and employment"
+ },
+ {
+ "path": "careers-employment/education-and-career-counseling/",
+ "name": "Career planning and guidance"
+ },
+ {
+ "path": "careers-employment/education-and-career-counseling/apply-career-guidance-form-25-8832/introduction",
+ "name": "Apply for Personalized Career Planning and Guidance Form 25‑8832"
+ }
+ ]
+ }
+ },
{
"appName": "View Payments",
"entryName": "view-payments",
@@ -912,7 +947,7 @@
},
{
"path": "resources/search",
- "name": "Search results"
+ "name": "Resources and Support Search Results"
}
]
}
@@ -1378,13 +1413,23 @@
"layout": "page-react.html"
}
},
+ {
+ "appName": "Mock Form Patterns",
+ "entryName": "mock-form-patterns-v3",
+ "rootUrl": "/mock-form-patterns",
+ "productId": "46a3411a-7a98-4527-bcd8-b0cd71226bff",
+ "template": {
+ "vagovprod": false,
+ "layout": "page-react.html"
+ }
+ },
{
"appName": "21-10210 Lay/Witness Statement",
"entryName": "10210-lay-witness-statement",
"rootUrl": "/supporting-forms-for-claims/lay-witness-statement-form-21-10210",
"productId": "f8117c9f-0d57-486f-91ee-89c806b84d65",
"template": {
- "vagovprod": false,
+ "vagovprod": true,
"layout": "page-react.html",
"includeBreadcrumbs": true,
"breadcrumbs_override": [
@@ -1417,27 +1462,62 @@
"title": "Income Limits",
"layout": "page-react.html",
"description": "Income Limits application",
- "vagovprod": false
+ "vagovprod": true
}
},
{
- "appName": "21-0845 Authorization to Disclose Personal Information to a Third Party",
+ "appName": "Authorize VA to release your information to a third-party source",
"entryName": "0845-auth-disclose",
- "rootUrl": "/authorization-to-disclose",
+ "rootUrl": "/supporting-forms-for-claims/third-party-authorization-form-21-0845",
"productId": "08a7da80-f48a-4677-9b74-0dcb642b97a1",
+ "template": {
+ "vagovprod": true,
+ "layout": "page-react.html",
+ "includeBreadcrumbs": true,
+ "breadcrumbs_override": [
+ {
+ "name": "Supporting forms for VA claims",
+ "path": "supporting-forms-for-claims"
+ },
+ {
+ "name": "Authorize VA to release your information to a third-party source",
+ "path": "supporting-forms-for-claims/third-party-authorization-form-21-0845"
+ }
+ ]
+ }
+ },
+ {
+ "appName": "Mock Form - Alternate header - 21-0845 Authorization to Disclose Personal Information to a Third Party",
+ "entryName": "mock-alternate-header-0845",
+ "rootUrl": "/authorization-to-disclose-alternate",
+ "productId": "f1075156-0d85-4a0e-bce6-3f053b6243b5",
"template": {
"vagovprod": false,
- "layout": "page-react.html"
+ "layout": "page-react.html",
+ "noNavOrLogin": true,
+ "noMegamenu": true,
+ "minimalFooter": true
}
},
{
- "appName": "Sign for benefits on behalf of another person",
+ "appName": "Sign VA claim forms as an alternate signer",
"entryName": "21-0972-alternate-signer",
- "rootUrl": "/alternate-signer",
+ "rootUrl": "/supporting-forms-for-claims/alternate-signer-form-21-0972",
"productId": "38f07696-546f-4c9a-a6f0-16348bde4d33",
"template": {
"vagovprod": false,
- "layout": "page-react.html"
+ "layout": "page-react.html",
+ "includeBreadcrumbs": true,
+ "breadcrumbs_override": [
+ {
+ "name": "Supporting forms for VA claims",
+ "path": "supporting-forms-for-claims"
+ },
+ {
+ "name": "Sign VA claim forms as an alternate signer",
+ "path": "supporting-forms-for-claims/alternate-signer-form-21-0972"
+ }
+ ]
}
},
{
@@ -1451,24 +1531,106 @@
}
},
{
- "appName": "Request to be a substitute claimant",
+ "appName": "Request to be a substitute claimant for a deceased claimant",
"entryName": "21P-0847-substitute-claimant",
- "rootUrl": "/substitute-claimant",
+ "rootUrl": "/supporting-forms-for-claims/substitute-claimant-form-21P-0847",
"productId": "8b51d056-9bea-493f-a815-65555ba66188",
+ "template": {
+ "vagovprod": true,
+ "layout": "page-react.html",
+ "includeBreadcrumbs": true,
+ "breadcrumbs_override": [
+ {
+ "name": "Supporting forms for VA claims",
+ "path": "supporting-forms-for-claims"
+ },
+ {
+ "name": "Request to be a substitute claimant for a deceased claimant",
+ "path": "supporting-forms-for-claims/substitute-claimant-form-21P-0847"
+ }
+ ]
+ }
+ },
+ {
+ "appName": "After Visit Summary",
+ "entryName": "avs",
+ "rootUrl": "/my-health/medical-records/care-notes/avs",
+ "productId": "e5bd4cff-77b1-4d56-ab8e-a66c9c2667ab",
"template": {
"vagovprod": false,
"layout": "page-react.html"
}
},
{
- "appName": "Order hearing aid or CPAP supplies",
- "entryName": "health-care-supply-reordering",
- "rootUrl": "/health-care/order-hearing-aid-or-CPAP-supplies-form",
+ "appName": "Notification Center",
+ "entryName": "notification-center",
+ "rootUrl": "/notification-center",
+ "productId": "",
"template": {
"vagovprod": false,
"layout": "page-react.html"
}
- }
-
-
+ },
+ {
+ "appName": "DS Playground",
+ "entryName": "ds-playground",
+ "rootUrl": "/ds-playground",
+ "productId": "7cb70bc6-9331-416f-aa20-20e6a595cfb1",
+ "template": {
+ "vagovprod": false,
+ "layout": "page-react.html"
+ }
+ },
+ {
+ "appName": "DS V3 Playground",
+ "entryName": "ds-v3-playground",
+ "rootUrl": "/ds-v3-playground",
+ "productId": "63fa0a80-e79d-47e7-9cce-e03a8edb2535",
+ "template": {
+ "vagovprod": false,
+ "layout": "page-react.html"
+ }
+ },
+ {
+ "appName": "Request a Presidential Memorial Certificate",
+ "entryName": "0247-pmc",
+ "rootUrl": "/request-presidential-memorial-certificate",
+ "productId": "5af7a83b-c1a0-4a0a-b0e9-88baf150e6a9",
+ "template": {
+ "vagovprod": false,
+ "layout": "page-react.html"
+ }
+ },
+ {
+ "appName": "PACT Act",
+ "entryName": "pact-act",
+ "rootUrl": "pact-act-wizard-test",
+ "template": {
+ "title": "PACT Act",
+ "layout": "page-react.html",
+ "description": "PACT Act application",
+ "vagovprod": false
+ }
+ },
+ {
+ "appName": "Appeals Testing",
+ "entryName": "appeals-testing",
+ "rootUrl": "/decision-reviews/appeals-testing",
+ "productId": "5dd32517-00fd-4e97-afed-e6db1f1f54f9",
+ "template": {
+ "layout": "page-react.html",
+ "description": "Testing new layout for Appeals forms",
+ "vagovprod": false,
+ "includeBreadcrumbs": false,
+ "noNavOrLogin": true,
+ "noMegamenu": true,
+ "includeFeedbackButton": false,
+ "minimalFooter": true
+ }
+ },
+ {
+ "appName": "Order hearing aid or CPAP supplies",
+ "entryName": "health-care-supply-reordering",
+ "rootUrl": "/health-care/order-hearing-aid-or-CPAP-supplies-form"
+ }
]
diff --git a/src/site/components/phone-number.drupal.liquid b/src/site/components/phone-number.drupal.liquid
index 3ffcb9c30b..63a294f045 100644
--- a/src/site/components/phone-number.drupal.liquid
+++ b/src/site/components/phone-number.drupal.liquid
@@ -1,11 +1,7 @@
-{% if number.fieldPhoneLabel != empty %}
- {% assign phoneLabel = number.fieldPhoneLabel %}
-{% endif %}
-{% if phoneLabel == empty %}
- {% assign phoneLabel = 'Phone' %}
-{% endif %}
-