diff --git a/.circleci/config.yml b/.circleci/config.yml index ff375cd8..af450fad 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -140,19 +140,20 @@ commands: kubectl set image cronjobs.batch/crls crls=<< parameters.tag >> --namespace=<< parameters.namespace >> jobs: - docker-build-staging: + docker-build: docker: - image: docker:18.06.0-ce-git steps: - - docker-build: - cdn_url: https://atat-cdn-staging.azureedge.net/static/assets - - docker-build-master: - docker: - - image: docker:18.06.0-ce-git - steps: - - docker-build: - cdn_url: https://atat-cdn.azureedge.net/static/assets + - checkout + - setup_remote_docker: + docker_layer_caching: true + version: 18.06.0-ce + - run: + name: Build image + command: | + docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:builder --target builder + docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest + - cache_docker_image test: docker: @@ -285,54 +286,29 @@ workflows: version: 2 run-tests: jobs: - - docker-build-staging: - filters: - branches: - ignore: - - staging - - master + - docker-build - test: requires: - - docker-build-staging + - docker-build - integration-tests: requires: - - docker-build-staging - - build-staging: - jobs: - - docker-build-staging: - filters: - branches: - only: - - staging - - test: - requires: - - docker-build-staging - - integration-tests: - requires: - - docker-build-staging + - docker-build - deploy-staging: requires: - test - integration-tests - - build-master: - jobs: - - docker-build-master: filters: branches: only: - - master - - test: - requires: - - docker-build-master - - integration-tests: - requires: - - docker-build-master + - staging - deploy-master: requires: - test - integration-tests + filters: + branches: + only: + - master test-crl-parser: triggers: @@ -343,7 +319,7 @@ workflows: only: - staging jobs: - - docker-build-staging + - docker-build - test-crl-parser: requires: - - docker-build-staging + - docker-build diff --git a/.secrets.baseline b/.secrets.baseline index dd2428f1..07353d5a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2019-11-19T18:28:27Z", + "generated_at": "2019-11-26T21:33:43Z", "plugins_used": [ { "base64_limit": 4.5, @@ -46,7 +46,7 @@ "hashed_secret": "81b127e2222d9bfc4609053faec85300f7525463", "is_secret": false, "is_verified": false, - "line_number": 244, + "line_number": 290, "type": "Secret Keyword" } ], diff --git a/README.md b/README.md index d12a5b74..d18177d4 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,44 @@ To generate coverage reports for the Javascript tests: yarn test:coverage +## Configuration + +- `ASSETS_URL`: URL to host which serves static assets (such as a CDN). +- `BLOB_STORAGE_URL`: URL to Azure blob storage container. +- `CAC_URL`: URL for the CAC authentication route. +- `CA_CHAIN`: Path to the CA chain file. +- `CDN_ORIGIN`: URL for the origin host for asset files. +- `CELERY_DEFAULT_QUEUE`: String specifying the name of the queue that background tasks will be added to. +- `CONTRACT_END_DATE`: String specifying the end date of the JEDI contract. Used for task order validation. Example: 2019-09-14 +- `CONTRACT_START_DATE`: String specifying the start date of the JEDI contract. Used for task order validation. Example: 2019-09-14. +- `CRL_FAIL_OPEN`: Boolean specifying if expired CRLs should fail open, rather than closed. +- `CRL_STORAGE_CONTAINER`: Path to a directory where the CRL cache will be stored. +- `CSP`: String specifying the cloud service provider to use. Acceptable values: "azure", "mock", "mock-csp". +- `DEBUG`: Boolean. A truthy value enables Flask's debug mode. https://flask.palletsprojects.com/en/1.1.x/config/#DEBUG +- `DISABLE_CRL_CHECK`: Boolean specifying if CRL check should be bypassed. Useful for instances of the application container that are not serving HTTP requests, such as Celery workers. +- `ENVIRONMENT`: String specifying the current environment. Acceptable values: "dev", "prod". +- `LIMIT_CONCURRENT_SESSIONS`: Boolean specifying if users should be allowed only one active session at a time. +- `LOG_JSON`: Boolean specifying whether app should log in a json format. +- `PERMANENT_SESSION_LIFETIME`: Integer specifying how many seconds a user's session can stay valid for. https://flask.palletsprojects.com/en/1.1.x/config/#PERMANENT_SESSION_LIFETIME +- `PGDATABASE`: String specifying the name of the postgres database. +- `PGHOST`: String specifying the hostname of the postgres database. +- `PGPASSWORD`: String specifying the password of the postgres database. +- `PGPORT`: Integer specifying the port number of the postgres database. +- `PGSSLMODE`: String specifying the ssl mode to use when connecting to the postgres database. https://www.postgresql.org/docs/9.1/libpq-ssl.html +- `PGSSLROOTCERT`: Path to the root SSL certificate for the postgres database. +- `PGUSER`: String specifying the username to use when connecting to the postgres database. +- `PORT`: Integer specifying the port to bind to when running the flask server. Used only for local development. +- `REDIS_URI`: URI for the redis server. +- `SECRET_KEY`: String key which will be used to sign the session cookie. Should be a long string of random bytes. https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY +- `SERVER_NAME`: Hostname for ATAT. Only needs to be specified in contexts where the hostname cannot be inferred from the request, such as Celery workers. https://flask.palletsprojects.com/en/1.1.x/config/#SERVER_NAME +- `SESSION_COOKIE_NAME`: String value specifying the name to use for the session cookie. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_NAME +- `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/ +- `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed. +- `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout. +- `STATIC_URL`: URL specifying where static assets are hosted. +- `USE_AUDIT_LOG`: Boolean value describing if ATAT should write to the audit log table in the database. Set to "false" by default for performance reasons. +- `WTF_CSRF_ENABLED`: Boolean value specifying if WTForms should protect against CSRF. Should be set to "true" unless running automated tests. + ### UI Test Automation AT-AT uses [Ghost Inpsector](https://app.ghostinspector.com/), a testing PaaS diff --git a/atst/app.py b/atst/app.py index 3695f65e..39eab6ec 100644 --- a/atst/app.py +++ b/atst/app.py @@ -160,7 +160,6 @@ def map_config(config): "BROKER_URL": config["default"]["REDIS_URI"], "DEBUG": config["default"].getboolean("DEBUG"), "SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"), - "CLASSIFIED": config["default"].getboolean("CLASSIFIED"), "PORT": int(config["default"]["PORT"]), "SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"], "SQLALCHEMY_TRACK_MODIFICATIONS": False, @@ -175,8 +174,6 @@ def map_config(config): "PERMANENT_SESSION_LIFETIME": config.getint( "default", "PERMANENT_SESSION_LIFETIME" ), - "RQ_REDIS_URL": config["default"]["REDIS_URI"], - "RQ_QUEUES": [config["default"]["RQ_QUEUES"]], "DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"), "CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"), "LOG_JSON": config.getboolean("default", "LOG_JSON"), diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py index 88c30482..2d42fede 100644 --- a/atst/domain/csp/reports.py +++ b/atst/domain/csp/reports.py @@ -1,6 +1,7 @@ from itertools import groupby -from collections import OrderedDict import pendulum +from decimal import Decimal +from collections import OrderedDict class ReportingInterface: @@ -35,14 +36,16 @@ def generate_sample_dates(_max=8): current = pendulum.now() sample_dates = [] for _i in range(_max): - current = current.subtract(months=1) sample_dates.append(current.strftime("%m/%Y")) + current = current.subtract(months=1) reversed(sample_dates) return sample_dates class MockReportingProvider(ReportingInterface): + MOCK_PERCENT_EXPENDED_FUNDS = 0.75 + FIXTURE_MONTHS = generate_sample_dates() MONTHLY_SPEND_BY_ENVIRONMENT = { @@ -163,25 +166,8 @@ class MockReportingProvider(ReportingInterface): "FM_Prod": {FIXTURE_MONTHS[0]: 5686}, } - CUMULATIVE_BUDGET_A_WING = { - FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857}, - FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738}, - FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748}, - FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259}, - FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984}, - FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312}, - FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803}, - FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831}, - } - - CUMULATIVE_BUDGET_B_WING = { - FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838}, - FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338}, - } - REPORT_FIXTURE_MAP = { "A-Wing": { - "cumulative": CUMULATIVE_BUDGET_A_WING, "applications": [ MockApplication("LC04", ["Integ", "PreProd", "Prod"]), MockApplication("SF18", ["Integ", "PreProd", "Prod"]), @@ -202,7 +188,6 @@ class MockReportingProvider(ReportingInterface): "budget": 500_000, }, "B-Wing": { - "cumulative": CUMULATIVE_BUDGET_B_WING, "applications": [ MockApplication("NP02", ["Integ", "PreProd", "Prod"]), MockApplication("FM", ["Integ", "Prod"]), @@ -211,28 +196,6 @@ class MockReportingProvider(ReportingInterface): }, } - def _sum_monthly_spend(self, data): - return sum( - [ - spend - for application in data - for env in application.environments - for spend in self.MONTHLY_SPEND_BY_ENVIRONMENT[env.id].values() - ] - ) - - def get_budget(self, portfolio): - if portfolio.name in self.REPORT_FIXTURE_MAP: - return self.REPORT_FIXTURE_MAP[portfolio.name]["budget"] - return 0 - - def get_total_spending(self, portfolio): - if portfolio.name in self.REPORT_FIXTURE_MAP: - return self._sum_monthly_spend( - self.REPORT_FIXTURE_MAP[portfolio.name]["applications"] - ) - return 0 - def _rollup_application_totals(self, data): application_totals = {} for application, environments in data.items(): @@ -270,7 +233,14 @@ class MockReportingProvider(ReportingInterface): { "01/2018": 79.85, "02/2018": 86.54 } """ - return self.MONTHLY_SPEND_BY_ENVIRONMENT.get(environment_id, {}) + environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get( + environment_id, {} + ).copy() + + environment_monthly_totals["total_spend_to_date"] = sum( + monthly_total for monthly_total in environment_monthly_totals.values() + ) + return environment_monthly_totals def monthly_totals(self, portfolio): """Return month totals rolled up by environment, application, and portfolio. @@ -309,19 +279,49 @@ class MockReportingProvider(ReportingInterface): "portfolio": portfolio_totals, } - def cumulative_budget(self, portfolio): + def get_obligated_funds_by_JEDI_clin(self, portfolio): + """ + Returns a dictionary of obligated funds and spending per JEDI CLIN + { + JEDI_CLIN: { + obligated_funds, + expended_funds + } + } + """ if portfolio.name in self.REPORT_FIXTURE_MAP: - budget_months = self.REPORT_FIXTURE_MAP[portfolio.name]["cumulative"] - else: - budget_months = {} + return_dict = {} + for jedi_clin, clins in groupby( + portfolio.active_clins, lambda clin: clin.jedi_clin_type + ): + obligated_funds = sum(clin.obligated_amount for clin in clins) + return_dict[jedi_clin.value] = { + "obligated_funds": obligated_funds, + "expended_funds": ( + obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS) + ), + } + return OrderedDict( + # 0 index for dict item, -1 for last digit of 4 digit CLIN, e.g. 0001 + sorted(return_dict.items(), key=lambda clin: clin[0][-1]) + ) + return {} - end = pendulum.now() - start = end.subtract(months=12) - period = pendulum.period(start, end) - - all_months = OrderedDict() - for t in period.range("months"): - month_str = "{month:02d}/{year}".format(month=t.month, year=t.year) - all_months[month_str] = budget_months.get(month_str, None) - - return {"months": all_months} + def get_expired_task_orders(self, portfolio): + return [ + { + "id": task_order.id, + "number": task_order.number, + "period_of_performance": { + "start_date": task_order.start_date, + "end_date": task_order.end_date, + }, + "total_obligated_funds": task_order.total_obligated_funds, + "expended_funds": ( + task_order.total_obligated_funds + * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS) + ), + } + for task_order in portfolio.task_orders + if task_order.is_expired + ] diff --git a/atst/domain/reports.py b/atst/domain/reports.py index 96085afb..94f6c54e 100644 --- a/atst/domain/reports.py +++ b/atst/domain/reports.py @@ -2,16 +2,14 @@ from flask import current_app class Reports: - @classmethod - def portfolio_totals(cls, portfolio): - budget = current_app.csp.reports.get_budget(portfolio) - spent = current_app.csp.reports.get_total_spending(portfolio) - return {"budget": budget, "spent": spent} - @classmethod def monthly_totals(cls, portfolio): return current_app.csp.reports.monthly_totals(portfolio) @classmethod - def cumulative_budget(cls, portfolio): - return current_app.csp.reports.cumulative_budget(portfolio) + def expired_task_orders(cls, portfolio): + return current_app.csp.reports.get_expired_task_orders(portfolio) + + @classmethod + def obligated_funds_by_JEDI_clin(cls, portfolio): + return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio) diff --git a/atst/forms/data.py b/atst/forms/data.py index 9bf88cd7..7b1b4a40 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -117,8 +117,8 @@ ENV_ROLES = [(role.value, role.value) for role in CSPRole] + [ ] JEDI_CLIN_TYPES = [ - ("JEDI_CLIN_1", translate("forms.task_order.clin_01_label")), - ("JEDI_CLIN_2", translate("forms.task_order.clin_02_label")), - ("JEDI_CLIN_3", translate("forms.task_order.clin_03_label")), - ("JEDI_CLIN_4", translate("forms.task_order.clin_04_label")), + ("JEDI_CLIN_1", translate("JEDICLINType.JEDI_CLIN_1")), + ("JEDI_CLIN_2", translate("JEDICLINType.JEDI_CLIN_2")), + ("JEDI_CLIN_3", translate("JEDICLINType.JEDI_CLIN_3")), + ("JEDI_CLIN_4", translate("JEDICLINType.JEDI_CLIN_4")), ] diff --git a/atst/models/application_role.py b/atst/models/application_role.py index 12c7a9c3..f8f7f201 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -113,19 +113,17 @@ class ApplicationRole( @property def display_status(self): if ( - self.is_pending - and self.latest_invitation - and self.latest_invitation.is_pending - ): - return "invite_pending" - - elif ( self.is_pending and self.latest_invitation and self.latest_invitation.is_expired ): return "invite_expired" - + elif ( + self.is_pending + and self.latest_invitation + and self.latest_invitation.is_pending + ): + return "invite_pending" elif self.is_active and any( env_role.is_pending for env_role in self.environment_roles ): diff --git a/atst/models/clin.py b/atst/models/clin.py index e376da9c..2802e292 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -1,6 +1,7 @@ from enum import Enum from sqlalchemy import Column, Date, Enum as SQLAEnum, ForeignKey, Numeric, String from sqlalchemy.orm import relationship +from datetime import date from atst.models.base import Base import atst.models.mixins as mixins @@ -61,3 +62,7 @@ class CLIN(Base, mixins.TimestampsMixin): for c in self.__table__.columns if c.name not in ["id"] } + + @property + def is_active(self): + return self.start_date <= date.today() <= self.end_date diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index d749e470..08a65f1c 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -65,6 +65,58 @@ class Portfolio( def num_task_orders(self): return len(self.task_orders) + @property + def active_clins(self): + return [ + clin + for task_order in self.task_orders + for clin in task_order.clins + if clin.is_active + ] + + @property + def active_task_orders(self): + return [task_order for task_order in self.task_orders if task_order.is_active] + + @property + def funding_duration(self): + """ + Return the earliest period of performance start date and latest period + of performance end date for all active task orders in a portfolio. + @return: (datetime.date or None, datetime.date or None) + """ + start_dates = ( + task_order.start_date + for task_order in self.task_orders + if task_order.is_active + ) + + end_dates = ( + task_order.end_date + for task_order in self.task_orders + if task_order.is_active + ) + + earliest_pop_start_date = min(start_dates, default=None) + latest_pop_end_date = max(end_dates, default=None) + + return (earliest_pop_start_date, latest_pop_end_date) + + @property + def days_to_funding_expiration(self): + """ + Returns the number of days between today and the lastest period performance + end date of all active Task Orders + """ + return max( + ( + task_order.days_to_expiration + for task_order in self.task_orders + if task_order.is_active + ), + default=0, + ) + @property def members(self): return ( diff --git a/atst/queue.py b/atst/queue.py index 1cefbcbd..dfe9d894 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -1,5 +1,6 @@ from celery import Celery + celery = Celery(__name__) diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index c166223c..a4aea550 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -6,6 +6,7 @@ from atst.domain.environments import Environments from atst.domain.applications import Applications from atst.domain.application_roles import ApplicationRoles from atst.domain.audit_log import AuditLog +from atst.domain.csp.cloud import GeneralCSPException from atst.domain.common import Paginator from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations @@ -221,14 +222,22 @@ def handle_update_member(application_id, application_role_id, form_data): ) if form.validate(): - ApplicationRoles.update_permission_sets(app_role, form.data["permission_sets"]) + try: + ApplicationRoles.update_permission_sets( + app_role, form.data["permission_sets"] + ) - for env_role in form.environment_roles: - environment = Environments.get(env_role.environment_id.data) - new_role = None if env_role.disabled.data else env_role.data["role"] - Environments.update_env_role(environment, app_role, new_role) + for env_role in form.environment_roles: + environment = Environments.get(env_role.environment_id.data) + new_role = None if env_role.disabled.data else env_role.data["role"] + Environments.update_env_role(environment, app_role, new_role) - flash("application_member_updated", user_name=app_role.user_name) + flash("application_member_updated", user_name=app_role.user_name) + + except GeneralCSPException: + flash( + "application_member_update_error", user_name=app_role.user_name, + ) else: pass # TODO: flash error message diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 9c2ec1a0..06a4e783 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -1,4 +1,4 @@ -from datetime import date, timedelta +from datetime import date, datetime, timedelta from flask import redirect, render_template, url_for, request as http_request, g @@ -46,34 +46,25 @@ def create_portfolio(): def reports(portfolio_id): portfolio = Portfolios.get(g.current_user, portfolio_id) today = date.today() - month = http_request.args.get("month", today.month) - year = http_request.args.get("year", today.year) - current_month = date(int(year), int(month), 15) + current_month = date(int(today.year), int(today.month), 15) prev_month = current_month - timedelta(days=28) - two_months_ago = prev_month - timedelta(days=28) - - task_order = next( - (task_order for task_order in portfolio.task_orders if task_order.is_active), - None, + # wrapped in str() because the sum of obligated funds returns a Decimal object + total_portfolio_value = str( + sum( + task_order.total_obligated_funds + for task_order in portfolio.active_task_orders + ) ) - expiration_date = task_order and task_order.end_date - if expiration_date: - remaining_difference = expiration_date - today - remaining_days = remaining_difference.days - else: - remaining_days = None - return render_template( "portfolios/reports/index.html", - cumulative_budget=Reports.cumulative_budget(portfolio), - portfolio_totals=Reports.portfolio_totals(portfolio), + portfolio=portfolio, + total_portfolio_value=total_portfolio_value, + current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio), + expired_task_orders=Reports.expired_task_orders(portfolio), monthly_totals=Reports.monthly_totals(portfolio), - task_order=task_order, current_month=current_month, prev_month=prev_month, - two_months_ago=two_months_ago, - expiration_date=expiration_date, - remaining_days=remaining_days, + now=datetime.now(), # mocked datetime of reporting data retrival ) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 5fbad688..73bdbc4c 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -54,6 +54,11 @@ MESSAGES = { "message_template": "You have successfully deleted {{ user_name }} from {{ application_name }}", "category": "success", }, + "application_member_update_error": { + "title_template": "{{ user_name }} could not be updated", + "message_template": "An unexpected problem occurred with your request, please try again. If the problem persists, contact an administrator.", + "category": "error", + }, "application_member_updated": { "title_template": "Team member updated", "message_template": "You have successfully updated the permissions for {{ user_name }}", diff --git a/atst/utils/logging.py b/atst/utils/logging.py index d2370558..94e7aab6 100644 --- a/atst/utils/logging.py +++ b/atst/utils/logging.py @@ -10,6 +10,7 @@ class RequestContextFilter(logging.Filter): if has_request_context(): if getattr(g, "current_user", None): record.user_id = str(g.current_user.id) + record.dod_edipi = g.current_user.dod_id if request.environ.get("HTTP_X_REQUEST_ID"): record.request_id = request.environ.get("HTTP_X_REQUEST_ID") @@ -28,16 +29,28 @@ class JsonFormatter(logging.Formatter): ("version", lambda r: 1), ("request_id", lambda r: r.__dict__.get("request_id")), ("user_id", lambda r: r.__dict__.get("user_id")), + ("dod_edipi", lambda r: r.__dict__.get("dod_edipi")), ("severity", lambda r: r.levelname), ("tags", lambda r: r.__dict__.get("tags")), - ("message", lambda r: r.msg), ("audit_event", lambda r: r.__dict__.get("audit_event")), ] - def format(self, record): - message_dict = {"source": "atst"} + def __init__(self, *args, source="atst", **kwargs): + self.source = source + super().__init__(self) + + def format(self, record, *args, **kwargs): + message_dict = {"source": self.source} + for field, func in self._DEFAULT_RECORD_FIELDS: - message_dict[field] = func(record) + result = func(record) + if result: + message_dict[field] = result + + if record.args: + message_dict["message"] = record.msg % record.args + else: + message_dict["message"] = record.msg if record.__dict__.get("exc_info") is not None: message_dict["details"] = { diff --git a/celery_worker.py b/celery_worker.py index f1371182..d2a99b3a 100644 --- a/celery_worker.py +++ b/celery_worker.py @@ -1,7 +1,19 @@ #!/usr/bin/env python +import logging from atst.app import celery, make_app, make_config +from celery.signals import after_setup_task_logger + +from atst.utils.logging import JsonFormatter config = make_config() app = make_app(config) app.app_context().push() + + +@after_setup_task_logger.connect +def setup_task_logger(*args, **kwargs): + if app.config.get("LOG_JSON"): + logger = logging.getLogger() + for handler in logger.handlers: + handler.setFormatter(JsonFormatter(source="queue")) diff --git a/config/base.ini b/config/base.ini index 146ebcb4..257059e4 100644 --- a/config/base.ini +++ b/config/base.ini @@ -5,7 +5,6 @@ CAC_URL = http://localhost:8000/login-redirect CA_CHAIN = ssl/server-certs/ca-chain.pem CDN_ORIGIN=http://localhost:8000 CELERY_DEFAULT_QUEUE=celery -CLASSIFIED = false CONTRACT_END_DATE = 2022-09-14 CONTRACT_START_DATE = 2019-09-14 CRL_FAIL_OPEN = false @@ -17,8 +16,6 @@ ENVIRONMENT = dev LIMIT_CONCURRENT_SESSIONS = false LOG_JSON = false PERMANENT_SESSION_LIFETIME = 1800 -PE_NUMBER_CSV_URL = http://c95e1ebb198426ee57b8-174bb05a294821bedbf46b6384fe9b1f.r31.cf5.rackcdn.com/penumbers.csv -PGAPPNAME = atst PGDATABASE = atat PGHOST = localhost PGPASSWORD = postgres @@ -28,7 +25,6 @@ PGSSLROOTCERT PGUSER = postgres PORT=8000 REDIS_URI = redis://localhost:6379 -RQ_QUEUES = atat_%(ENVIRONMENT)s SECRET_KEY = change_me_into_something_secret SERVER_NAME SESSION_COOKIE_NAME=atat diff --git a/deploy/README.md b/deploy/README.md index c359fd5f..be66290d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,25 +8,28 @@ Applying the K8s config relies on a combination of kustomize and envsubst. Kusto The production configuration (azure.atat.code.mil, currently) is reflected in the configuration found in the `deploy/azure` directory. Configuration for a staging environment relies on kustomize to overwrite the production config with values appropriate for that environment. You can find more information about using kustomize [here](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/). Kustomize does not manage templating, and certain values need to be templated. These include: -- CONTAINER_IMAGE: the ATAT container image to use -- PORT_PREFIX: "8" for production, "9" for staging -- MAIN_DOMAIN: the host domain for the environment -- AUTH_DOMAIN: the host domain for the authentication endpoint for the environment +- CONTAINER_IMAGE: The ATAT container image to use. +- PORT_PREFIX: "8" for production, "9" for staging. +- MAIN_DOMAIN: The host domain for the environment. +- AUTH_DOMAIN: The host domain for the authentication endpoint for the environment. +- KV_MI_ID: the fully qualified id (path) of the managed identity for the key vault (instructions on retrieving this are down in section on [Setting up FlexVol](#configuring-the-identity)). Example: /subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME +- KV_MI_CLIENT_ID: The client id of the managed identity for the key vault. This is a GUID. -We use envsubst to substitute values for these variables. +We use envsubst to substitute values for these variables. There is a wrapper script (script/k8s_config) that will output the compiled configuration, using a combination of kustomize and envsubst. -To apply config to the main environment, you should first do a diff to determine whether your new config introduces unexpected changes: +To apply config to the main environment, you should first do a diff to determine whether your new config introduces unexpected changes. These examples assume that all the relevant environment variables listed above have been set: ``` -kubectl kustomize deploy/azure | CONTAINER_IMAGE=myregistry.io/atat-some-commit-sha PORT_PREFIX=8 MAIN_DOMAIN=azure.atat.code.mil AUTH_DOMAIN=auth-azure.atat.code.mil envsubst '$CONTAINER_IMAGE $PORT_PREFIX $MAIN_DOMAIN $AUTH_DOMAIN' | kubectl diff -f - +./script/k8s_config deploy/azure | kubectl diff -f - ``` Here, `kubectl kustomize` assembles the config and streams it to STDOUT. We specify environment variables for envsubst to use and pass the names of those env vars as a string argument to envsubst. This is important, because envsubst will override NGINX variables in the NGINX config if you don't limit its scope. Finally, we pipe the result from envsubst to `kubectl diff`, which reports a list of differences. Note that some values tracked by K8s internally might have changed, such as [`generation`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#objectmeta-v1-meta). This is fine and expected. If you are satisfied with the output from the diff, you can apply the new config the same way: + ``` -kubectl kustomize deploy/azure | CONTAINER_IMAGE=myregistry.io/atat-some-commit-sha PORT_PREFIX=8 MAIN_DOMAIN=azure.atat.code.mil AUTH_DOMAIN=auth-azure.atat.code.mil envsubst '$CONTAINER_IMAGE $PORT_PREFIX $MAIN_DOMAIN $AUTH_DOMAIN' | kubectl apply -f - +./script/k8s_config deploy/azure | kubectl apply -f - ``` **Note:** Depending on how your `kubectl` config is set up, these commands may need to be adjusted. If you have configuration for multiple clusters, you may need to specify the `kubectl` context for each command with the `--context` flag (something like `kubectl --context=my-cluster [etc.]` or `kubectl --context=azure [etc.]`). @@ -165,3 +168,52 @@ Then: ``` kubectl -n atat create secret tls azure-atat-code-mil-tls --key="[path to the private key]" --cert="[path to the full chain]" ``` + +--- + +# Setting Up FlexVol for Secrets + +## Preparing Azure Environment + +A Key Vault will need to be created. Save it's full id (the full path) for use later. + +## Preparing Cluster + +The 2 following k8s configs will need to be applied to the cluster. They do not need to be namespaced, the latter will create a `kv` namespace for itself. +``` +kubectl apply -f deploy/azure/keyvault/deployment-rbac.yaml +kubectl apply -f deploy/azure/keyvault/kv-flex-vol-installer.yaml +``` + +## Configuring The Identity + +1. Creat the identity in a resource group that is able to manage the cluster (`RESOURCE_GROUP_NAME`). You'll also determine a name for the identity (`MANAGED_IDENTITY_NAME`) that you'll use to refer to the identity later: + + `az identity create -g -n -o json` + +2. From the resulting JSON, we'll need to use: `id`, `clientId` and `principalId` for subsequent commands and configuration. +Example values: + ``` + id: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME" + clientId: 00000000-0000-0000-0000-000000000000 + principalId: 00000000-0000-0000-0000-000000000000 + ``` + + > You can recover these values later by running the following command. Verify you are looking at the correct identity by making sure the end of the first line (id) is the same as the name you provided above. If you want the full details of the identity, leave off the last section. + > + >`az identity list -g | jq '.[] | select(.type == "Microsoft.ManagedIdentity/userAssignedIdentities") | .id, .clientId, principalId'` + +3. Assign the new identity two roles: Managed Identity Operator (for itself) and Reader (of the vault). The `VAULT_ID` can be found in the azure portal. + + ``` + az role assignment create --role "Managed Identity Operator" --assignee --scope + az role assignment create --role Reader --assignee --scope + ``` + +4. Grant the identity get permissions for each of the types of values the vault can store, Keys, Secrets, and Certificates: + ``` + az keyvault set-policy -n --spn --key-permissions get --secret-permissions get --certificate-permissions get + ``` + +5. The file `deploy/azure/aadpodidentity.yml` is templated via Kustomize, so you'll need to include clientId (as `KV_MI_CLIENT_ID`) and id (as `KV_MI_ID`) of the managed identity as part of the call to Kustomize. + diff --git a/deploy/azure/aadpodidentity.yml b/deploy/azure/aadpodidentity.yml new file mode 100644 index 00000000..cd08aef1 --- /dev/null +++ b/deploy/azure/aadpodidentity.yml @@ -0,0 +1,19 @@ +--- +apiVersion: "aadpodidentity.k8s.io/v1" +kind: AzureIdentity +metadata: + name: atat-kv-identity +spec: + type: 0 + ResourceID: $KV_MI_ID + ClientID: $KV_MI_CLIENT_ID +--- +apiVersion: "aadpodidentity.k8s.io/v1" +kind: AzureIdentityBinding +metadata: + name: atat-key-vault-identity-binding +spec: + AzureIdentity: atat-kv-identity + Selector: atat-kv-id-binding +--- + diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index b22a9cb4..8d46fa4b 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -46,6 +46,9 @@ spec: - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: uwsgi-config + mountPath: "/opt/atat/atst/uwsgi.ini" + subPath: uwsgi.ini - name: nginx image: nginx:alpine ports: @@ -124,6 +127,14 @@ spec: configMap: name: acme-challenges defaultMode: 0666 + - name: uwsgi-config + configMap: + name: uwsgi-config + defaultMode: 0666 + items: + - key: uwsgi.ini + path: uwsgi.ini + mode: 0644 --- apiVersion: extensions/v1beta1 kind: Deployment diff --git a/deploy/azure/keyvault/deployment-rbac.yml b/deploy/azure/keyvault/deployment-rbac.yml new file mode 100644 index 00000000..45ac6ef1 --- /dev/null +++ b/deploy/azure/keyvault/deployment-rbac.yml @@ -0,0 +1,259 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aad-pod-id-nmi-service-account + namespace: default +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: azureassignedidentities.aadpodidentity.k8s.io +spec: + group: aadpodidentity.k8s.io + version: v1 + names: + kind: AzureAssignedIdentity + plural: azureassignedidentities + scope: Namespaced +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: azureidentitybindings.aadpodidentity.k8s.io +spec: + group: aadpodidentity.k8s.io + version: v1 + names: + kind: AzureIdentityBinding + plural: azureidentitybindings + scope: Namespaced +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: azureidentities.aadpodidentity.k8s.io +spec: + group: aadpodidentity.k8s.io + version: v1 + names: + kind: AzureIdentity + singular: azureidentity + plural: azureidentities + scope: Namespaced +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: azurepodidentityexceptions.aadpodidentity.k8s.io +spec: + group: aadpodidentity.k8s.io + version: v1 + names: + kind: AzurePodIdentityException + singular: azurepodidentityexception + plural: azurepodidentityexceptions + scope: Namespaced +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: aad-pod-id-nmi-role +rules: + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: ["aadpodidentity.k8s.io"] + resources: + ["azureidentitybindings", "azureidentities", "azurepodidentityexceptions"] + verbs: ["get", "list", "watch"] + - apiGroups: ["aadpodidentity.k8s.io"] + resources: ["azureassignedidentities"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: aad-pod-id-nmi-binding + labels: + k8s-app: aad-pod-id-nmi-binding +subjects: + - kind: ServiceAccount + name: aad-pod-id-nmi-service-account + namespace: default +roleRef: + kind: ClusterRole + name: aad-pod-id-nmi-role + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + kubernetes.io/cluster-service: "true" + component: nmi + tier: node + k8s-app: aad-pod-id + name: nmi + namespace: default +spec: + updateStrategy: + type: RollingUpdate + selector: + matchLabels: + component: nmi + tier: node + template: + metadata: + labels: + component: nmi + tier: node + spec: + serviceAccountName: aad-pod-id-nmi-service-account + hostNetwork: true + volumes: + - hostPath: + path: /run/xtables.lock + type: FileOrCreate + name: iptableslock + containers: + - name: nmi + image: "mcr.microsoft.com/k8s/aad-pod-identity/nmi:1.5.3" + imagePullPolicy: Always + args: + - "--host-ip=$(HOST_IP)" + - "--node=$(NODE_NAME)" + env: + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + resources: + limits: + cpu: 200m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + securityContext: + privileged: true + capabilities: + add: + - NET_ADMIN + volumeMounts: + - mountPath: /run/xtables.lock + name: iptableslock + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + nodeSelector: + beta.kubernetes.io/os: linux +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aad-pod-id-mic-service-account + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: aad-pod-id-mic-role +rules: + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["*"] + - apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["list", "watch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + - apiGroups: [""] + resources: ["endpoints"] + verbs: ["create", "get", "update"] + - apiGroups: ["aadpodidentity.k8s.io"] + resources: ["azureidentitybindings", "azureidentities"] + verbs: ["get", "list", "watch", "post"] + - apiGroups: ["aadpodidentity.k8s.io"] + resources: ["azureassignedidentities"] + verbs: ["*"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: aad-pod-id-mic-binding + labels: + k8s-app: aad-pod-id-mic-binding +subjects: + - kind: ServiceAccount + name: aad-pod-id-mic-service-account + namespace: default +roleRef: + kind: ClusterRole + name: aad-pod-id-mic-role + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + component: mic + k8s-app: aad-pod-id + name: mic + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + component: mic + app: mic + template: + metadata: + labels: + component: mic + app: mic + spec: + serviceAccountName: aad-pod-id-mic-service-account + containers: + - name: mic + image: "mcr.microsoft.com/k8s/aad-pod-identity/mic:1.5.3" + imagePullPolicy: Always + args: + - "--cloudconfig=/etc/kubernetes/azure.json" + - "--logtostderr" + resources: + limits: + cpu: 200m + memory: 1024Mi + requests: + cpu: 100m + memory: 256Mi + volumeMounts: + - name: k8s-azure-file + mountPath: /etc/kubernetes/azure.json + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + volumes: + - name: k8s-azure-file + hostPath: + path: /etc/kubernetes/azure.json + nodeSelector: + beta.kubernetes.io/os: linux diff --git a/deploy/azure/keyvault/kv-flex-vol-installer.yml b/deploy/azure/keyvault/kv-flex-vol-installer.yml new file mode 100644 index 00000000..b8217055 --- /dev/null +++ b/deploy/azure/keyvault/kv-flex-vol-installer.yml @@ -0,0 +1,49 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: kv +--- +apiVersion: extensions/v1beta1 +kind: DaemonSet +metadata: + labels: + app: keyvault-flexvolume + name: keyvault-flexvolume + namespace: kv +spec: + updateStrategy: + type: RollingUpdate + template: + metadata: + labels: + app: keyvault-flexvolume + spec: + tolerations: + containers: + - name: flexvol-driver-installer + image: "mcr.microsoft.com/k8s/flexvolume/keyvault-flexvolume:v0.0.15" + imagePullPolicy: Always + resources: + requests: + cpu: 50m + memory: 100Mi + limits: + cpu: 50m + memory: 100Mi + env: + # if you have used flex before on your cluster, use same directory + # set TARGET_DIR env var and mount the same directory to to the container + - name: TARGET_DIR + value: "/etc/kubernetes/volumeplugins" + volumeMounts: + - mountPath: "/etc/kubernetes/volumeplugins" + name: volplugins + volumes: + - hostPath: + # Modify this directory if your nodes are using a different one + # default is "/usr/libexec/kubernetes/kubelet-plugins/volume/exec" + # below is Azure default + path: "/etc/kubernetes/volumeplugins" + name: volplugins + nodeSelector: + beta.kubernetes.io/os: linux diff --git a/deploy/azure/kustomization.yaml b/deploy/azure/kustomization.yaml index 24ce6863..43e6f813 100644 --- a/deploy/azure/kustomization.yaml +++ b/deploy/azure/kustomization.yaml @@ -1,7 +1,7 @@ namespace: atat resources: - azure.yml - - atst-configmap.yml + - uwsgi-config.yml - atst-envvars-configmap.yml - atst-nginx-configmap.yml - atst-worker-envvars-configmap.yml @@ -10,3 +10,4 @@ resources: - volume-claim.yml - nginx-client-ca-bundle.yml - acme-challenges.yml + - aadpodidentity.yml diff --git a/deploy/azure/atst-configmap.yml b/deploy/azure/uwsgi-config.yml similarity index 94% rename from deploy/azure/atst-configmap.yml rename to deploy/azure/uwsgi-config.yml index 79f9a61b..553ea973 100644 --- a/deploy/azure/atst-configmap.yml +++ b/deploy/azure/uwsgi-config.yml @@ -2,10 +2,10 @@ apiVersion: v1 kind: ConfigMap metadata: - name: atst-config + name: uwsgi-config namespace: atat data: - uwsgi-config: |- + uwsgi.ini: |- [uwsgi] callable = app module = app @@ -14,6 +14,7 @@ data: plugin = logfile virtualenv = /opt/atat/atst/.venv chmod-socket = 666 + chown-socket = atst:atat ; logger config diff --git a/js/components/accordion.js b/js/components/accordion.js new file mode 100644 index 00000000..d281a9e7 --- /dev/null +++ b/js/components/accordion.js @@ -0,0 +1,14 @@ +import ToggleMixin from '../mixins/toggle' + +export default { + name: 'accordion', + + mixins: [ToggleMixin], + + props: { + defaultVisible: { + type: Boolean, + default: false, + }, + }, +} diff --git a/js/components/charts/budget_chart.js b/js/components/charts/budget_chart.js deleted file mode 100644 index 11fee9b0..00000000 --- a/js/components/charts/budget_chart.js +++ /dev/null @@ -1,189 +0,0 @@ -import { - format, - isWithinRange, - addMonths, - isSameMonth, - getMonth, -} from 'date-fns' -import { abbreviateDollars, formatDollars } from '../../lib/dollars' - -const TOP_OFFSET = 20 -const BOTTOM_OFFSET = 70 -const CHART_HEIGHT = 360 - -export default { - name: 'budget-chart', - props: { - currentMonth: String, - expirationDate: String, - months: Object, - budget: String, - }, - - data: function() { - const heightScale = - this.budget / (CHART_HEIGHT - TOP_OFFSET - BOTTOM_OFFSET) - return { - numMonths: 10, - focusedMonthPosition: 4, - height: CHART_HEIGHT, - heightScale, - budgetHeight: CHART_HEIGHT - BOTTOM_OFFSET - this.budget / heightScale, - baseHeight: CHART_HEIGHT - BOTTOM_OFFSET, - width: 0, - displayedMonths: [], - spendPath: '', - projectedPath: '', - displayBudget: formatDollars(parseFloat(this.budget)), - } - }, - - mounted: function() { - this._setDisplayedMonths() - this._setMetrics() - addEventListener('load', this._setMetrics) - addEventListener('resize', this._setMetrics) - }, - - methods: { - _setMetrics: function() { - this.width = this.$refs.panel.clientWidth - this.spendPath = '' - this.projectedPath = '' - - let lastSpend = 0 - let lastSpendPoint = '' - - for (let i = 0; i < this.numMonths; i++) { - const { - metrics, - budget, - rollingAverage, - cumulativeTotal, - } = this.displayedMonths[i] - const blockWidth = this.width / this.numMonths - const blockX = blockWidth * i - const spend = budget && budget.spend ? budget.spend : rollingAverage - const barHeight = spend / this.heightScale - lastSpend = spend - const cumulativeY = - this.height - cumulativeTotal / this.heightScale - BOTTOM_OFFSET - const cumulativeX = blockX + blockWidth / 2 - const cumulativePoint = `${cumulativeX} ${cumulativeY}` - - this.displayedMonths[i].metrics = Object.assign(metrics, { - blockWidth, - blockX, - barHeight, - barWidth: 30, - barX: blockX + (blockWidth / 2 - 15), - barY: this.height - barHeight - BOTTOM_OFFSET, - cumulativeR: 2.5, - cumulativeY, - cumulativeX, - }) - - if (budget && budget.spend) { - this.spendPath += this.spendPath === '' ? 'M' : ' L' - this.spendPath += cumulativePoint - lastSpendPoint = cumulativePoint - } else if (lastSpendPoint !== '') { - this.projectedPath += - this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L' - this.projectedPath += cumulativePoint - } - } - }, - - _setDisplayedMonths: function() { - const [month, year] = this.currentMonth.split('/') - const [expYear, expMonth, expDate] = this.expirationDate.split('-') // assumes format 'YYYY-MM-DD' - const monthsRange = [] - const monthsBack = this.focusedMonthPosition + 1 - const monthsForward = this.numMonths - this.focusedMonthPosition - 1 - - // currently focused date - const current = new Date(year, month) - - // starting date of the chart - const start = addMonths(current, -monthsBack) - - // ending date of the chart - const end = addMonths(start, this.numMonths + 1) - - // expiration date - const expires = new Date(expYear, expMonth - 1, expDate) - - // is the expiration date within the displayed date range? - const expirationWithinRange = isWithinRange(expires, start, end) - - let rollingAverage = 0 - let cumulativeTotal = 0 - - for (let i = 0; i < this.numMonths; i++) { - const date = addMonths(start, i) - const dateMinusOne = addMonths(date, -1) - const dateMinusTwo = addMonths(date, -2) - const dateMinusThree = addMonths(date, -3) - - const index = format(date, 'MM/YYYY') - const indexMinusOne = format(dateMinusOne, 'MM/YYYY') - const indexMinusTwo = format(dateMinusTwo, 'MM/YYYY') - const indexMinusThree = format(dateMinusThree, 'MM/YYYY') - - const budget = this.months[index] || null - const spendAmount = budget ? budget.spend : rollingAverage - const spendMinusOne = this.months[indexMinusOne] - ? this.months[indexMinusOne].spend - : rollingAverage - const spendMinusTwo = this.months[indexMinusTwo] - ? this.months[indexMinusTwo].spend - : rollingAverage - const spendMinusThree = this.months[indexMinusThree] - ? this.months[indexMinusThree].spend - : rollingAverage - - const isExpirationMonth = isSameMonth(date, expires) - - if (budget && budget.cumulative) { - cumulativeTotal = budget.cumulative - } else { - cumulativeTotal += spendAmount - } - - rollingAverage = - (spendAmount + spendMinusOne + spendMinusTwo + spendMinusThree) / 4 - - monthsRange.push({ - budget, - rollingAverage, - cumulativeTotal, - isExpirationMonth, - spendAmount: formatDollars(spendAmount), - abbreviatedSpend: abbreviateDollars(spendAmount), - cumulativeAmount: formatDollars(cumulativeTotal), - abbreviatedCumulative: abbreviateDollars(cumulativeTotal), - date: { - monthIndex: format(date, 'M'), - month: format(date, 'MMM'), - year: format(date, 'YYYY'), - }, - showYear: isExpirationMonth || i === 0 || getMonth(date) === 0, - isHighlighted: this.currentMonth === index, - metrics: { - blockWidth: 0, - blockX: 0, - barHeight: 0, - barWidth: 0, - barX: 0, - barY: 0, - cumulativeY: 0, - cumulativeX: 0, - cumulativeR: 0, - }, - }) - } - this.displayedMonths = monthsRange - }, - }, -} diff --git a/js/components/multi_checkbox_input.js b/js/components/multi_checkbox_input.js index 8ef4e880..fdba839f 100644 --- a/js/components/multi_checkbox_input.js +++ b/js/components/multi_checkbox_input.js @@ -14,6 +14,7 @@ export default { default: () => [], }, initialOtherValue: String, + optional: Boolean, }, data: function() { @@ -45,7 +46,7 @@ export default { computed: { valid: function() { - return this.showValid + return this.optional || this.showValid }, }, } diff --git a/js/components/options_input.js b/js/components/options_input.js index 639c8454..fafd0e02 100644 --- a/js/components/options_input.js +++ b/js/components/options_input.js @@ -10,10 +10,6 @@ export default { default: () => [], }, initialValue: String, - watch: { - type: Boolean, - default: false, - }, optional: Boolean, nullOption: { type: String, diff --git a/js/components/tables/spend_table.js b/js/components/tables/spend_table.js index f8d663a3..c3a1c90f 100644 --- a/js/components/tables/spend_table.js +++ b/js/components/tables/spend_table.js @@ -6,11 +6,9 @@ export default { props: { applications: Object, - portfolio: Object, environments: Object, currentMonthIndex: String, prevMonthIndex: String, - twoMonthsAgoIndex: String, }, data: function() { @@ -40,9 +38,5 @@ export default { formatDollars: function(value) { return formatDollars(value, false) }, - - round: function(value) { - return Math.round(value) - }, }, } diff --git a/js/components/toggle_menu.js b/js/components/toggle_menu.js new file mode 100644 index 00000000..e17a201a --- /dev/null +++ b/js/components/toggle_menu.js @@ -0,0 +1,25 @@ +import ToggleMixin from '../mixins/toggle' + +export default { + name: 'toggleMenu', + + mixins: [ToggleMixin], + + methods: { + toggle: function(e) { + if (this.$el.contains(e.target)) { + this.isVisible = !this.isVisible + } else { + this.isVisible = false + } + }, + }, + + mounted: function() { + document.addEventListener('click', this.toggle) + }, + + beforeDestroy: function() { + document.removeEventListener('click', this.toggle) + }, +} diff --git a/js/index.js b/js/index.js index 7381c828..a28c4868 100644 --- a/js/index.js +++ b/js/index.js @@ -17,7 +17,6 @@ import ApplicationEnvironments from './components/forms/new_application/environm import MultiStepModalForm from './components/forms/multi_step_modal_form' import uploadinput from './components/upload_input' import Modal from './mixins/modal' -import BudgetChart from './components/charts/budget_chart' import SpendTable from './components/tables/spend_table' import LocalDatetime from './components/local_datetime' import { isNotInVerticalViewport } from './lib/viewport' @@ -30,6 +29,8 @@ import SemiCollapsibleText from './components/semi_collapsible_text' import ToForm from './components/forms/to_form' import ClinFields from './components/clin_fields' import PopDateRange from './components/pop_date_range' +import Accordion from './components/accordion' +import ToggleMenu from './components/toggle_menu' Vue.config.productionTip = false @@ -40,6 +41,7 @@ Vue.mixin(Modal) const app = new Vue({ el: '#app-root', components: { + Accordion, dodlogin, toggler, optionsinput, @@ -47,7 +49,6 @@ const app = new Vue({ textinput, checkboxinput, ApplicationEnvironments, - BudgetChart, SpendTable, LocalDatetime, MultiStepModalForm, @@ -61,6 +62,7 @@ const app = new Vue({ ToForm, ClinFields, PopDateRange, + ToggleMenu, }, mounted: function() { diff --git a/js/mixins/text_input_mixin.js b/js/mixins/text_input_mixin.js index 54f8a7cf..87471a50 100644 --- a/js/mixins/text_input_mixin.js +++ b/js/mixins/text_input_mixin.js @@ -27,10 +27,6 @@ export default { paragraph: String, noMaxWidth: String, optional: Boolean, - watch: { - type: Boolean, - default: false, - }, }, data: function() { diff --git a/script/add_ccpo_user.py b/script/add_ccpo_user.py new file mode 100644 index 00000000..379dd9eb --- /dev/null +++ b/script/add_ccpo_user.py @@ -0,0 +1,35 @@ +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(parent_dir) + +from atst.app import make_config, make_app + +from atst.domain.exceptions import NotFoundError +from atst.domain.users import Users + + +def grant_ccpo_perms(dod_id): + try: + user = Users.get_by_dod_id(dod_id) + if user.permission_sets: + print("%s (DoD ID: %s) already CCPO user." % (user.full_name, user.dod_id)) + else: + Users.give_ccpo_perms(user) + print( + "CCPO permissions successfully granted to %s (DoD ID: %s)." + % (user.full_name, user.dod_id) + ) + + except NotFoundError: + print("User not found.") + + +if __name__ == "__main__": + config = make_config({"DISABLE_CRL_CHECK": True, "DEBUG": False}) + app = make_app(config) + + with app.app_context(): + dod_id = sys.argv[1] + grant_ccpo_perms(dod_id) diff --git a/script/k8s_config b/script/k8s_config new file mode 100755 index 00000000..ee3c9878 --- /dev/null +++ b/script/k8s_config @@ -0,0 +1,45 @@ +#!/bin/bash + +# script/k8s_update: Compiles the Kubernetes configuration for a given +# directory. Expects that the SETTINGS listed are all set as environment +# variables. +set -e + +# Expected settings. Script will error if these are not provided. +SETTINGS=( + CONTAINER_IMAGE + PORT_PREFIX + MAIN_DOMAIN + AUTH_DOMAIN + KV_MI_ID + KV_MI_CLIENT_ID +) + +# Loop all expected settings. Track ones that are missing and build +# concatenated list for envsubst. If any are missing, exit. +MISSING_SETTINGS=() +CONCAT_SETTINGS="" +for envvar in "${SETTINGS[@]}"; do + CONCAT_SETTINGS="${CONCAT_SETTINGS} \$${envvar}" + if [ -z "${!envvar}" ]; then + MISSING_SETTINGS+=(${envvar}) + fi +done + +if [[ ${#MISSING_SETTINGS[@]} > 0 ]]; then + >&2 echo "The following variables need to be set:" + for missing in "${MISSING_SETTINGS[@]}"; do + >&2 echo $missing + done + exit 1 +fi + +# Check that a directory is provided as a command line argument. +if [ "$#" -ne 1 ] || ! [ -d $1 ]; then + >&2 echo "You must provide a Kubernetes configuration directory." + exit 1 +fi + +# Use Kustomize to compile Kubernetes configuration and pipe to envsubst to +# substitute the templated values. +kubectl kustomize $1 | envsubst "'${CONCAT_SETTINGS}'" diff --git a/styles/atat.scss b/styles/atat.scss index 346b5d44..c3fd1a55 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -33,7 +33,6 @@ @import "components/progress_menu.scss"; @import "components/forms"; @import "components/selector"; -@import "components/budget_chart"; @import "components/audit_log"; @import "components/usa_banner"; @import "components/dod_login_notice.scss"; diff --git a/styles/components/_budget_chart.scss b/styles/components/_budget_chart.scss deleted file mode 100644 index d5930236..00000000 --- a/styles/components/_budget_chart.scss +++ /dev/null @@ -1,169 +0,0 @@ -.budget-chart { - svg { - display: block; - - .filter__text-background { - feFlood { - flood-color: $color-white; - flood-opacity: 1; - } - - &--highlighted { - feFlood { - flood-color: $color-aqua-lightest; - flood-opacity: 1; - } - } - } - - a { - text-decoration: none; - - &:focus { - outline: none; - stroke: $color-gray-light; - stroke-dasharray: 2px; - } - - &:hover { - .filter__text-background { - feFlood { - flood-color: $color-aqua-lightest; - flood-opacity: 1; - } - } - } - } - } - - &__header { - border-bottom: 1px solid $color-gray-light; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - &__legend { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - dl { - margin: 0 0 0 ($gap * 2); - - > div { - margin: 0; - display: flex; - flex-direction: row-reverse; - align-items: center; - - dt { - @include small-label; - } - } - } - - &__dot { - width: $gap; - height: $gap; - border-radius: $gap / 2; - margin: 0 $gap; - - &.accumulated { - background-color: $color-gold; - } - - &.monthly { - background-color: $color-blue; - } - } - - &__line { - height: 2px; - width: $gap * 3; - border-top-width: 2px; - border-top-style: dashed; - margin: $gap; - - &.spend { - border-color: $color-blue; - } - - &.accumulated { - border-color: $color-gold; - } - } - } - - &__block { - fill: transparent; - cursor: pointer; - - &--highlighted { - fill: rgba($color-aqua, 0.15); - } - - &--is-expiration { - border-left: 2px dotted $color-gray; - } - - &:hover { - fill: rgba($color-aqua, 0.15); - } - } - - &__bar { - fill: $color-blue; - - &--projected { - fill: transparent; - stroke-width: 2px; - stroke: $color-blue; - stroke-dasharray: 4px; - } - } - - &__expiration-line { - stroke-width: 2px; - stroke: $color-gray-light; - stroke-dasharray: 4px; - } - - &__cumulative { - &__dot { - fill: $color-gold; - } - } - - &__projected-path { - stroke-width: 1px; - stroke: $color-gold; - stroke-dasharray: 4px; - fill: none; - } - - &__spend-path { - stroke-width: 1px; - stroke: $color-gold; - fill: none; - } - - &__budget-line { - stroke-width: 2px; - stroke: $color-gray-light; - stroke-dasharray: 4px; - } - - &__label { - @include small-label; - - fill: $color-gray; - pointer-events: none; - - &--strong { - fill: $color-black; - } - } -} diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 4b2b3c65..0e018809 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -253,6 +253,11 @@ border-radius: 3px; cursor: pointer; + &:hover, + &--active { + background-color: $color-aqua-lightest; + } + .icon { margin: $gap / 2; } @@ -278,6 +283,10 @@ &:last-child { border-bottom: 0; } + + &:hover { + background-color: $color-aqua-lightest; + } } } } diff --git a/styles/elements/_typography.scss b/styles/elements/_typography.scss index 1ae28d9f..48784659 100644 --- a/styles/elements/_typography.scss +++ b/styles/elements/_typography.scss @@ -102,3 +102,7 @@ dl { font-weight: $font-bold; color: $color-black; } + +@mixin small-copy { + font-size: $small-font-size; +} diff --git a/styles/sections/_reports.scss b/styles/sections/_reports.scss index a53e2842..f80a7750 100644 --- a/styles/sections/_reports.scss +++ b/styles/sections/_reports.scss @@ -1,264 +1,90 @@ -.funding-summary-row { - @include media($medium-screen) { - @include grid-row; - - flex-wrap: wrap; +.portfolio-reports { + .estimate-warning { + margin-top: $gap * 3; + margin-bottom: $gap * 3; } - &__col { - hr { - margin: (2 * $gap) 0; + .reporting-section-header { + display: flex; + align-items: baseline; + &__header { + margin-right: $gap; + } + &__subheader { + @include small-copy; + } + } + + .jedi-clin-funding { + padding-top: $gap * 3; + padding-bottom: $gap * 3; + + &__clin-wrapper { + border-bottom: 1px solid $color-gray-light; + margin-bottom: $gap * 3; + padding-bottom: $gap * 3; + } + > div:nth-last-child(2) { + margin-bottom: 0; } - @include media($medium-screen) { - @include grid-pad; - - flex-grow: 1; - display: flex; - flex-direction: row; - flex-basis: 50%; - - @include ie-only { - max-width: 50%; - } - - &:first-child { - padding-left: 0; - } - - &:last-child { - padding-right: 0; - } + &__header { + margin: 0; } - align-items: stretch; + &__subheader { + @include small-copy; + margin: 0; + } - .panel { - padding: $gap * 2; + &__meter { + margin: 10px 0; + -moz-transform: scale(-1, 1); + -webkit-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); width: 100%; - @include ie-only { - max-width: 100%; - } - - .subheading { - @include h4; - - margin: 0 $gap (2 * $gap) 0; - -ms-flex-negative: 1; - } - - // Spending Summary - // =============================== - &.spend-summary { + &-values { display: flex; - flex-direction: column; - justify-content: space-between; - - .row { - justify-content: space-between; - - @include ie-only { - max-width: 100%; - flex-wrap: wrap; - } - } - - &__budget { - @include ie-only { - margin: $gap 0 0 0; - } - } - - dl { - text-align: left; - margin: 0 0 ($gap / 2) 0; - - @include ie-only { - text-align: left; - } - - dt { - text-transform: uppercase; - color: $color-gray-light; - margin-right: $gap; - font-weight: bold; - font-size: $small-font-size; - } - } - - meter { - width: 100%; - height: 3rem; - margin: ($gap * 2) 0 0; - } - - &__spent { - margin: (2 * $gap) 0; - display: flex; - flex-direction: column; - justify-content: flex-end; - - dt { - letter-spacing: 0.47px; - } - } } + } - // Task Order Summary - // =============================== - &.to-summary { - .icon-link { - font-weight: $font-normal; - } - - .subheading { - margin-bottom: 0; - } - - .to-summary__heading { - @include h4; - - margin: 0 $gap 0 0; - } - - .to-summary__to-number { - margin: 0; - - dd { - &::before { - content: "#"; - color: $color-gray; - margin-right: $gap; - } - } - - @include media($xlarge-screen) { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - - .to-summary__to { - margin: 0 $gap 0 0; - } - - .to-summary__expiration { - text-align: right; - flex-grow: 1; - - dl { - margin: 0 0 0 $gap; - - dd, - dt { - display: inline; - } - } - } - } - } - - .to-summary__expiration { - dl { - text-align: right; - margin-top: -2 * $gap; - - dd, - dt { - display: inline; - } - - dt { - font-size: $small-font-size; - text-transform: uppercase; - font-weight: $font-bold; - color: $color-gray-light; - } - - dd.ending-soon { - font-size: $h2-font-size; - white-space: nowrap; - - .icon { - @include icon-size(28); - } - } - } - - .icon-link { - margin: 0 (-$gap); - } - } - - .to-summary__co { - margin: ($gap * 2) 0 0 0; - - @include media($xlarge-screen) { - margin: 0; - } - } + &__meta { + &--remaining { + margin-left: auto; + text-align: right; + } + &-header { + @include small-copy; + margin-bottom: 0; + } + &-value { + margin-bottom: 0; + line-height: 1.2; } } } -} -.spend-table__month-select { - margin: 0; - flex: 1; -} - -table { - .spend-table__portfolio { - th, - td { - font-weight: bold; + .reporting-summary-item { + border-right: 1px solid $color-gray-light; + margin-right: $gap * 3; + padding-right: $gap * 3; + &:last-child { + border-right: none; + margin-right: 0; + padding-right: 0; } - } - - th, - td { - &.previous-month { - color: $color-gray; + &__header { + margin: 0; + &-icon { + margin: 0; + padding: 0; + } } - - &.meter-cell { - padding-left: 0; - position: relative; - min-width: 4rem; - - @include media($medium-screen) { - min-width: 12rem; - } - - meter { - width: 100%; - height: 3rem; - background: $color-white; - display: none; - - @include media($medium-screen) { - display: block; - } - - &::-webkit-meter-bar { - background: $color-white; - } - } - - .spend-table__meter-value { - @include h5; - - @include media($medium-screen) { - display: block; - color: $color-white; - background-color: rgba($color-blue, 0.65); - border-radius: $gap / 2; - position: absolute; - top: 2.3rem; - left: $gap / 2; - padding: 0 ($gap / 2); - } - } + &__value { + font-size: $lead-font-size; } } } diff --git a/templates/applications/fragments/member_form_fields.html b/templates/applications/fragments/member_form_fields.html index 97ab3832..20ecc210 100644 --- a/templates/applications/fragments/member_form_fields.html +++ b/templates/applications/fragments/member_form_fields.html @@ -66,8 +66,7 @@ + v-bind:optional="true">
{{ sub_form.role(**{"v-model": "value", "id": "{}-{}".format(sub_form.role.name, member_role_id)}) }}
diff --git a/templates/applications/fragments/members.html b/templates/applications/fragments/members.html index a8e8d80b..1a7bcb4a 100644 --- a/templates/applications/fragments/members.html +++ b/templates/applications/fragments/members.html @@ -94,61 +94,55 @@
- - - +
+ + + + + + + + + {% for member in members %} + {% set perms_modal = "edit_member-{}".format(loop.index) %} + {% set invite_pending = member.role_status == 'invite_pending' %} + {% set invite_expired = member.role_status == 'invite_expired' %} - - - - - - - {% for member in members %} - {% set perms_modal = "edit_member-{}".format(loop.index) %} - {% set invite_pending = member.role_status == 'invite_pending' %} - {% set invite_expired = member.role_status == 'invite_expired' %} - - + - - + - - {% endfor %} - -
{{ "common.name" | translate }}{{ "portfolios.applications.members.form.app_perms.title" | translate }}{{ 'portfolios.applications.members.form.env_access.table_header' | translate }}
{{ "common.name" | translate }}{{ "portfolios.applications.members.form.app_perms.title" | translate }}{{ 'portfolios.applications.members.form.env_access.table_header' | translate }}
- {{ member.user_name }} -
- {{ Label(type=member.role_status, classes='label--below') }} -
+ {{ member.user_name }} +
+ {{ Label(type=member.role_status, classes='label--below') }} +
- {% for perm, value in member.permission_sets.items() %} -
- {{ ("portfolios.applications.members.{}.{}".format(perm, value)) | translate }} -
- {% endfor %} -
- {% for env in member.environment_roles %} -
- - {{ env.environment_name }} - - - : {{ env.role }} - -
- {% endfor %} - {% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%} +
+ {% for perm, value in member.permission_sets.items() %} +
+ {{ ("portfolios.applications.members.{}.{}".format(perm, value)) | translate }} +
+ {% endfor %} +
+ {% for env in member.environment_roles %} +
+ + {{ env.environment_name }} + + + : {{ env.role }} + +
+ {% endfor %} + {% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%} +
- {% set toggle_button -%} + {{ Icon('ellipsis')}} - {%- endset %} - {% set section = "app_member_edit-{}".format(member.role_id) %} + + + {{ Icon('ellipsis')}} + - {{ - ToggleButton( - open_html=toggle_button, - close_html=toggle_button, - section_name=section, - active_style=True - ) - }} - {% call ToggleSection(section_name=section, classes="app-member-menu__toggle") %} +
{{ "portfolios.applications.members.menu.edit" | translate }} @@ -162,15 +156,15 @@ {{ 'invites.revoke' | translate }} {%- endif %} {%- endif %} - {% endcall %} +
- {%- endif %} -
-
+ + {%- endif %} + + + {% endfor %} + +
{% endif %} diff --git a/templates/components/accordion.html b/templates/components/accordion.html new file mode 100644 index 00000000..8e508321 --- /dev/null +++ b/templates/components/accordion.html @@ -0,0 +1,23 @@ +{% macro Accordion(title, id, heading_level="h2") %} + +
+ <{{heading_level}}> + + +
+ {{ caller() }} +
+
+
+{% endmacro %} \ No newline at end of file diff --git a/templates/components/clin_dollar_amount.html b/templates/components/clin_dollar_amount.html index dd8468dc..c39d3ef6 100644 --- a/templates/components/clin_dollar_amount.html +++ b/templates/components/clin_dollar_amount.html @@ -1,6 +1,6 @@ {% from 'components/icon.html' import Icon %} -{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%} +{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%}
+ validation="clinDollars">
{% if field %} -
- {%- endmacro %} \ No newline at end of file + {%- endmacro %} diff --git a/templates/components/clin_fields.html b/templates/components/clin_fields.html index 94ca2a5b..1117b89c 100644 --- a/templates/components/clin_fields.html +++ b/templates/components/clin_fields.html @@ -41,10 +41,9 @@
{% if fields %} - {{ TextInput(fields.number, watch=True, optional=False) }} + {{ TextInput(fields.number, optional=False) }} {% else %} - +