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 %} -{{ phoneLabel }} - - {{ number.fieldPhoneNumber }}{% if number.fieldPhoneExtension %}x {{ number.fieldPhoneExtension }}{% endif %} - +{{ phoneLabel | default: 'Phone' }} +
+ +
diff --git a/src/site/components/phone.drupal.liquid b/src/site/components/phone.drupal.liquid index 61bfb2bee4..4f2466632b 100644 --- a/src/site/components/phone.drupal.liquid +++ b/src/site/components/phone.drupal.liquid @@ -4,8 +4,9 @@ {% if phoneNumberObj.tel %} {% for number in phoneNumberObj.tel %} {% include "src/site/components/phone-number.drupal.liquid" with - number = number - phoneLabel = 'Phone' + phoneNumber = number.fieldPhoneNumber + phoneExtension = number.fieldPhoneExtension + phoneLabel = number.fieldPhoneLabel | default: 'Phone' phoneHeaderLevel = phoneHeaderLevel %} {% endfor %} @@ -14,8 +15,9 @@ {% if phoneNumberObj.fax %} {% for number in phoneNumberObj.fax %} {% include "src/site/components/phone-number.drupal.liquid" with - number = number - phoneLabel = 'Fax' + phoneNumber = number.fieldPhoneNumber + phoneExtension = number.fieldPhoneExtension + phoneLabel = number.fieldPhoneLabel | default: 'Fax' phoneHeaderLevel = phoneHeaderLevel %} {% endfor %} @@ -24,8 +26,9 @@ {% if phoneNumberObj.sms %} {% for number in phoneNumberObj.sms %} {% include "src/site/components/phone-number.drupal.liquid" with - number = number - phoneLabel = 'SMS' + phoneNumber = number.fieldPhoneNumber + phoneExtension = number.fieldPhoneExtension + phoneLabel = number.fieldPhoneLabel | default: 'SMS' phoneHeaderLevel = phoneHeaderLevel %} {% endfor %} @@ -34,8 +37,9 @@ {% if phoneNumberObj.tty %} {% for number in phoneNumberObj.tty %} {% include "src/site/components/phone-number.drupal.liquid" with - number = number - phoneLabel = 'TTY' + phoneNumber = number.fieldPhoneNumber + phoneExtension = number.fieldPhoneExtension + phoneLabel = number.fieldPhoneLabel | default: 'TTY' phoneHeaderLevel = phoneHeaderLevel %} {% endfor %} diff --git a/src/site/constants/content-modeling.js b/src/site/constants/content-modeling.js index 2232b6377c..87f5157eb9 100644 --- a/src/site/constants/content-modeling.js +++ b/src/site/constants/content-modeling.js @@ -5,11 +5,9 @@ */ const ENTITY_BUNDLES = { BASIC_LANDING_PAGE: 'basic_landing_page', - BIOS_PAGE: 'bios_page', CHECKLIST: 'checklist', EVENT_LISTING: 'event_listing', EVENT: 'event', - EVENTS_PAGE: 'events_page', FAQ_MULTIPLE_Q_A: 'faq_multiple_q_a', FULL_WIDTH_BANNER_ALERT: 'full_width_banner_alert', HEALTH_CARE_FACILITY_STATUS: 'health_care_facility_status', @@ -25,7 +23,6 @@ const ENTITY_BUNDLES = { LOCATIONS_LISTING: 'locations_listing', MEDIA_LIST_IMAGES: 'media_list_images', MEDIA_LIST_VIDEOS: 'media_list_videos', - NEWS_STORIES_PAGE: 'news_stories_page', NEWS_STORY: 'news_story', OFFICE: 'office', OUTREACH_ASSET: 'outreach_asset', @@ -33,7 +30,6 @@ const ENTITY_BUNDLES = { PERSON_PROFILE: 'person_profile', PRESS_RELEASE: 'press_release', PRESS_RELEASES_LISTING: 'press_releases_listing', - PRESS_RELEASES_PAGE: 'press_releases_page', PUBLICATION_LISTING: 'publication_listing', Q_A: 'q_a', REGIONAL_HEALTH_CARE_SERVICE_DES: 'regional_health_care_service_des', diff --git a/src/site/facilities/facilities_health_services_buttons.drupal.liquid b/src/site/facilities/facilities_health_services_buttons.drupal.liquid index 2b8f24fe29..a2b45ce8bb 100644 --- a/src/site/facilities/facilities_health_services_buttons.drupal.liquid +++ b/src/site/facilities/facilities_health_services_buttons.drupal.liquid @@ -1,14 +1,14 @@ {% comment %} This is used for Facility details pages and (A-Z) Health Services page {% endcomment %} -
-
- Make an appointment +
+
+ Make an appointment
-
- Register for care + - diff --git a/src/site/facilities/facility_health_service.drupal.liquid b/src/site/facilities/facility_health_service.drupal.liquid index 768818a652..d2a256cbcd 100644 --- a/src/site/facilities/facility_health_service.drupal.liquid +++ b/src/site/facilities/facility_health_service.drupal.liquid @@ -3,10 +3,11 @@ {% if serviceTaxonomy.fieldAlsoKnownAs %} subheader="{{ serviceTaxonomy.fieldAlsoKnownAs }}" {% endif %} - class="facilities_health_service" + class="facilities_health_service va-accordion-item" data-label="{{ serviceTaxonomy.name }}" data-childlabel="{{ serviceTaxonomy.fieldAlsoKnownAs }}" data-template="facilities/facilities_health_service" + id="{{serviceTaxonomy.name | hashReference: 60 }}" >

{{ serviceTaxonomy.name }} diff --git a/src/site/facilities/facility_social_links.drupal.liquid b/src/site/facilities/facility_social_links.drupal.liquid index fb51205f3c..ca4f1d2b96 100644 --- a/src/site/facilities/facility_social_links.drupal.liquid +++ b/src/site/facilities/facility_social_links.drupal.liquid @@ -6,8 +6,8 @@