diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 676a95bce..651dcbb7d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,5 +81,11 @@ jobs: docker build \ -t "${ECR_REPO_URI}:ab2d-${DEPLOYMENT_ENV}-$SHA_SHORT" \ -t "${ECR_REPO_URI}:ab2d-${DEPLOYMENT_ENV}-latest" . + + # Push to special tag for promotion if this is run on a push to main + if [ "$GITHUB_REF" == "refs/heads/main" ]; then + docker tag $ECR_REPO_URI:ab2d-$DEPLOYMENT_ENV-$SHA_SHORT $ECR_REPO_URI:main-$SHA_SHORT + fi + echo "Pushing image" docker push "${ECR_REPO_URI}" --all-tags diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 43b30fe72..22df77018 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,7 +19,7 @@ on: - test - sbx - prod - - prod-test + - prod_test module: required: true type: choice @@ -33,16 +33,17 @@ jobs: permissions: contents: read id-token: write - env: - DEPLOYMENT_ENV: ${{ vars[format('{0}_DEPLOYMENT_ENV', inputs.environment)] }} - ACCOUNT: ${{ inputs.environment == 'prod-test' && 'prod' || inputs.environment }} - steps: - - name: Assume role in AB2D ${{ env.ACCOUNT }} account - uses: aws-actions/configure-aws-credentials@v3 + - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + env: + ACCOUNT: ${{ inputs.environment == 'prod_test' && 'prod' || inputs.environment }} with: aws-region: ${{ vars.AWS_REGION }} role-to-assume: arn:aws:iam::${{ secrets[format('{0}_ACCOUNT_ID', env.ACCOUNT)] }}:role/delegatedadmin/developer/ab2d-${{ env.ACCOUNT }}-github-actions - - - name: Deploy latest image in ECR to ECS - run: aws ecs update-service --cluster ab2d-${DEPLOYMENT_ENV}-${{ inputs.module }} --service ab2d-${DEPLOYMENT_ENV}-${{ inputs.module }} --force-new-deployment + - name: Deploy ECS service to run on latest image in ECR + env: + SERVICE_NAME: ab2d-${{ vars[format('{0}_DEPLOYMENT_ENV', inputs.environment)] }}-${{ inputs.module }} + run: | + echo "Deploying service $SERVICE_NAME" + aws ecs update-service --cluster "$SERVICE_NAME" --service "$SERVICE_NAME" --force-new-deployment > /dev/null + aws ecs wait services-stable --cluster "$SERVICE_NAME" --services "$SERVICE_NAME" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 000000000..f167abfff --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,60 @@ +name: promote + +on: + workflow_call: + inputs: + environment: + required: true + type: string + module: + required: true + type: string + workflow_dispatch: + inputs: + environment: + required: true + type: choice + options: + - sbx + - prod + - prod_test + module: + required: true + type: choice + options: + - api + - worker + +permissions: + contents: read + id-token: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ secrets.MGMT_ACCOUNT_ID }}:role/delegatedadmin/developer/ab2d-mgmt-github-actions + - name: Retag images in ECR + env: + DEPLOYMENT_ENV: ${{ vars[format('{0}_DEPLOYMENT_ENV', inputs.environment)] }} + ECR_REPO_DOMAIN: ${{ secrets.MGMT_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com + ECR_REPO: ab2d_${{ inputs.module }} + run: | + SHA_SHORT="$(git rev-parse --short HEAD)" + TOKEN="$(aws ecr get-authorization-token --output text --query 'authorizationData[].authorizationToken')" + CONTENT_TYPE="application/vnd.docker.distribution.manifest.v2+json" + + echo "Getting the manifest of the image tagged main-$SHA_SHORT" + MANIFEST="$(curl -sS -H "Authorization: Basic $TOKEN" -H "Accept: $CONTENT_TYPE" "https://$ECR_REPO_DOMAIN/v2/$ECR_REPO/manifests/main-$SHA_SHORT")" + + SHA_TAG="ab2d-$DEPLOYMENT_ENV-$SHA_SHORT" + echo "Adding the $SHA_TAG tag to main-$SHA_SHORT image" + curl -sS -X PUT -H "Authorization: Basic $TOKEN" -H "Content-Type: $CONTENT_TYPE" -d "$MANIFEST" "https://$ECR_REPO_DOMAIN/v2/$ECR_REPO/manifests/$SHA_TAG" + + LATEST_TAG="ab2d-$DEPLOYMENT_ENV-latest" + echo "Adding the $LATEST_TAG tag to main-$SHA_SHORT image" + curl -sS -X PUT -H "Authorization: Basic $TOKEN" -H "Content-Type: $CONTENT_TYPE" -d "$MANIFEST" "https://$ECR_REPO_DOMAIN/v2/$ECR_REPO/manifests/$LATEST_TAG" diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml new file mode 100644 index 000000000..3e5ac51fa --- /dev/null +++ b/.github/workflows/push-main.yml @@ -0,0 +1,46 @@ +name: push to main + +on: + push: + branches: + - main + +jobs: + build-api: + uses: ./.github/workflows/build.yml + with: + environment: test + module: api + secrets: inherit + build-worker: + uses: ./.github/workflows/build.yml + with: + environment: test + module: worker + secrets: inherit + deploy-api: + needs: build-api + permissions: + contents: read + id-token: write + uses: ./.github/workflows/deploy.yml + with: + environment: test + module: api + secrets: inherit + deploy-worker: + needs: build-worker + permissions: + contents: read + id-token: write + uses: ./.github/workflows/deploy.yml + with: + environment: test + module: worker + secrets: inherit + e2e-test: + needs: [deploy-api, deploy-worker] + uses: ./.github/workflows/e2e-test.yml + with: + environment: test + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..aa25edd3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: release + +on: + release: + types: [released] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + # Promote and Deploy to prod-test, which only includes worker + promote-prod-test-worker: + uses: ./.github/workflows/promote.yml + with: + environment: prod_test + module: worker + secrets: inherit + + deploy-prod-test-worker: + needs: promote-prod-test-worker + uses: ./.github/workflows/deploy.yml + with: + environment: prod_test + module: worker + secrets: inherit + + # Promote and Deploy to prod + promote-prod-api: + uses: ./.github/workflows/promote.yml + with: + environment: prod + module: api + secrets: inherit + + promote-prod-worker: + uses: ./.github/workflows/promote.yml + with: + environment: prod + module: worker + secrets: inherit + + deploy-prod-api: + needs: promote-prod-api + uses: ./.github/workflows/deploy.yml + with: + environment: prod + module: api + secrets: inherit + + deploy-prod-worker: + needs: promote-prod-worker + uses: ./.github/workflows/deploy.yml + with: + environment: prod + module: worker + secrets: inherit + + # Promote and Deploy to sandbox + promote-sbx-api: + uses: ./.github/workflows/promote.yml + with: + environment: sbx + module: api + secrets: inherit + + promote-sbx-worker: + uses: ./.github/workflows/promote.yml + with: + environment: sbx + module: worker + secrets: inherit + + deploy-sbx-api: + needs: promote-sbx-api + uses: ./.github/workflows/deploy.yml + with: + environment: sbx + module: api + secrets: inherit + + deploy-sbx-worker: + needs: promote-sbx-worker + uses: ./.github/workflows/deploy.yml + with: + environment: sbx + module: worker + secrets: inherit diff --git a/.github/workflows/unit-integration-test.yml b/.github/workflows/unit-integration-test.yml index db25683c4..f4fcdafc2 100644 --- a/.github/workflows/unit-integration-test.yml +++ b/.github/workflows/unit-integration-test.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v3 + with: + fetch-depth: 0 # Get entire history for SonarQube - name: Setup Java uses: actions/setup-java@v3 @@ -54,10 +56,10 @@ jobs: run: | mvn -ntp -U clean - - name: SonarQube analysis + - name: Run unit and integration tests run: | - mvn -ntp -s settings.xml ${RUNNER_DEBUG:+"--debug"} compile sonar:sonar -Dsonar.projectKey=ab2d-project -Dsonar.qualitygate.wait=true -DskipTests -Dusername=${ARTIFACTORY_USER} -Dpassword=${ARTIFACTORY_PASSWORD} -Drepository_url=${ARTIFACTORY_URL} + mvn -ntp -s settings.xml ${RUNNER_DEBUG:+"--debug"} -Dusername=${ARTIFACTORY_USER} -Dpassword=${ARTIFACTORY_PASSWORD} -Drepository_url=${ARTIFACTORY_URL} test -pl common,job,coverage,api,worker - - name: Run unit and integration tests + - name: SonarQube analysis run: | - mvn -ntp -s settings.xml ${RUNNER_DEBUG:+"--debug"} -Dusername=${ARTIFACTORY_USER} -Dpassword=${ARTIFACTORY_PASSWORD} -Drepository_url=${ARTIFACTORY_URL} test -pl common,job,coverage,api,worker + mvn -ntp -s settings.xml ${RUNNER_DEBUG:+"--debug"} package sonar:sonar -Dsonar.projectKey=ab2d-project -Dsonar.qualitygate.wait=true -DskipTests -Dusername=${ARTIFACTORY_USER} -Dpassword=${ARTIFACTORY_PASSWORD} -Drepository_url=${ARTIFACTORY_URL} diff --git a/api/src/main/java/gov/cms/ab2d/api/config/OpenAPIConfig.java b/api/src/main/java/gov/cms/ab2d/api/config/OpenAPIConfig.java index 2c8806d33..3be83466d 100644 --- a/api/src/main/java/gov/cms/ab2d/api/config/OpenAPIConfig.java +++ b/api/src/main/java/gov/cms/ab2d/api/config/OpenAPIConfig.java @@ -142,7 +142,7 @@ public OpenApiCustomizer defaultResponseMessages() { @JsonPropertyOrder({ "text" }) - static class Details { + public static class Details { @JsonProperty("text") private String text; @@ -177,7 +177,7 @@ public void setAdditionalProperty(String name, Object value) { "code", "details" }) - static class Issue { + public static class Issue { @JsonProperty("severity") private String severity; @JsonProperty("code") diff --git a/api/src/main/java/gov/cms/ab2d/api/controller/common/StatusCommon.java b/api/src/main/java/gov/cms/ab2d/api/controller/common/StatusCommon.java index 8e154936a..e76dea204 100644 --- a/api/src/main/java/gov/cms/ab2d/api/controller/common/StatusCommon.java +++ b/api/src/main/java/gov/cms/ab2d/api/controller/common/StatusCommon.java @@ -1,5 +1,6 @@ package gov.cms.ab2d.api.controller.common; +import gov.cms.ab2d.api.config.OpenAPIConfig; import gov.cms.ab2d.api.controller.JobCompletedResponse; import gov.cms.ab2d.api.controller.JobProcessingException; import gov.cms.ab2d.api.controller.TooManyRequestsException; @@ -11,6 +12,7 @@ import gov.cms.ab2d.eventclient.events.ApiResponseEvent; import gov.cms.ab2d.job.dto.JobPollResult; import gov.cms.ab2d.job.model.JobOutput; + import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -25,7 +27,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; - import static gov.cms.ab2d.api.controller.common.ApiText.X_PROG; import static gov.cms.ab2d.common.util.Constants.FHIR_PREFIX; import static gov.cms.ab2d.common.util.Constants.JOB_LOG; @@ -33,6 +34,7 @@ import static gov.cms.ab2d.common.util.Constants.REQUEST_ID; import static org.springframework.http.HttpHeaders.EXPIRES; import static org.springframework.http.HttpHeaders.RETRY_AFTER; +import static org.springframework.http.MediaType.APPLICATION_JSON; @Service @Slf4j @@ -41,6 +43,7 @@ public class StatusCommon { private final JobClient jobClient; private final SQSEventClient eventLogger; private final int retryAfterDelay; + private final OpenAPIConfig openApi; StatusCommon(PdpClientService pdpClientService, JobClient jobClient, SQSEventClient eventLogger, @Value("${api.retry-after.delay}") int retryAfterDelay) { @@ -48,6 +51,8 @@ public class StatusCommon { this.jobClient = jobClient; this.eventLogger = eventLogger; this.retryAfterDelay = retryAfterDelay; + + this.openApi = new OpenAPIConfig(); } public void throwFailedResponse(String msg) { @@ -80,6 +85,8 @@ public ResponseEntity doStatus(String jobUuid, HttpServletRequest request, Strin "Job in progress", jobPollResult.getProgress() + "% complete", (String) request.getAttribute(REQUEST_ID))); return new ResponseEntity<>(null, responseHeaders, HttpStatus.ACCEPTED); + case CANCELLED: + return getCanceledResponse(jobPollResult, jobUuid, request); case FAILED: throwFailedResponse("Job failed while processing"); break; @@ -117,6 +124,31 @@ protected JobCompletedResponse getJobCompletedResponse(JobPollResult jobPollResu return resp; } + protected ResponseEntity getCanceledResponse(JobPollResult jobPollResult, String jobUuid, HttpServletRequest request) { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(APPLICATION_JSON); + + OpenAPIConfig.OperationOutcome outcome = openApi.new OperationOutcome(); + outcome.setResourceType("OperationOutcome"); + + OpenAPIConfig.Details details = new OpenAPIConfig.Details(); + details.setText("Job is canceled."); + + OpenAPIConfig.Issue issue = new OpenAPIConfig.Issue(); + issue.setDetails(details); + issue.setCode("deleted"); + issue.setSeverity("error"); + + List issuesList = new ArrayList<>(); + issuesList.add(issue); + outcome.setIssue(issuesList); + + eventLogger.sendLogs(new ApiResponseEvent(MDC.get(ORGANIZATION), jobUuid, HttpStatus.NOT_FOUND, + "Job was previously canceled", null, (String) request.getAttribute(REQUEST_ID))); + + return new ResponseEntity(outcome, responseHeaders, HttpStatus.NOT_FOUND); + } + private String getUrlPath(String jobUuid, String filePath, HttpServletRequest request, String apiPrefix) { return Common.getUrl(apiPrefix + FHIR_PREFIX + "/Job/" + jobUuid + "/file/" + filePath, request); } diff --git a/api/src/test/java/gov/cms/ab2d/api/controller/common/StatusCommonTest.java b/api/src/test/java/gov/cms/ab2d/api/controller/common/StatusCommonTest.java index 88b9e060f..7c9e7be59 100644 --- a/api/src/test/java/gov/cms/ab2d/api/controller/common/StatusCommonTest.java +++ b/api/src/test/java/gov/cms/ab2d/api/controller/common/StatusCommonTest.java @@ -67,7 +67,7 @@ void testTooFrequentInvocations() { } @Test - void testDoStatus1() { + void testDoStatusSuccessful() { when(jobPollResult.getStatus()).thenReturn(JobStatus.SUCCESSFUL); assertNotNull( statusCommon.doStatus("1234", req, "prefix") @@ -75,7 +75,7 @@ void testDoStatus1() { } @Test - void testDoStatus2() { + void testDoStatusSubmitted() { when(jobPollResult.getStatus()).thenReturn(JobStatus.SUBMITTED); assertNotNull( statusCommon.doStatus("1234", req, "prefix") @@ -83,7 +83,7 @@ void testDoStatus2() { } @Test - void testDoStatus3() { + void testDoStatusInProgress() { when(jobPollResult.getStatus()).thenReturn(JobStatus.IN_PROGRESS); assertNotNull( statusCommon.doStatus("1234", req, "prefix") @@ -91,7 +91,7 @@ void testDoStatus3() { } @Test - void testDoStatus4() { + void testDoStatusFailed() { when(jobPollResult.getStatus()).thenReturn(JobStatus.FAILED); assertThrows(JobProcessingException.class, () -> { statusCommon.doStatus("1234", req, "prefix"); @@ -99,11 +99,11 @@ void testDoStatus4() { } @Test - void testDoStatus5() { + void testDoStatusCanceled() { when(jobPollResult.getStatus()).thenReturn(JobStatus.CANCELLED); - assertThrows(JobProcessingException.class, () -> { - statusCommon.doStatus("1234", req, "prefix"); - }); + assertNotNull( + statusCommon.doStatus("1234", req, "prefix") + ); } @Test @@ -120,4 +120,11 @@ void testGetJobCompletedResponse() { ); } + @Test + void testGetJobCanceledResponse() { + assertNotNull( + statusCommon.getCanceledResponse(jobPollResult, "1234", req) + ); + } + }