Merge branch 'staging' into azure-custom-integration
This commit is contained in:
commit
35eea8e31c
5
.gitignore
vendored
5
.gitignore
vendored
@ -31,6 +31,7 @@ static/buildinfo.*
|
||||
|
||||
# local log files
|
||||
log/*
|
||||
*.log
|
||||
|
||||
config/dev.ini
|
||||
.env*
|
||||
@ -74,3 +75,7 @@ celerybeat-schedule
|
||||
js/test_templates
|
||||
|
||||
.mypy_cache/
|
||||
|
||||
# terraform
|
||||
*.tfstate
|
||||
*.backup
|
||||
|
@ -3,7 +3,7 @@
|
||||
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
|
||||
"lines": null
|
||||
},
|
||||
"generated_at": "2020-01-09T16:55:07Z",
|
||||
"generated_at": "2020-01-19T20:21:20Z",
|
||||
"plugins_used": [
|
||||
{
|
||||
"base64_limit": 4.5,
|
||||
@ -32,13 +32,6 @@
|
||||
"is_verified": false,
|
||||
"line_number": 156,
|
||||
"type": "Secret Keyword"
|
||||
},
|
||||
{
|
||||
"hashed_secret": "81b127e2222d9bfc4609053faec85300f7525463",
|
||||
"is_secret": false,
|
||||
"is_verified": false,
|
||||
"line_number": 290,
|
||||
"type": "Secret Keyword"
|
||||
}
|
||||
],
|
||||
"alembic.ini": [
|
||||
@ -89,7 +82,7 @@
|
||||
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
|
||||
"is_secret": false,
|
||||
"is_verified": false,
|
||||
"line_number": 30,
|
||||
"line_number": 31,
|
||||
"type": "Secret Keyword"
|
||||
}
|
||||
],
|
||||
@ -152,7 +145,7 @@
|
||||
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
|
||||
"is_secret": false,
|
||||
"is_verified": false,
|
||||
"line_number": 665,
|
||||
"line_number": 649,
|
||||
"type": "Hex High Entropy String"
|
||||
}
|
||||
]
|
||||
|
@ -73,7 +73,8 @@ RUN apk update && \
|
||||
postgresql-client \
|
||||
postgresql-dev \
|
||||
postgresql-libs \
|
||||
uwsgi-logfile
|
||||
uwsgi-logfile \
|
||||
uwsgi-python3
|
||||
|
||||
COPY --from=builder /install/.venv/ ./.venv/
|
||||
COPY --from=builder /install/alembic/ ./alembic/
|
||||
|
48
README.md
48
README.md
@ -255,6 +255,7 @@ To generate coverage reports for the Javascript tests:
|
||||
- `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_COOKIE_DOMAIN`: String value specifying the name to use for the session cookie. This should be set to the root domain so that it is valid for both the main site and the authentication subdomain. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_DOMAIN
|
||||
- `SESSION_KEY_PREFIX`: A prefix that is added before all session keys: https://pythonhosted.org/Flask-Session/#configuration
|
||||
- `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.
|
||||
@ -362,50 +363,3 @@ fi
|
||||
|
||||
Also note that if the line number of a previously whitelisted secret changes, the whitelist file, `.secrets.baseline`, will be updated and needs to be committed.
|
||||
|
||||
## Local Kubernetes Setup
|
||||
|
||||
A modified version of the Kubernetes cluster can be deployed locally for
|
||||
testing and development purposes.
|
||||
|
||||
It is strongly recommended that you backup your local K8s config (usually
|
||||
`~/.kube/config`) before launching Minikube for the first time.
|
||||
|
||||
Before beginning:
|
||||
|
||||
- install the [Docker CLI](https://docs.docker.com/v17.12/install/)
|
||||
- install [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
|
||||
(this will also require installing a Hypervisor, such as VirtualBox)
|
||||
|
||||
### Setup
|
||||
|
||||
Run
|
||||
|
||||
```
|
||||
script/minikube_setup
|
||||
```
|
||||
|
||||
Once the script exits successfully, run
|
||||
|
||||
```
|
||||
minikube service list
|
||||
```
|
||||
|
||||
### Access the site
|
||||
|
||||
One of the two URLs given for the `atat-auth` service will load an HTTP version
|
||||
of the application.
|
||||
|
||||
For HTTP basic auth, the username and password are both `minikube`.
|
||||
|
||||
### Differences from the main config
|
||||
|
||||
As of the time of writing, this setup does not include the following:
|
||||
|
||||
- SSL/TLS or the complete DoD PKI
|
||||
- the cronjob for syncing CRLs and the peristent storage
|
||||
- production configuration
|
||||
|
||||
In order for the application to run, the K8s config for Minikube includes an
|
||||
additional deployment resource called `datastores`. This includes Postgres
|
||||
and Redis containers. It also includes hard-coded versions of the K8s secrets
|
||||
used in the regular clusters.
|
||||
|
@ -159,6 +159,7 @@ def map_config(config):
|
||||
"ENV": config["default"]["ENVIRONMENT"],
|
||||
"BROKER_URL": config["default"]["REDIS_URI"],
|
||||
"DEBUG": config["default"].getboolean("DEBUG"),
|
||||
"DEBUG_MAILER": config["default"].getboolean("DEBUG_MAILER"),
|
||||
"SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"),
|
||||
"PORT": int(config["default"]["PORT"]),
|
||||
"SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"],
|
||||
@ -221,7 +222,7 @@ def make_config(direct_config=None):
|
||||
config.read_dict({"default": direct_config})
|
||||
|
||||
# Assemble DATABASE_URI value
|
||||
database_uri = "postgres://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
|
||||
database_uri = "postgresql://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
|
||||
config.get("default", "PGUSER"),
|
||||
config.get("default", "PGPASSWORD"),
|
||||
config.get("default", "PGHOST"),
|
||||
@ -289,7 +290,7 @@ def make_crl_validator(app):
|
||||
|
||||
|
||||
def make_mailer(app):
|
||||
if app.config["DEBUG"]:
|
||||
if app.config["DEBUG"] or app.config["DEBUG_MAILER"]:
|
||||
mailer_connection = mailer.RedisConnection(app.redis)
|
||||
else:
|
||||
mailer_connection = mailer.SMTPConnection(
|
||||
|
@ -1,4 +1,13 @@
|
||||
from flask import g, redirect, url_for, session, request, current_app as app
|
||||
from flask import (
|
||||
g,
|
||||
redirect,
|
||||
url_for,
|
||||
session,
|
||||
request,
|
||||
current_app as app,
|
||||
_request_ctx_stack as request_ctx_stack,
|
||||
)
|
||||
from werkzeug.datastructures import ImmutableTypeConversionDict
|
||||
|
||||
from atst.domain.users import Users
|
||||
|
||||
@ -10,7 +19,6 @@ UNPROTECTED_ROUTES = [
|
||||
"atst.login_redirect",
|
||||
"atst.logout",
|
||||
"atst.unauthorized",
|
||||
"atst.helpdocs",
|
||||
"static",
|
||||
"atst.about",
|
||||
]
|
||||
@ -57,12 +65,26 @@ def get_last_login():
|
||||
return session.get("user_id") and session.get("last_login")
|
||||
|
||||
|
||||
def _nullify_session(session):
|
||||
session_key = f"{app.config.get('SESSION_KEY_PREFIX')}{session.sid}"
|
||||
app.redis.delete(session_key)
|
||||
request.cookies = ImmutableTypeConversionDict()
|
||||
request_ctx_stack.top.session = app.session_interface.open_session(app, request)
|
||||
|
||||
|
||||
def _current_dod_id():
|
||||
return g.current_user.dod_id if session.get("user_id") else None
|
||||
|
||||
|
||||
def logout():
|
||||
if session.get("user_id"): # pragma: no branch
|
||||
dod_id = g.current_user.dod_id
|
||||
del session["user_id"]
|
||||
del session["last_login"]
|
||||
dod_id = _current_dod_id()
|
||||
|
||||
_nullify_session(session)
|
||||
|
||||
if dod_id:
|
||||
app.logger.info(f"user with EDIPI {dod_id} has logged out")
|
||||
else:
|
||||
app.logger.info("unauthenticated user has logged out")
|
||||
|
||||
|
||||
def _unprotected_route(request):
|
||||
|
@ -490,6 +490,12 @@ class CloudProviderInterface:
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_subscription(self, environment):
|
||||
"""Returns True if a new subscription has been created or raises an
|
||||
exception if an error occurs while creating a subscription.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class MockCloudProvider(CloudProviderInterface):
|
||||
|
||||
@ -763,6 +769,11 @@ class MockCloudProvider(CloudProviderInterface):
|
||||
|
||||
return self._maybe(12)
|
||||
|
||||
def create_subscription(self, environment):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return True
|
||||
|
||||
def get_calculator_url(self):
|
||||
return "https://www.rackspace.com/en-us/calculator"
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
from .forms import BaseForm, remove_empty_string
|
||||
from wtforms.fields import StringField, TextAreaField, FieldList
|
||||
from wtforms.validators import Required, Optional
|
||||
from atst.forms.validators import ListItemRequired, ListItemsUnique
|
||||
from wtforms.validators import Required, Optional, Length
|
||||
from atst.forms.validators import ListItemRequired, ListItemsUnique, Name, AlphaNumeric
|
||||
from atst.utils.localization import translate
|
||||
|
||||
|
||||
class EditEnvironmentForm(BaseForm):
|
||||
name = StringField(
|
||||
label=translate("forms.environments.name_label"),
|
||||
validators=[Required()],
|
||||
validators=[Required(), Name(), Length(max=100)],
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
|
||||
@ -16,12 +16,12 @@ class EditEnvironmentForm(BaseForm):
|
||||
class NameAndDescriptionForm(BaseForm):
|
||||
name = StringField(
|
||||
label=translate("forms.application.name_label"),
|
||||
validators=[Required()],
|
||||
validators=[Required(), Name(), Length(max=100)],
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
description = TextAreaField(
|
||||
label=translate("forms.application.description_label"),
|
||||
validators=[Optional()],
|
||||
validators=[Optional(), Length(max=1_000)],
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
|
||||
@ -31,6 +31,7 @@ class EnvironmentsForm(BaseForm):
|
||||
StringField(
|
||||
label=translate("forms.application.environment_names_label"),
|
||||
filters=[remove_empty_string],
|
||||
validators=[AlphaNumeric(), Length(max=100)],
|
||||
),
|
||||
validators=[
|
||||
ListItemRequired(
|
||||
|
@ -1,5 +1,6 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms.fields import FormField, FieldList, HiddenField, BooleanField
|
||||
from wtforms.validators import UUID
|
||||
from wtforms import Form
|
||||
|
||||
from .member import NewForm as BaseNewMemberForm
|
||||
@ -7,11 +8,13 @@ from .data import ENV_ROLES, ENV_ROLE_NO_ACCESS as NO_ACCESS
|
||||
from atst.forms.fields import SelectField
|
||||
from atst.domain.permission_sets import PermissionSets
|
||||
from atst.utils.localization import translate
|
||||
from atst.forms.validators import AlphaNumeric
|
||||
from wtforms.validators import Length
|
||||
|
||||
|
||||
class EnvironmentForm(Form):
|
||||
environment_id = HiddenField()
|
||||
environment_name = HiddenField()
|
||||
environment_id = HiddenField(validators=[UUID()])
|
||||
environment_name = HiddenField(validators=[AlphaNumeric(), Length(max=100)])
|
||||
role = SelectField(
|
||||
environment_name,
|
||||
choices=ENV_ROLES,
|
||||
@ -43,13 +46,6 @@ class PermissionsForm(FlaskForm):
|
||||
"portfolios.applications.members.form.team_mgmt.description"
|
||||
),
|
||||
)
|
||||
perms_del_env = BooleanField(
|
||||
translate("portfolios.applications.members.form.del_env.label"),
|
||||
default=False,
|
||||
description=translate(
|
||||
"portfolios.applications.members.form.del_env.description"
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
@ -63,9 +59,6 @@ class PermissionsForm(FlaskForm):
|
||||
if _data["perms_team_mgmt"]:
|
||||
perm_sets.append(PermissionSets.EDIT_APPLICATION_TEAM)
|
||||
|
||||
if _data["perms_del_env"]:
|
||||
perm_sets.append(PermissionSets.DELETE_APPLICATION_ENVIRONMENTS)
|
||||
|
||||
_data["permission_sets"] = perm_sets
|
||||
return _data
|
||||
|
||||
|
@ -2,12 +2,12 @@ from flask_wtf import FlaskForm
|
||||
from wtforms.validators import Required, Length
|
||||
from wtforms.fields import StringField
|
||||
|
||||
from atst.forms.validators import IsNumber
|
||||
from atst.forms.validators import Number
|
||||
from atst.utils.localization import translate
|
||||
|
||||
|
||||
class CCPOUserForm(FlaskForm):
|
||||
dod_id = StringField(
|
||||
translate("forms.new_member.dod_id_label"),
|
||||
validators=[Required(), Length(min=10, max=10), IsNumber()],
|
||||
validators=[Required(), Length(min=10, max=10), Number()],
|
||||
)
|
||||
|
@ -9,22 +9,26 @@ from .forms import BaseForm
|
||||
from .data import SERVICE_BRANCHES
|
||||
from atst.models.user import User
|
||||
from atst.utils.localization import translate
|
||||
from wtforms.validators import Length
|
||||
from atst.forms.validators import Number
|
||||
|
||||
from .validators import Name, DateRange, PhoneNumber
|
||||
|
||||
|
||||
USER_FIELDS = {
|
||||
"first_name": StringField(
|
||||
translate("forms.edit_user.first_name_label"), validators=[Name()]
|
||||
translate("forms.edit_user.first_name_label"),
|
||||
validators=[Name(), Length(max=100)],
|
||||
),
|
||||
"last_name": StringField(
|
||||
translate("forms.edit_user.last_name_label"), validators=[Name()]
|
||||
translate("forms.edit_user.last_name_label"),
|
||||
validators=[Name(), Length(max=100)],
|
||||
),
|
||||
"email": EmailField(translate("forms.edit_user.email_label"), validators=[Email()]),
|
||||
"phone_number": TelField(
|
||||
translate("forms.edit_user.phone_number_label"), validators=[PhoneNumber()]
|
||||
),
|
||||
"phone_ext": StringField("Extension"),
|
||||
"phone_ext": StringField("Extension", validators=[Number(), Length(max=10)]),
|
||||
"service_branch": SelectField(
|
||||
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
|
||||
),
|
||||
|
@ -3,16 +3,18 @@ from wtforms.fields.html5 import EmailField, TelField
|
||||
from wtforms.validators import Required, Email, Length, Optional
|
||||
from wtforms.fields import StringField
|
||||
|
||||
from atst.forms.validators import IsNumber, PhoneNumber
|
||||
from atst.forms.validators import Number, PhoneNumber, Name
|
||||
from atst.utils.localization import translate
|
||||
|
||||
|
||||
class NewForm(FlaskForm):
|
||||
first_name = StringField(
|
||||
label=translate("forms.new_member.first_name_label"), validators=[Required()]
|
||||
label=translate("forms.new_member.first_name_label"),
|
||||
validators=[Required(), Name(), Length(max=100)],
|
||||
)
|
||||
last_name = StringField(
|
||||
label=translate("forms.new_member.last_name_label"), validators=[Required()]
|
||||
label=translate("forms.new_member.last_name_label"),
|
||||
validators=[Required(), Name(), Length(max=100)],
|
||||
)
|
||||
email = EmailField(
|
||||
translate("forms.new_member.email_label"), validators=[Required(), Email()]
|
||||
@ -21,8 +23,8 @@ class NewForm(FlaskForm):
|
||||
translate("forms.new_member.phone_number_label"),
|
||||
validators=[Optional(), PhoneNumber()],
|
||||
)
|
||||
phone_ext = StringField("Extension")
|
||||
phone_ext = StringField("Extension", validators=[Number(), Length(max=10)])
|
||||
dod_id = StringField(
|
||||
translate("forms.new_member.dod_id_label"),
|
||||
validators=[Required(), Length(min=10), IsNumber()],
|
||||
validators=[Required(), Length(min=10), Number()],
|
||||
)
|
||||
|
@ -4,6 +4,7 @@ from wtforms.fields import (
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import Length, InputRequired
|
||||
from atst.forms.validators import Name
|
||||
from wtforms.widgets import ListWidget, CheckboxInput
|
||||
|
||||
from .forms import BaseForm
|
||||
@ -20,14 +21,18 @@ class PortfolioForm(BaseForm):
|
||||
min=4,
|
||||
max=100,
|
||||
message=translate("forms.portfolio.name.length_validation_message"),
|
||||
)
|
||||
),
|
||||
Name(),
|
||||
],
|
||||
)
|
||||
description = TextAreaField(translate("forms.portfolio.description.label"),)
|
||||
description = TextAreaField(
|
||||
translate("forms.portfolio.description.label"), validators=[Length(max=1_000)]
|
||||
)
|
||||
|
||||
|
||||
class PortfolioCreationForm(PortfolioForm):
|
||||
defense_component = SelectMultipleField(
|
||||
translate("forms.portfolio.defense_component.title"),
|
||||
choices=SERVICE_BRANCHES,
|
||||
widget=ListWidget(prefix_label=False),
|
||||
option_widget=CheckboxInput(),
|
||||
|
@ -7,9 +7,15 @@ from wtforms.fields import (
|
||||
HiddenField,
|
||||
)
|
||||
from wtforms.fields.html5 import DateField
|
||||
from wtforms.validators import Required, Length, NumberRange, ValidationError
|
||||
from wtforms.validators import (
|
||||
Required,
|
||||
Length,
|
||||
NumberRange,
|
||||
ValidationError,
|
||||
)
|
||||
from flask_wtf import FlaskForm
|
||||
from numbers import Number
|
||||
import numbers
|
||||
from atst.forms.validators import Number, AlphaNumeric
|
||||
|
||||
from .data import JEDI_CLIN_TYPES
|
||||
from .fields import SelectField
|
||||
@ -17,7 +23,7 @@ from .forms import BaseForm, remove_empty_string
|
||||
from atst.utils.localization import translate
|
||||
from flask import current_app as app
|
||||
|
||||
MAX_CLIN_AMOUNT = 1000000000
|
||||
MAX_CLIN_AMOUNT = 1_000_000_000
|
||||
|
||||
|
||||
def coerce_enum(enum_inst):
|
||||
@ -29,8 +35,8 @@ def coerce_enum(enum_inst):
|
||||
|
||||
def validate_funding(form, field):
|
||||
if (
|
||||
isinstance(form.total_amount.data, Number)
|
||||
and isinstance(field.data, Number)
|
||||
isinstance(form.total_amount.data, numbers.Number)
|
||||
and isinstance(field.data, numbers.Number)
|
||||
and form.total_amount.data < field.data
|
||||
):
|
||||
raise ValidationError(
|
||||
@ -61,7 +67,10 @@ class CLINForm(FlaskForm):
|
||||
coerce=coerce_enum,
|
||||
)
|
||||
|
||||
number = StringField(label=translate("task_orders.form.clin_number_label"))
|
||||
number = StringField(
|
||||
label=translate("task_orders.form.clin_number_label"),
|
||||
validators=[Number(), Length(max=4)],
|
||||
)
|
||||
start_date = DateField(
|
||||
translate("task_orders.form.pop_start"),
|
||||
description=translate("task_orders.form.pop_example"),
|
||||
@ -116,7 +125,10 @@ class AttachmentForm(BaseForm):
|
||||
filename = HiddenField(
|
||||
id="attachment_filename",
|
||||
validators=[
|
||||
Length(max=100, message=translate("forms.attachment.filename.length_error"))
|
||||
Length(
|
||||
max=100, message=translate("forms.attachment.filename.length_error")
|
||||
),
|
||||
AlphaNumeric(),
|
||||
],
|
||||
)
|
||||
object_name = HiddenField(
|
||||
@ -124,7 +136,8 @@ class AttachmentForm(BaseForm):
|
||||
validators=[
|
||||
Length(
|
||||
max=40, message=translate("forms.attachment.object_name.length_error")
|
||||
)
|
||||
),
|
||||
AlphaNumeric(),
|
||||
],
|
||||
)
|
||||
accept = ".pdf,application/pdf"
|
||||
@ -137,6 +150,7 @@ class TaskOrderForm(BaseForm):
|
||||
number = StringField(
|
||||
label=translate("forms.task_order.number_description"),
|
||||
filters=[remove_empty_string],
|
||||
validators=[Number(), Length(max=13)],
|
||||
)
|
||||
pdf = FormField(
|
||||
AttachmentForm,
|
||||
|
@ -2,7 +2,7 @@ from datetime import datetime
|
||||
import re
|
||||
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from wtforms.validators import ValidationError
|
||||
from wtforms.validators import ValidationError, Regexp
|
||||
import pendulum
|
||||
|
||||
from atst.utils.localization import translate
|
||||
@ -31,12 +31,13 @@ def DateRange(lower_bound=None, upper_bound=None, message=None):
|
||||
return _date_range
|
||||
|
||||
|
||||
def IsNumber(message=translate("forms.validators.is_number_message")):
|
||||
def Number(message=translate("forms.validators.is_number_message")):
|
||||
def _is_number(form, field):
|
||||
try:
|
||||
int(field.data)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(message)
|
||||
if field.data:
|
||||
try:
|
||||
int(field.data)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(message)
|
||||
|
||||
return _is_number
|
||||
|
||||
@ -97,3 +98,7 @@ def FileLength(max_length=50000000, message=None):
|
||||
field.data.seek(0)
|
||||
|
||||
return _file_length
|
||||
|
||||
|
||||
def AlphaNumeric(message=translate("forms.validators.alpha_numeric_message")):
|
||||
return Regexp(regex=r"^[A-Za-z0-9\-_ \.]*$", message=message)
|
||||
|
@ -42,29 +42,11 @@ def root():
|
||||
return render_template("login.html", redirect_url=redirect_url)
|
||||
|
||||
|
||||
@bp.route("/help")
|
||||
@bp.route("/help/<path:doc>")
|
||||
def helpdocs(doc=None):
|
||||
docs = [os.path.splitext(file)[0] for file in os.listdir("templates/help/docs")]
|
||||
if doc:
|
||||
return render_template("help/docs/{}.html".format(doc), docs=docs, doc=doc)
|
||||
else:
|
||||
return render_template("help/index.html", docs=docs, doc=doc)
|
||||
|
||||
|
||||
@bp.route("/home")
|
||||
def home():
|
||||
return render_template("home.html")
|
||||
|
||||
|
||||
@bp.route("/<path:path>")
|
||||
def catch_all(path):
|
||||
try:
|
||||
return render_template("{}.html".format(path))
|
||||
except TemplateNotFound:
|
||||
raise NotFound()
|
||||
|
||||
|
||||
def _client_s_dn():
|
||||
return request.environ.get("HTTP_X_SSL_CLIENT_S_DN")
|
||||
|
||||
|
@ -26,7 +26,7 @@ def has_portfolio_applications(_user, portfolio=None, **_kwargs):
|
||||
def portfolio_applications(portfolio_id):
|
||||
user_env_roles = EnvironmentRoles.for_user(g.current_user.id, portfolio_id)
|
||||
environment_access = {
|
||||
env_role.environment_id: env_role.role for env_role in user_env_roles
|
||||
env_role.environment_id: env_role.role.value for env_role in user_env_roles
|
||||
}
|
||||
|
||||
return render_template(
|
||||
|
@ -1,9 +1,10 @@
|
||||
from flask import (
|
||||
current_app as app,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request as http_request,
|
||||
url_for,
|
||||
g,
|
||||
)
|
||||
|
||||
from .blueprint import applications_bp
|
||||
@ -64,9 +65,6 @@ def filter_perm_sets_data(member):
|
||||
"perms_env_mgmt": bool(
|
||||
member.has_permission_set(PermissionSets.EDIT_APPLICATION_ENVIRONMENTS)
|
||||
),
|
||||
"perms_del_env": bool(
|
||||
member.has_permission_set(PermissionSets.DELETE_APPLICATION_ENVIRONMENTS)
|
||||
),
|
||||
}
|
||||
|
||||
return perm_sets_data
|
||||
@ -509,11 +507,7 @@ def resend_invite(application_id, application_role_id):
|
||||
token=new_invite.token,
|
||||
)
|
||||
|
||||
flash(
|
||||
"application_invite_resent",
|
||||
user_name=new_invite.user_name,
|
||||
application_name=app_role.application.name,
|
||||
)
|
||||
flash("application_invite_resent", email=new_invite.email)
|
||||
else:
|
||||
flash(
|
||||
"application_invite_error",
|
||||
@ -529,3 +523,31 @@ def resend_invite(application_id, application_role_id):
|
||||
_anchor="application-members",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@applications_bp.route(
|
||||
"/environments/<environment_id>/add_subscription", methods=["POST"]
|
||||
)
|
||||
@user_can(Permissions.EDIT_ENVIRONMENT, message="create new environment subscription")
|
||||
def create_subscription(environment_id):
|
||||
environment = Environments.get(environment_id)
|
||||
|
||||
try:
|
||||
app.csp.cloud.create_subscription(environment)
|
||||
flash("environment_subscription_success", name=environment.displayname)
|
||||
|
||||
except GeneralCSPException:
|
||||
flash("environment_subscription_failure")
|
||||
return (
|
||||
render_settings_page(application=environment.application, show_flash=True),
|
||||
400,
|
||||
)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"applications.settings",
|
||||
application_id=environment.application.id,
|
||||
fragment="application-environments",
|
||||
_anchor="application-environments",
|
||||
)
|
||||
)
|
||||
|
@ -19,9 +19,6 @@ from atst.domain.exceptions import UnauthorizedError
|
||||
|
||||
def filter_perm_sets_data(member):
|
||||
perm_sets_data = {
|
||||
"perms_portfolio_mgmt": bool(
|
||||
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN)
|
||||
),
|
||||
"perms_app_mgmt": bool(
|
||||
member.has_permission_set(
|
||||
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
|
||||
@ -33,24 +30,43 @@ def filter_perm_sets_data(member):
|
||||
"perms_reporting": bool(
|
||||
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_REPORTS)
|
||||
),
|
||||
"perms_portfolio_mgmt": bool(
|
||||
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN)
|
||||
),
|
||||
}
|
||||
|
||||
return perm_sets_data
|
||||
|
||||
|
||||
def filter_members_data(members_list, portfolio):
|
||||
def filter_members_data(members_list):
|
||||
members_data = []
|
||||
for member in members_list:
|
||||
members_data.append(
|
||||
{
|
||||
"role_id": member.id,
|
||||
"user_name": member.user_name,
|
||||
"permission_sets": filter_perm_sets_data(member),
|
||||
"status": member.display_status,
|
||||
"ppoc": PermissionSets.PORTFOLIO_POC in member.permission_sets,
|
||||
# add in stuff here for forms
|
||||
}
|
||||
permission_sets = filter_perm_sets_data(member)
|
||||
ppoc = (
|
||||
PermissionSets.get(PermissionSets.PORTFOLIO_POC) in member.permission_sets
|
||||
)
|
||||
member_data = {
|
||||
"role_id": member.id,
|
||||
"user_name": member.user_name,
|
||||
"permission_sets": filter_perm_sets_data(member),
|
||||
"status": member.display_status,
|
||||
"ppoc": ppoc,
|
||||
"form": member_forms.PermissionsForm(permission_sets),
|
||||
}
|
||||
|
||||
if not ppoc:
|
||||
member_data["update_invite_form"] = (
|
||||
member_forms.NewForm(user_data=member.latest_invitation)
|
||||
if member.latest_invitation and member.latest_invitation.can_resend
|
||||
else member_forms.NewForm()
|
||||
)
|
||||
member_data["invite_token"] = (
|
||||
member.latest_invitation.token
|
||||
if member.latest_invitation and member.latest_invitation.can_resend
|
||||
else None
|
||||
)
|
||||
|
||||
members_data.append(member_data)
|
||||
|
||||
return sorted(members_data, key=lambda member: member["user_name"])
|
||||
|
||||
@ -75,7 +91,7 @@ def render_admin_page(portfolio, form=None):
|
||||
"portfolios/admin.html",
|
||||
form=form,
|
||||
portfolio_form=portfolio_form,
|
||||
members=filter_members_data(member_list, portfolio),
|
||||
members=filter_members_data(member_list),
|
||||
new_manager_form=member_forms.NewForm(),
|
||||
assign_ppoc_form=assign_ppoc_form,
|
||||
portfolio=portfolio,
|
||||
@ -93,26 +109,27 @@ def admin(portfolio_id):
|
||||
return render_admin_page(portfolio)
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios/<portfolio_id>/update_ppoc", methods=["POST"])
|
||||
@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc")
|
||||
def update_ppoc(portfolio_id):
|
||||
role_id = http_request.form.get("role_id")
|
||||
|
||||
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||
new_ppoc_role = PortfolioRoles.get_by_id(role_id)
|
||||
|
||||
PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role)
|
||||
|
||||
flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"portfolios.admin",
|
||||
portfolio_id=portfolio.id,
|
||||
fragment="primary-point-of-contact",
|
||||
_anchor="primary-point-of-contact",
|
||||
)
|
||||
)
|
||||
# Updating PPoC is a post-MVP feature
|
||||
# @portfolios_bp.route("/portfolios/<portfolio_id>/update_ppoc", methods=["POST"])
|
||||
# @user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc")
|
||||
# def update_ppoc(portfolio_id): # pragma: no cover
|
||||
# role_id = http_request.form.get("role_id")
|
||||
#
|
||||
# portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||
# new_ppoc_role = PortfolioRoles.get_by_id(role_id)
|
||||
#
|
||||
# PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role)
|
||||
#
|
||||
# flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name)
|
||||
#
|
||||
# return redirect(
|
||||
# url_for(
|
||||
# "portfolios.admin",
|
||||
# portfolio_id=portfolio.id,
|
||||
# fragment="primary-point-of-contact",
|
||||
# _anchor="primary-point-of-contact",
|
||||
# )
|
||||
# )
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
|
||||
@ -166,3 +183,30 @@ def remove_member(portfolio_id, portfolio_role_id):
|
||||
fragment="portfolio-members",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@portfolios_bp.route(
|
||||
"/portfolios/<portfolio_id>/members/<portfolio_role_id>", methods=["POST"]
|
||||
)
|
||||
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members")
|
||||
def update_member(portfolio_id, portfolio_role_id):
|
||||
form_data = http_request.form
|
||||
form = member_forms.PermissionsForm(formdata=form_data)
|
||||
portfolio_role = PortfolioRoles.get_by_id(portfolio_role_id)
|
||||
portfolio = Portfolios.get(user=g.current_user, portfolio_id=portfolio_id)
|
||||
|
||||
if form.validate() and portfolio.owner_role != portfolio_role:
|
||||
PortfolioRoles.update(portfolio_role, form.data["permission_sets"])
|
||||
flash("update_portfolio_member", member_name=portfolio_role.full_name)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"portfolios.admin",
|
||||
portfolio_id=portfolio_id,
|
||||
_anchor="portfolio-members",
|
||||
fragment="portfolio-members",
|
||||
)
|
||||
)
|
||||
else:
|
||||
flash("update_portfolio_member_error", member_name=portfolio_role.full_name)
|
||||
return (render_admin_page(portfolio), 400)
|
||||
|
@ -54,13 +54,22 @@ def revoke_invitation(portfolio_id, portfolio_token):
|
||||
)
|
||||
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation")
|
||||
def resend_invitation(portfolio_id, portfolio_token):
|
||||
invite = PortfolioInvitations.resend(g.current_user, portfolio_token)
|
||||
send_portfolio_invitation(
|
||||
invitee_email=invite.email,
|
||||
inviter_name=g.current_user.full_name,
|
||||
token=invite.token,
|
||||
)
|
||||
flash("resend_portfolio_invitation", user_name=invite.user_name)
|
||||
form = member_forms.NewForm(http_request.form)
|
||||
|
||||
if form.validate():
|
||||
invite = PortfolioInvitations.resend(
|
||||
g.current_user, portfolio_token, form.data["user_data"]
|
||||
)
|
||||
send_portfolio_invitation(
|
||||
invitee_email=invite.email,
|
||||
inviter_name=g.current_user.full_name,
|
||||
token=invite.token,
|
||||
)
|
||||
flash("resend_portfolio_invitation", email=invite.email)
|
||||
else:
|
||||
user_name = f"{form['user_data']['first_name'].data} {form['user_data']['last_name'].data}"
|
||||
flash("resend_portfolio_invitation_error", user_name=user_name)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"portfolios.admin",
|
||||
|
@ -29,7 +29,7 @@ MESSAGES = {
|
||||
"category": "error",
|
||||
},
|
||||
"application_invite_resent": {
|
||||
"title": "flash.application_invite.resent.title",
|
||||
"title": None,
|
||||
"message": "flash.application_invite.resent.message",
|
||||
"category": "success",
|
||||
},
|
||||
@ -83,6 +83,16 @@ MESSAGES = {
|
||||
"message": "flash.environment.deleted.message",
|
||||
"category": "success",
|
||||
},
|
||||
"environment_subscription_failure": {
|
||||
"title": "flash.environment.subscription_failure.title",
|
||||
"message": "flash.environment.subscription_failure.message",
|
||||
"category": "error",
|
||||
},
|
||||
"environment_subscription_success": {
|
||||
"title": "flash.environment.subscription_success.title",
|
||||
"message": "flash.environment.subscription_success.message",
|
||||
"category": "success",
|
||||
},
|
||||
"form_errors": {
|
||||
"title": "flash.form.errors.title",
|
||||
"message": "flash.form.errors.message",
|
||||
@ -90,7 +100,7 @@ MESSAGES = {
|
||||
},
|
||||
"insufficient_funds": {
|
||||
"title": "flash.task_order.insufficient_funds.title",
|
||||
"message": "",
|
||||
"message": None,
|
||||
"category": "warning",
|
||||
},
|
||||
"logged_out": {
|
||||
@ -109,8 +119,8 @@ MESSAGES = {
|
||||
"category": "success",
|
||||
},
|
||||
"new_portfolio_member": {
|
||||
"title": "flash.success",
|
||||
"message": "flash.new_portfolio_member",
|
||||
"title": "flash.new_portfolio_member.title",
|
||||
"message": "flash.new_portfolio_member.message",
|
||||
"category": "success",
|
||||
},
|
||||
"portfolio_member_removed": {
|
||||
@ -124,10 +134,15 @@ MESSAGES = {
|
||||
"category": "success",
|
||||
},
|
||||
"resend_portfolio_invitation": {
|
||||
"title": "flash.portfolio_invite.resent.title",
|
||||
"title": None,
|
||||
"message": "flash.portfolio_invite.resent.message",
|
||||
"category": "success",
|
||||
},
|
||||
"resend_portfolio_invitation_error": {
|
||||
"title": "flash.portfolio_invite.error.title",
|
||||
"message": "flash.portfolio_invite.error.message",
|
||||
"category": "error",
|
||||
},
|
||||
"revoked_portfolio_access": {
|
||||
"title": "flash.portfolio_member.revoked.title",
|
||||
"message": "flash.portfolio_member.revoked.message",
|
||||
@ -153,6 +168,16 @@ MESSAGES = {
|
||||
"message": "flash.task_order.submitted.message",
|
||||
"category": "success",
|
||||
},
|
||||
"update_portfolio_member": {
|
||||
"title": "flash.portfolio_member.update.title",
|
||||
"message": "flash.portfolio_member.update.message",
|
||||
"category": "success",
|
||||
},
|
||||
"update_portfolio_member_error": {
|
||||
"title": "flash.portfolio_member.update_error.title",
|
||||
"message": "flash.portfolio_member.update_error.message",
|
||||
"category": "error",
|
||||
},
|
||||
"updated_application_team_settings": {
|
||||
"title": "flash.success",
|
||||
"message": "flash.updated_application_team_settings",
|
||||
|
@ -4,6 +4,7 @@ from atst.domain.users import Users
|
||||
class SessionLimiter(object):
|
||||
def __init__(self, config, session, redis):
|
||||
self.limit_logins = config["LIMIT_CONCURRENT_SESSIONS"]
|
||||
self.session_prefix = config.get("SESSION_KEY_PREFIX", "session:")
|
||||
self.session = session
|
||||
self.redis = redis
|
||||
|
||||
@ -16,4 +17,4 @@ class SessionLimiter(object):
|
||||
Users.update_last_session_id(user, session_id)
|
||||
|
||||
def _delete_session(self, session_id):
|
||||
self.redis.delete("session:{}".format(session_id))
|
||||
self.redis.delete(f"{self.session_prefix}{session_id}")
|
||||
|
@ -15,6 +15,7 @@ CRL_FAIL_OPEN = false
|
||||
CRL_STORAGE_CONTAINER = crls
|
||||
CSP=mock
|
||||
DEBUG = true
|
||||
DEBUG_MAILER = false
|
||||
DISABLE_CRL_CHECK = false
|
||||
ENVIRONMENT = dev
|
||||
LIMIT_CONCURRENT_SESSIONS = false
|
||||
@ -41,6 +42,7 @@ SECRET_KEY = change_me_into_something_secret
|
||||
SERVER_NAME
|
||||
SESSION_COOKIE_NAME=atat
|
||||
SESSION_COOKIE_DOMAIN
|
||||
SESSION_KEY_PREFIX=session:
|
||||
SESSION_TYPE = redis
|
||||
SESSION_USE_SIGNER = True
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
@ -15,6 +15,7 @@ data:
|
||||
CSP: azure
|
||||
DEBUG: "0"
|
||||
FLASK_ENV: master
|
||||
LIMIT_CONCURRENT_SESSIONS: "true"
|
||||
LOG_JSON: "true"
|
||||
MAIL_PORT: "587"
|
||||
MAIL_SENDER: postmaster@atat.code.mil
|
||||
|
40
deploy/azure/autoscaling.yml
Normal file
40
deploy/azure/autoscaling.yml
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst
|
||||
namespace: atat
|
||||
spec:
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: atst
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
targetAverageUtilization: 60
|
||||
---
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst-worker
|
||||
namespace: atat
|
||||
spec:
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: atst-worker
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
targetAverageUtilization: 60
|
@ -15,7 +15,6 @@ spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
role: web
|
||||
replicas: 4
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
@ -30,6 +29,13 @@ spec:
|
||||
containers:
|
||||
- name: atst
|
||||
image: $CONTAINER_IMAGE
|
||||
env:
|
||||
- name: UWSGI_PROCESSES
|
||||
value: "2"
|
||||
- name: UWSGI_THREADS
|
||||
value: "2"
|
||||
- name: UWSGI_ENABLE_THREADS
|
||||
value: "1"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
@ -51,11 +57,11 @@ spec:
|
||||
mountPath: "/config"
|
||||
resources:
|
||||
requests:
|
||||
memory: 200Mi
|
||||
cpu: 400m
|
||||
memory: 400Mi
|
||||
cpu: 940m
|
||||
limits:
|
||||
memory: 200Mi
|
||||
cpu: 400m
|
||||
memory: 400Mi
|
||||
cpu: 940m
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
@ -87,10 +93,10 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
memory: 20Mi
|
||||
cpu: 10m
|
||||
cpu: 25m
|
||||
limits:
|
||||
memory: 20Mi
|
||||
cpu: 10m
|
||||
cpu: 25m
|
||||
volumes:
|
||||
- name: nginx-client-ca-bundle
|
||||
configMap:
|
||||
@ -169,7 +175,6 @@ spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
role: worker
|
||||
replicas: 2
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
@ -207,10 +212,10 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
memory: 280Mi
|
||||
cpu: 20m
|
||||
cpu: 400m
|
||||
limits:
|
||||
memory: 280Mi
|
||||
cpu: 20m
|
||||
cpu: 400m
|
||||
volumes:
|
||||
- name: pgsslrootcert
|
||||
configMap:
|
||||
@ -311,6 +316,7 @@ metadata:
|
||||
namespace: atat
|
||||
spec:
|
||||
loadBalancerIP: 13.92.235.6
|
||||
externalTrafficPolicy: Local
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8342
|
||||
@ -331,6 +337,7 @@ metadata:
|
||||
namespace: atat
|
||||
spec:
|
||||
loadBalancerIP: 23.100.24.41
|
||||
externalTrafficPolicy: Local
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8343
|
||||
|
@ -12,3 +12,4 @@ resources:
|
||||
- acme-challenges.yml
|
||||
- aadpodidentity.yml
|
||||
- nginx-snippets.yml
|
||||
- autoscaling.yml
|
||||
|
@ -10,6 +10,7 @@ data:
|
||||
callable = app
|
||||
module = app
|
||||
socket = /var/run/uwsgi/uwsgi.socket
|
||||
plugins-dir = /usr/lib/uwsgi
|
||||
plugin = python3
|
||||
plugin = logfile
|
||||
virtualenv = /opt/atat/atst/.venv
|
||||
|
@ -1,35 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: atst-config
|
||||
namespace: atat
|
||||
data:
|
||||
uwsgi-config: |-
|
||||
[uwsgi]
|
||||
callable = app
|
||||
module = app
|
||||
socket = /var/run/uwsgi/uwsgi.socket
|
||||
plugin = python3
|
||||
plugin = logfile
|
||||
virtualenv = /opt/atat/atst/.venv
|
||||
chmod-socket = 666
|
||||
|
||||
; logger config
|
||||
|
||||
; application logs: log without modifying
|
||||
logger = secondlogger stdio
|
||||
log-route = secondlogger atst
|
||||
log-encoder = format:secondlogger ${msg}
|
||||
|
||||
; default uWSGI messages (start, stop, etc.)
|
||||
logger = default stdio
|
||||
log-route = default ^((?!atst).)*$
|
||||
log-encoder = json:default {"timestamp":"${strftime:%%FT%%T}","source":"uwsgi","severity":"DEBUG","message":"${msg}"}
|
||||
log-encoder = nl
|
||||
|
||||
; uWSGI request logs
|
||||
logger-req = stdio
|
||||
log-format = request_id=%(var.HTTP_X_REQUEST_ID), pid=%(pid), remote_add=%(addr), request=%(method) %(uri), status=%(status), body_bytes_sent=%(rsize), referer=%(referer), user_agent=%(uagent), http_x_forwarded_for=%(var.HTTP_X_FORWARDED_FOR)
|
||||
log-req-encoder = json {"timestamp":"${strftime:%%FT%%T}","source":"req","severity":"INFO","message":"${msg}"}
|
||||
log-req-encoder = nl
|
@ -1,15 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: atst-envvars
|
||||
namespace: atat
|
||||
data:
|
||||
TZ: UTC
|
||||
FLASK_ENV: dev
|
||||
OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini
|
||||
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini
|
||||
CRL_STORAGE_PROVIDER: CLOUDFILES
|
||||
LOG_JSON: "true"
|
||||
REDIS_URI: "redis://redis-svc:6379"
|
||||
PGHOST: postgres-svc
|
@ -1,73 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: atst-nginx
|
||||
namespace: atat
|
||||
data:
|
||||
nginx-config: |-
|
||||
server {
|
||||
listen 8342;
|
||||
server_name aws.atat.code.mil;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8343;
|
||||
server_name auth-aws.atat.code.mil;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
server {
|
||||
server_name aws.atat.code.mil;
|
||||
# access_log /var/log/nginx/access.log json;
|
||||
listen 8442;
|
||||
location /login-redirect {
|
||||
return 301 https://auth-aws.atat.code.mil$request_uri;
|
||||
}
|
||||
location /login-dev {
|
||||
try_files $uri @appbasicauth;
|
||||
}
|
||||
location / {
|
||||
try_files $uri @app;
|
||||
}
|
||||
location @app {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
|
||||
uwsgi_param HTTP_X_REQUEST_ID $request_id;
|
||||
}
|
||||
location @appbasicauth {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
|
||||
auth_basic "Developer Access";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
uwsgi_param HTTP_X_REQUEST_ID $request_id;
|
||||
}
|
||||
}
|
||||
server {
|
||||
# access_log /var/log/nginx/access.log json;
|
||||
server_name auth-aws.atat.code.mil;
|
||||
listen 8443;
|
||||
listen [::]:8443 ipv6only=on;
|
||||
# Request and validate client certificate
|
||||
ssl_verify_client on;
|
||||
ssl_verify_depth 10;
|
||||
ssl_client_certificate /etc/ssl/client-ca-bundle.pem;
|
||||
# Guard against HTTPS -> HTTP downgrade
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always";
|
||||
location / {
|
||||
return 301 https://aws.atat.code.mil$request_uri;
|
||||
}
|
||||
location /login-redirect {
|
||||
try_files $uri @app;
|
||||
}
|
||||
location @app {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_VERIFY $ssl_client_verify;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_CERT $ssl_client_raw_cert;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_S_DN $ssl_client_s_dn;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_S_DN_LEGACY $ssl_client_s_dn_legacy;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_I_DN $ssl_client_i_dn;
|
||||
uwsgi_param HTTP_X_SSL_CLIENT_I_DN_LEGACY $ssl_client_i_dn_legacy;
|
||||
uwsgi_param HTTP_X_REQUEST_ID $request_id;
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: atst-worker-envvars
|
||||
namespace: atat
|
||||
data:
|
||||
TZ: UTC
|
||||
DISABLE_CRL_CHECK: "True"
|
||||
CRL_STORAGE_PROVIDER: CLOUDFILES
|
||||
REDIS_URI: "redis://redis-svc:6379"
|
||||
PGHOST: postgres-svc
|
@ -1,61 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: db-cache
|
||||
name: datastores
|
||||
namespace: atat
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: db-cache
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: db-cache
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:11-alpine
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
- name: redis
|
||||
image: redis:5.0-alpine
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-svc
|
||||
namespace: atat
|
||||
spec:
|
||||
ports:
|
||||
- name: db-port
|
||||
protocol: "TCP"
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
selector:
|
||||
app: db-cache
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-svc
|
||||
namespace: atat
|
||||
spec:
|
||||
ports:
|
||||
- name: cache-port
|
||||
protocol: "TCP"
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
selector:
|
||||
app: db-cache
|
@ -1,232 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst
|
||||
namespace: atat
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
role: web
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: web
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: atst
|
||||
image: atat:latest
|
||||
imagePullPolicy: Never
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: nginx-client-ca-bundle
|
||||
mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem"
|
||||
subPath: client-ca-bundle.pem
|
||||
- name: uwsgi-socket-dir
|
||||
mountPath: "/var/run/uwsgi"
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8342
|
||||
name: main-upgrade
|
||||
- containerPort: 8442
|
||||
name: main
|
||||
- containerPort: 8343
|
||||
name: auth-upgrade
|
||||
- containerPort: 8443
|
||||
name: auth
|
||||
volumeMounts:
|
||||
- name: nginx-config
|
||||
mountPath: "/etc/nginx/conf.d/atst.conf"
|
||||
subPath: atst.conf
|
||||
- name: uwsgi-socket-dir
|
||||
mountPath: "/var/run/uwsgi"
|
||||
- name: nginx-htpasswd
|
||||
mountPath: "/etc/nginx/.htpasswd"
|
||||
subPath: .htpasswd
|
||||
- name: nginx-client-ca-bundle
|
||||
mountPath: "/etc/ssl/"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: nginx-client-ca-bundle
|
||||
configMap:
|
||||
name: nginx-client-ca-bundle
|
||||
defaultMode: 0666
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: atst-nginx
|
||||
items:
|
||||
- key: nginx-config
|
||||
path: atst.conf
|
||||
- name: uwsgi-socket-dir
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: nginx-htpasswd
|
||||
secret:
|
||||
secretName: atst-nginx-htpasswd
|
||||
items:
|
||||
- key: htpasswd
|
||||
path: .htpasswd
|
||||
mode: 0640
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst-worker
|
||||
namespace: atat
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
role: worker
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: worker
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: atst-worker
|
||||
image: atat:latest
|
||||
imagePullPolicy: Never
|
||||
args: [
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"worker",
|
||||
"--loglevel=info"
|
||||
]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst-beat
|
||||
namespace: atat
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
role: beat
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: beat
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: atst-beat
|
||||
image: atat:latest
|
||||
imagePullPolicy: Never
|
||||
args: [
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"beat",
|
||||
"--loglevel=info"
|
||||
]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst-main
|
||||
namespace: atat
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8342
|
||||
name: http-main
|
||||
- port: 443
|
||||
targetPort: 8442
|
||||
name: https-main
|
||||
selector:
|
||||
role: web
|
||||
type: LoadBalancer
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
name: atst-auth
|
||||
namespace: atat
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8343
|
||||
name: http-auth
|
||||
- port: 443
|
||||
targetPort: 8443
|
||||
name: https-auth
|
||||
selector:
|
||||
role: web
|
||||
type: LoadBalancer
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -9,13 +9,23 @@ spec:
|
||||
- name: nginx-secret
|
||||
flexVolume:
|
||||
options:
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "dhparam4096;cert;cert"
|
||||
keyvaultname: "cloudzero-dev-keyvault"
|
||||
# keyvaultobjectnames: "dhparam4096;cert;cert"
|
||||
keyvaultobjectnames: "foo"
|
||||
keyvaultobjectaliases: "FOO"
|
||||
keyvaultobjecttypes: "secret"
|
||||
usevmmanagedidentity: "true"
|
||||
usepodidentity: "false"
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
options:
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
|
||||
keyvaultname: "cloudzero-dev-keyvault"
|
||||
# keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
|
||||
keyvaultobjectnames: "master-PGPASSWORD"
|
||||
keyvaultobjectaliases: "PGPASSWORD"
|
||||
keyvaultobjecttypes: "secret"
|
||||
usevmmanagedidentity: "true"
|
||||
usepodidentity: "false"
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
@ -28,8 +38,10 @@ spec:
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
options:
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultname: "cloudzero-dev-keyvault"
|
||||
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
|
||||
usevmmanagedidentity: "true"
|
||||
usepodidentity: "false"
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
@ -42,8 +54,10 @@ spec:
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
options:
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultname: "cloudzero-dev-keyvault"
|
||||
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
|
||||
usevmmanagedidentity: "true"
|
||||
usepodidentity: "false"
|
||||
---
|
||||
apiVersion: batch/v1beta1
|
||||
kind: CronJob
|
||||
@ -58,5 +72,7 @@ spec:
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
options:
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultname: "cloudzero-dev-keyvault"
|
||||
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
|
||||
usevmmanagedidentity: "true"
|
||||
usepodidentity: "false"
|
||||
|
@ -5,7 +5,6 @@ resources:
|
||||
- namespace.yml
|
||||
- reset-cron-job.yml
|
||||
patchesStrategicMerge:
|
||||
- replica_count.yml
|
||||
- ports.yml
|
||||
- envvars.yml
|
||||
- flex_vol.yml
|
||||
|
@ -3,6 +3,9 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: atst-main
|
||||
annotations:
|
||||
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
|
||||
service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "cloudzero-dev-public"
|
||||
spec:
|
||||
loadBalancerIP: ""
|
||||
ports:
|
||||
@ -17,6 +20,9 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: atst-auth
|
||||
annotations:
|
||||
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
|
||||
service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "cloudzero-dev-public"
|
||||
spec:
|
||||
loadBalancerIP: ""
|
||||
ports:
|
||||
|
@ -1,14 +0,0 @@
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: atst
|
||||
spec:
|
||||
replicas: 2
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: atst-worker
|
||||
spec:
|
||||
replicas: 1
|
16
deploy/overlays/staging/autoscaling.yml
Normal file
16
deploy/overlays/staging/autoscaling.yml
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: atst
|
||||
spec:
|
||||
minReplicas: 1
|
||||
maxReplicas: 2
|
||||
---
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: atst-worker
|
||||
spec:
|
||||
minReplicas: 1
|
||||
maxReplicas: 2
|
@ -5,7 +5,7 @@ resources:
|
||||
- namespace.yml
|
||||
- reset-cron-job.yml
|
||||
patchesStrategicMerge:
|
||||
- replica_count.yml
|
||||
- autoscaling.yml
|
||||
- ports.yml
|
||||
- envvars.yml
|
||||
- flex_vol.yml
|
||||
|
@ -1,14 +0,0 @@
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: atst
|
||||
spec:
|
||||
replicas: 2
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: atst-worker
|
||||
spec:
|
||||
replicas: 1
|
0
docs/ATATArchitecture.md
Normal file
0
docs/ATATArchitecture.md
Normal file
32
docs/EdgeControls.md
Normal file
32
docs/EdgeControls.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Edge Control
|
||||
This document describes the expected connections and listening services.
|
||||
|
||||
## Transient Connections
|
||||
| Service | Direction | Ports | Protocol | Encrypted? | Ciphers |
|
||||
| --------|-----------|-------|----------|------------|--------------|
|
||||
| Azure Container Registry | Egress | 443 | HTTP | Yes | MSFT Managed |
|
||||
| DOD CRL Service | Egress | 443 | HTTP | Yes | DOD Managed |
|
||||
| Azure Storage | Egress | 443 | HTTP | Yes | MSFT Managed|
|
||||
| Redis | Egress | 6380 | HTTP | Yes | MSFT Managed|
|
||||
| Postgres | Egress | 5432 | HTTP | Yes | MSFT Managed|
|
||||
|
||||
# Listening Ports / Services
|
||||
| Service/App | Port | Protocol| Encrypted? | Accessible |
|
||||
|-------------|---------|---------|------------|--------|
|
||||
| ATAT App | 80, 443 | HTTP | Both | Load Balancer Only
|
||||
| ATAT Auth | 80, 443 | HTTP | Both | Load Balancer Only
|
||||
|
||||
# Host List
|
||||
## Dev
|
||||
| Service| Host |
|
||||
|--------|------|
|
||||
| Redis | cloudzero-dev-redis.redis.cache.windows.net |
|
||||
| Postgres| cloudzero-dev-sql.postgres.database.azure.com |
|
||||
| Docker Container Registry | cloudzerodevregistry.azurecr.io |
|
||||
|
||||
## Production
|
||||
| Service | Host |
|
||||
|---------|------|
|
||||
| Redis | |
|
||||
| Postgres| |
|
||||
| Docker Container Registry | |
|
@ -70,7 +70,7 @@ describe('UploadInput Test', () => {
|
||||
})
|
||||
|
||||
const component = wrapper.find(uploadinput)
|
||||
const event = { target: { value: '', files: [{ name: '' }] } }
|
||||
const event = { target: { value: '', files: [{ name: 'sample.pdf' }] } }
|
||||
|
||||
component.setMethods({
|
||||
getUploader: async () => new MockUploader('token', 'objectName'),
|
||||
|
@ -101,7 +101,7 @@ export default {
|
||||
if (!!this.clinNumber) {
|
||||
return `CLIN ${this.clinNumber}`
|
||||
} else {
|
||||
return `CLIN`
|
||||
return `New CLIN`
|
||||
}
|
||||
},
|
||||
percentObligated: function() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { buildUploader } from '../lib/upload'
|
||||
import { emitFieldChange } from '../lib/emitters'
|
||||
import inputValidations from '../lib/input_validations'
|
||||
|
||||
export default {
|
||||
name: 'uploadinput',
|
||||
@ -28,6 +29,7 @@ export default {
|
||||
changed: false,
|
||||
uploadError: false,
|
||||
sizeError: false,
|
||||
filenameError: false,
|
||||
downloadLink: '',
|
||||
}
|
||||
},
|
||||
@ -50,6 +52,10 @@ export default {
|
||||
this.sizeError = true
|
||||
return
|
||||
}
|
||||
if (!this.validateFileName(file.name)) {
|
||||
this.filenameError = true
|
||||
return
|
||||
}
|
||||
|
||||
const uploader = await this.getUploader()
|
||||
const response = await uploader.upload(file)
|
||||
@ -71,6 +77,10 @@ export default {
|
||||
this.uploadError = true
|
||||
}
|
||||
},
|
||||
validateFileName: function(name) {
|
||||
const regex = inputValidations.restrictedFileName.match
|
||||
return regex.test(name)
|
||||
},
|
||||
removeAttachment: function(e) {
|
||||
e.preventDefault()
|
||||
this.attachment = null
|
||||
@ -118,7 +128,8 @@ export default {
|
||||
return (
|
||||
(!this.changed && this.initialErrors) ||
|
||||
this.uploadError ||
|
||||
this.sizeError
|
||||
this.sizeError ||
|
||||
this.filenameError
|
||||
)
|
||||
},
|
||||
valid: function() {
|
||||
|
@ -9,6 +9,12 @@ export default {
|
||||
unmask: [],
|
||||
validationError: 'Please enter a response',
|
||||
},
|
||||
clinNumber: {
|
||||
mask: false,
|
||||
match: /^\d{4}$/,
|
||||
unmask: [],
|
||||
validationError: 'Please enter a 4-digit CLIN number',
|
||||
},
|
||||
date: {
|
||||
mask: [/\d/, /\d/, '/', /\d/, /\d/, '/', /\d/, /\d/, /\d/, /\d/],
|
||||
match: /(0[1-9]|1[012])[- \/.](0[1-9]|[12][0-9]|3[01])[- \/.](19|20)\d\d/,
|
||||
@ -34,6 +40,20 @@ export default {
|
||||
unmask: ['$', ','],
|
||||
validationError: 'Please enter a dollar amount',
|
||||
},
|
||||
defaultStringField: {
|
||||
mask: false,
|
||||
match: /^[A-Za-z0-9\-_ \.]{1,100}$/,
|
||||
unmask: [],
|
||||
validationError:
|
||||
'Please enter a response of no more than 100 alphanumeric characters',
|
||||
},
|
||||
defaultTextAreaField: {
|
||||
mask: false,
|
||||
match: /^[A-Za-z0-9\-_ \.]{1,1000}$/,
|
||||
unmask: [],
|
||||
validationError:
|
||||
'Please enter a response of no more than 1000 alphanumeric characters',
|
||||
},
|
||||
clinDollars: {
|
||||
mask: createNumberMask({ prefix: '$', allowDecimal: true }),
|
||||
match: /^-?\d+\.?\d*$/,
|
||||
@ -53,6 +73,13 @@ export default {
|
||||
unmask: [','],
|
||||
validationError: 'Please enter a number',
|
||||
},
|
||||
name: {
|
||||
mask: false,
|
||||
match: /.{1,100}/,
|
||||
unmask: [],
|
||||
validationError:
|
||||
'This field accepts letters, numbers, commas, apostrophes, hyphens, and periods.',
|
||||
},
|
||||
phoneExt: {
|
||||
mask: createNumberMask({
|
||||
prefix: '',
|
||||
@ -71,7 +98,7 @@ export default {
|
||||
unmask: [],
|
||||
validationError: 'Portfolio names can be between 4-100 characters',
|
||||
},
|
||||
requiredField: {
|
||||
required: {
|
||||
mask: false,
|
||||
match: /.+/,
|
||||
unmask: [],
|
||||
@ -104,4 +131,11 @@ export default {
|
||||
unmask: ['(', ')', '-', ' '],
|
||||
validationError: 'Please enter a 10-digit phone number',
|
||||
},
|
||||
restrictedFileName: {
|
||||
mask: false,
|
||||
match: /^[A-Za-z0-9\-_ \.]+$/,
|
||||
unmask: [],
|
||||
validationError:
|
||||
'File names can only contain the characters A-Z, 0-9, space, hyphen, underscore, and period.',
|
||||
},
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
|
||||
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
|
||||
|
||||
# Alpha numerics for random entity names
|
||||
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
|
||||
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" # pragma: allowlist secret
|
||||
|
||||
NEW_PORTFOLIO_CHANCE = 10
|
||||
NEW_APPLICATION_CHANCE = 10
|
||||
@ -29,10 +29,6 @@ def logout(l):
|
||||
l.client.get("/logout")
|
||||
|
||||
|
||||
def get_index(l):
|
||||
l.client.get("/")
|
||||
|
||||
|
||||
def get_csrf_token(response):
|
||||
d = pq(response.text)
|
||||
return d("#csrf_token").val()
|
||||
@ -52,14 +48,9 @@ def extract_id(path):
|
||||
|
||||
|
||||
def get_portfolios(l):
|
||||
response = l.client.get("/portfolios")
|
||||
response = l.client.get("/home")
|
||||
d = pq(response.text)
|
||||
portfolio_links = [
|
||||
p.attr("href")
|
||||
for p in d(
|
||||
".global-panel-container .atat-table tbody tr td:first-child a"
|
||||
).items()
|
||||
]
|
||||
portfolio_links = [p.attr("href") for p in d(".sidenav__link").items()]
|
||||
force_new_portfolio = randrange(0, 100) < NEW_PORTFOLIO_CHANCE
|
||||
if len(portfolio_links) == 0 or force_new_portfolio:
|
||||
portfolio_links += [create_portfolio(l)]
|
||||
@ -73,7 +64,7 @@ def get_portfolio(l):
|
||||
d = pq(response.text)
|
||||
application_links = [
|
||||
p.attr("href")
|
||||
for p in d(".application-list .accordion__actions a:first-child").items()
|
||||
for p in d(".portfolio-applications .accordion__header-text a").items()
|
||||
]
|
||||
if len(application_links) > 0:
|
||||
portfolio_id = extract_id(portfolio_link)
|
||||
@ -161,18 +152,14 @@ class UserBehavior(TaskSequence):
|
||||
login(self)
|
||||
|
||||
@seq_task(1)
|
||||
def home(l):
|
||||
get_index(l)
|
||||
|
||||
@seq_task(2)
|
||||
def portfolios(l):
|
||||
get_portfolios(l)
|
||||
|
||||
@seq_task(3)
|
||||
@seq_task(2)
|
||||
def pick_a_portfolio(l):
|
||||
get_portfolio(l)
|
||||
|
||||
@seq_task(4)
|
||||
@seq_task(3)
|
||||
def pick_an_app(l):
|
||||
get_app(l)
|
||||
|
||||
@ -189,4 +176,3 @@ class WebsiteUser(HttpLocust):
|
||||
if __name__ == "__main__":
|
||||
# if run as the main file, will spin up a single locust
|
||||
WebsiteUser().run()
|
||||
|
||||
|
@ -40,9 +40,7 @@ reset_db() {
|
||||
local database_name="${1}"
|
||||
|
||||
# If the DB exists, drop it
|
||||
set +e
|
||||
dropdb "${database_name}"
|
||||
set -e
|
||||
dropdb --if-exists "${database_name}"
|
||||
|
||||
# Create a fresh DB
|
||||
createdb "${database_name}"
|
||||
|
@ -22,7 +22,7 @@ check_for_existing_virtual_environment() {
|
||||
local target_python_version_regex="^Python ${python_version}"
|
||||
|
||||
# Check for existing venv, and if one exists, save the Python version string
|
||||
existing_venv_version=$($(pipenv --py) --version)
|
||||
existing_venv_version=$($(pipenv --py 2> /dev/null) --version 2> /dev/null)
|
||||
if [ "$?" = "0" ]; then
|
||||
# Existing venv; see if the Python version matches
|
||||
if [[ "${existing_venv_version}" =~ ${target_python_version_regex} ]]; then
|
||||
|
@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# script/minikube_setup: Set up local AT-AT cluster on Minikube
|
||||
|
||||
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
|
||||
|
||||
output_divider "Start Minikube"
|
||||
minikube start
|
||||
|
||||
output_divider "Use Minikube Docker environment"
|
||||
eval $(minikube docker-env)
|
||||
|
||||
output_divider "Build AT-AT Docker image for Minikube registry"
|
||||
docker build . -t atat:latest
|
||||
|
||||
output_divider "Pull images for AT-AT cluster"
|
||||
docker pull redis:5.0-alpine
|
||||
docker pull postgres:11-alpine
|
||||
docker pull nginx:alpine
|
||||
|
||||
output_divider "Apply AT-AT Kubernetes config to Minikube cluster"
|
||||
kubectl --context=minikube create namespace atat
|
||||
kubectl --context=minikube apply -f deploy/minikube/
|
||||
|
||||
output_divider "Create database and apply migrations"
|
||||
# wait for the datastore deployment to become available
|
||||
kubectl --context=minikube -n atat wait --for=condition=Available deployment/datastores
|
||||
# postgres isn't necessarily running as soon as the pod is available, so wait a few
|
||||
sleep 3
|
||||
DB_POD=$(kubectl --context=minikube -n atat get pods -l app=db-cache -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
|
||||
ATST_POD=$(kubectl --context=minikube -n atat get pods -l app=atst -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
|
||||
kubectl --context=minikube -n atat exec -it $DB_POD -c postgres -- createdb -U postgres atat
|
||||
kubectl --context=minikube -n atat exec -it $ATST_POD -c atst -- .venv/bin/python .venv/bin/alembic upgrade head
|
@ -6,7 +6,7 @@
|
||||
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
|
||||
|
||||
# create upload directory for app
|
||||
mkdir uploads | true
|
||||
mkdir -p uploads
|
||||
|
||||
# Enable database resetting
|
||||
RESET_DB="true"
|
||||
|
@ -39,6 +39,7 @@
|
||||
@import "components/sticky_cta.scss";
|
||||
@import "components/error_page.scss";
|
||||
@import "components/member_form.scss";
|
||||
@import "components/toggle_menu.scss";
|
||||
|
||||
@import "sections/login";
|
||||
@import "sections/home";
|
||||
|
@ -106,7 +106,7 @@
|
||||
&__expanded {
|
||||
font-size: $small-font-size;
|
||||
font-weight: $font-normal;
|
||||
background-color: $color-gray-lightest;
|
||||
background-color: $color-offwhite;
|
||||
padding: $gap;
|
||||
|
||||
&:last-child {
|
||||
|
@ -130,10 +130,6 @@
|
||||
&--th {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&--td {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
@ -154,55 +150,6 @@
|
||||
margin-right: $gap * 6;
|
||||
}
|
||||
}
|
||||
|
||||
.app-member-menu {
|
||||
position: absolute;
|
||||
top: $gap;
|
||||
right: $gap * 2;
|
||||
|
||||
.accordion-table__item__toggler {
|
||||
padding: $gap / 3;
|
||||
border: 1px solid $color-gray-lighter;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: $color-aqua-lightest;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: $gap / 2;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
background-color: $color-white;
|
||||
border: 1px solid $color-gray-light;
|
||||
z-index: 1;
|
||||
margin-top: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: $gap;
|
||||
border-bottom: 1px solid $color-gray-lighter;
|
||||
text-decoration: none;
|
||||
color: $color-black;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-aqua-lightest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#add-new-env {
|
||||
@ -275,14 +222,21 @@
|
||||
|
||||
span.accordion-table__item__toggler {
|
||||
font-weight: $font-normal;
|
||||
text-decoration: underline;
|
||||
font-size: $small-font-size;
|
||||
|
||||
&.environment-list__item__members {
|
||||
float: unset;
|
||||
font-size: $small-font-size;
|
||||
margin-left: -$gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li.environment-list__edit {
|
||||
border: 1px solid $color-gray-lighter;
|
||||
padding: 0 $gap * 3 $gap * 2;
|
||||
}
|
||||
|
||||
.activity-log {
|
||||
border-top: 3px solid $color-blue;
|
||||
|
||||
|
58
styles/components/_toggle_menu.scss
Normal file
58
styles/components/_toggle_menu.scss
Normal file
@ -0,0 +1,58 @@
|
||||
.toggle-menu {
|
||||
position: absolute;
|
||||
top: $gap;
|
||||
right: $gap * 2;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.accordion-table__item__toggler {
|
||||
padding: $gap / 3;
|
||||
border: 1px solid $color-gray-lighter;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: $color-aqua-lightest;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: $gap / 2;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
background-color: $color-white;
|
||||
border: 1px solid $color-gray-light;
|
||||
z-index: 1;
|
||||
margin-top: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: $gap;
|
||||
border-bottom: 1px solid $color-gray-lighter;
|
||||
text-decoration: none;
|
||||
color: $color-black;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-aqua-lightest;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: $color-gray;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -12,10 +12,13 @@
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
|
||||
a {
|
||||
color: $color-white;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: $color-white !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: $topbar-height;
|
||||
@ -23,20 +26,28 @@
|
||||
text-decoration: none;
|
||||
|
||||
&-label {
|
||||
@include h5;
|
||||
text-decoration: underline;
|
||||
padding-left: $gap;
|
||||
font-size: $h5-font-size;
|
||||
font-weight: $font-semibold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
margin-left: $gap;
|
||||
|
||||
margin: 0 $gap 0 0;
|
||||
@include icon-color($color-white);
|
||||
}
|
||||
|
||||
.icon--logout {
|
||||
margin: 0 0 0 $gap;
|
||||
}
|
||||
|
||||
&--home {
|
||||
padding-left: $gap / 2;
|
||||
padding: 0 ($gap * 2);
|
||||
|
||||
.topbar__link-label {
|
||||
font-size: $base-font-size;
|
||||
font-weight: $font-bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -33,7 +33,7 @@ $title-font-size: 5.2rem;
|
||||
$h1-font-size: 4rem;
|
||||
$h2-font-size: 3rem;
|
||||
$h3-font-size: 2.3rem;
|
||||
$h4-font-size: 1.7rem;
|
||||
$h4-font-size: 1.9rem;
|
||||
$h5-font-size: 1.5rem;
|
||||
$h6-font-size: 1.3rem;
|
||||
$base-line-height: 1.5;
|
||||
@ -44,6 +44,7 @@ $font-sans: "Source Sans Pro", sans-serif;
|
||||
$font-serif: "Merriweather", serif;
|
||||
|
||||
$font-normal: 400;
|
||||
$font-semibold: 600;
|
||||
$font-bold: 700;
|
||||
|
||||
// Color
|
||||
|
@ -230,6 +230,8 @@
|
||||
&--anything,
|
||||
&--portfolioName,
|
||||
&--requiredField,
|
||||
&--defaultStringField,
|
||||
&--defaultTextAreaField,
|
||||
&--taskOrderNumber,
|
||||
&--email {
|
||||
input {
|
||||
|
@ -13,7 +13,7 @@
|
||||
) }}
|
||||
<div class="accordion-table__item-content new-env">
|
||||
<div class="h4">{{ "portfolios.applications.enter_env_name" | translate }}</div>
|
||||
{{ TextInput(new_env_form.name, label="", validation="requiredField", optional=False) }}
|
||||
{{ TextInput(new_env_form.name, label="", validation="defaultStringField", optional=False) }}
|
||||
<div class="action-group">
|
||||
{{ SaveButton(text=('common.save' | translate), element="input", form="add-new-env") }}
|
||||
<a class='action-group__action icon-link icon-link--default' v-on:click="toggle">
|
||||
|
@ -17,7 +17,7 @@
|
||||
{% if 0 == environments_obj | length -%}
|
||||
<div class="empty-state panel__content">
|
||||
<p class="empty-state__message">
|
||||
This Application has no environments
|
||||
{{ 'portfolios.applications.environments.blank_slate' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
@ -31,8 +31,23 @@
|
||||
<div class="accordion-table__item-content">
|
||||
<div class="environment-list__item">
|
||||
<span>
|
||||
{{ env['name'] }}
|
||||
<a
|
||||
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
|
||||
</a>
|
||||
</span>
|
||||
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
|
||||
{{
|
||||
ToggleButton(
|
||||
open_html="common.edit"|translate,
|
||||
close_html="common.close"|translate,
|
||||
section_name="edit"
|
||||
)
|
||||
}}
|
||||
{%- endif %}
|
||||
<br>
|
||||
{% set members_button = "portfolios.applications.member_count" | translate({'count': env['member_count']}) %}
|
||||
{{
|
||||
ToggleButton(
|
||||
@ -42,23 +57,9 @@
|
||||
classes="environment-list__item__members"
|
||||
)
|
||||
}}
|
||||
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
|
||||
{% set edit_environment_button = "Edit" %}
|
||||
{{
|
||||
ToggleButton(
|
||||
open_html=edit_environment_button,
|
||||
close_html=edit_environment_button,
|
||||
section_name="edit"
|
||||
)
|
||||
}}
|
||||
{%- endif %}
|
||||
<br>
|
||||
{% if env['pending'] -%}
|
||||
{{ Label(type="changes_pending", classes='label--below')}}
|
||||
{% else %}
|
||||
<a href='{{ url_for("applications.access_environment", environment_id=env.id)}}' target='_blank' rel='noopener noreferrer' class='application-list-item__environment__csp_link'>
|
||||
<span>{{ "portfolios.applications.csp_link" | translate }} {{ Icon('link', classes="icon--tiny") }}</span>
|
||||
</a>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -66,7 +67,7 @@
|
||||
{% call ToggleSection(section_name="members") %}
|
||||
<ul>
|
||||
{% for member in env['members'] %}
|
||||
{% set status = ": Access Suspended" if member['status'] == 'disabled' %}
|
||||
{% set status = "portfolios.applications.environments.disabled"|translate if member['status'] == 'disabled' %}
|
||||
<li class="accordion-table__item-toggle-content__expanded">
|
||||
{{ member['user_name'] }}{{ status }}
|
||||
</li>
|
||||
@ -77,16 +78,28 @@
|
||||
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
|
||||
{% call ToggleSection(section_name="edit") %}
|
||||
<ul>
|
||||
<li class="accordion-table__item-toggle-content__expanded">
|
||||
<li class="accordion-table__item-toggle-content__expanded environment-list__edit">
|
||||
<base-form inline-template>
|
||||
<form action="{{ url_for('applications.update_environment', environment_id=env['id']) }}" method="post" v-on:submit="handleSubmit">
|
||||
<form
|
||||
action="{{ url_for('applications.update_environment', environment_id=env['id']) }}"
|
||||
method="post"
|
||||
v-on:submit="handleSubmit"
|
||||
class="col col--half">
|
||||
{{ edit_form.csrf_token }}
|
||||
{{ TextInput(edit_form.name, validation='requiredField', optional=False) }}
|
||||
{{
|
||||
SaveButton(
|
||||
text=("common.save" | translate)
|
||||
)
|
||||
}}
|
||||
{{ TextInput(edit_form.name, validation='defaultStringField', optional=False) }}
|
||||
<div class="action-group action-group--tight">
|
||||
{{
|
||||
SaveButton(
|
||||
text=("common.save_changes" | translate)
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
formaction="{{ url_for('applications.create_subscription', environment_id=env.id)}}"
|
||||
class="usa-button usa-button-secondary">
|
||||
{{ "portfolios.applications.environments.add_subscription" | translate }}
|
||||
</button>
|
||||
</form>
|
||||
</base-form>
|
||||
</li>
|
||||
|
@ -21,7 +21,7 @@
|
||||
{{ sub_form.environment_name.data }}
|
||||
</div>
|
||||
<div class="usa-input__help">
|
||||
{{ role }}
|
||||
{{ "portfolios.applications.members.roles.{}".format(role) | translate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-col form-col--third">
|
||||
@ -89,16 +89,13 @@
|
||||
{% if new %}
|
||||
{% set team_mgmt = form.perms_team_mgmt.name %}
|
||||
{% set env_mgmt = form.perms_env_mgmt.name %}
|
||||
{% set del_env = form.perms_del_env.name %}
|
||||
{% else %}
|
||||
{% set team_mgmt = "perms_team_mgmt-{}".format(member_role_id) %}
|
||||
{% set env_mgmt = "perms_env_mgmt-{}".format(member_role_id) %}
|
||||
{% set del_env = "perms_del_env-{}".format(member_role_id) %}
|
||||
{% endif %}
|
||||
|
||||
{{ CheckboxInput(form.perms_team_mgmt, classes="input__inline-fields", key=team_mgmt, id=team_mgmt, optional=True) }}
|
||||
{{ CheckboxInput(form.perms_env_mgmt, classes="input__inline-fields", key=env_mgmt, id=env_mgmt, optional=True) }}
|
||||
{{ CheckboxInput(form.perms_del_env, classes="input__inline-fields", key=del_env, id=del_env, optional=True) }}
|
||||
</div>
|
||||
<hr class="full-width">
|
||||
<div class="environment_roles environment-roles-new">
|
||||
@ -119,11 +116,11 @@
|
||||
|
||||
{% macro InfoFields(member_form) %}
|
||||
<div class="user-info">
|
||||
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(member_form.first_name, validation='name', optional=False) }}
|
||||
{{ TextInput(member_form.last_name, validation='name', optional=False) }}
|
||||
{{ TextInput(member_form.email, validation='email', optional=False) }}
|
||||
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
|
||||
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
|
||||
<a href="#">How do I find the DoD ID?</a>
|
||||
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -5,6 +5,7 @@
|
||||
{% from "components/modal.html" import Modal %}
|
||||
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
|
||||
{% from "components/save_button.html" import SaveButton %}
|
||||
{% from "components/toggle_menu.html" import ToggleMenu %}
|
||||
|
||||
{% macro MemberManagementTemplate(
|
||||
application,
|
||||
@ -38,16 +39,17 @@
|
||||
{% call Modal(modal_name, classes="form-content--app-mem") %}
|
||||
<div class="modal__form--header">
|
||||
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
|
||||
<hr class="full-width">
|
||||
</div>
|
||||
<base-form inline-template>
|
||||
<form id='{{ modal_name }}' method="POST" action="{{ url_for(action_update, application_id=application.id, application_role_id=member.role_id,) }}">
|
||||
{{ member.form.csrf_token }}
|
||||
{{ member_fields.PermsFields(form=member.form, member_role_id=member.role_id) }}
|
||||
<div class="action-group">
|
||||
{{ SaveButton(text='Update', element='input', additional_classes='action-group__action') }}
|
||||
<a class='action-group__action usa-button usa-button-secondary' v-on:click="closeModal('{{ modal_name }}')">{{ "common.cancel" | translate }}</a>
|
||||
</div>
|
||||
{{ member_form.SubmitStep(
|
||||
name=modal_name,
|
||||
form=member_fields.PermsFields(form=member.form, member_role_id=member.role_id),
|
||||
submit_text="common.save_changes"|translate,
|
||||
previous=False,
|
||||
modal=modal_name,
|
||||
) }}
|
||||
</form>
|
||||
</base-form>
|
||||
{% endcall %}
|
||||
@ -57,16 +59,17 @@
|
||||
{% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
|
||||
<div class="modal__form--header">
|
||||
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
|
||||
<hr class="full-width">
|
||||
</div>
|
||||
<base-form inline-template :enable-save="true">
|
||||
<form id='{{ resend_invite_modal }}' method="POST" action="{{ url_for('applications.resend_invite', application_id=application.id, application_role_id=member.role_id) }}">
|
||||
{{ member.update_invite_form.csrf_token }}
|
||||
{{ member_fields.InfoFields(member.update_invite_form) }}
|
||||
<div class="action-group">
|
||||
{{ SaveButton(text="Resend Invite")}}
|
||||
<a class='action-group__action' v-on:click="closeModal('{{ resend_invite_modal }}')">{{ "common.cancel" | translate }}</a>
|
||||
</div>
|
||||
{{ member_form.SubmitStep(
|
||||
name=resend_invite_modal,
|
||||
form=member_fields.InfoFields(member.update_invite_form),
|
||||
submit_text="Resend Invite",
|
||||
previous=False,
|
||||
modal=resend_invite_modal,
|
||||
) }}
|
||||
</form>
|
||||
</base-form>
|
||||
{% endcall %}
|
||||
@ -119,7 +122,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="env_role--td">
|
||||
<td class="toggle-menu__container">
|
||||
{% for env in member.environment_roles %}
|
||||
<div class="row">
|
||||
<span class="env-role__environment">
|
||||
@ -131,32 +134,21 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%}
|
||||
<toggle-menu inline-template v-cloak>
|
||||
<div class="app-member-menu">
|
||||
<span v-if="isVisible" class="accordion-table__item__toggler accordion-table__item__toggler--active">
|
||||
{{ Icon('ellipsis')}}
|
||||
</span>
|
||||
<span v-else class="accordion-table__item__toggler">
|
||||
{{ Icon('ellipsis')}}
|
||||
</span>
|
||||
|
||||
<div v-show="isVisible" class="accordion-table__item-toggle-content app-member-menu__toggle">
|
||||
<a v-on:click="openModal('{{ perms_modal }}')">
|
||||
{{ "portfolios.applications.members.menu.edit" | translate }}
|
||||
</a>
|
||||
{% if invite_pending or invite_expired -%}
|
||||
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
|
||||
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
|
||||
<a v-on:click='openModal("{{ resend_invite_modal }}")'>
|
||||
{{ "portfolios.applications.members.menu.resend" | translate }}
|
||||
</a>
|
||||
{% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%}
|
||||
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke' | translate }}</a>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
</toggle-menu>
|
||||
{% call ToggleMenu() %}
|
||||
<a v-on:click="openModal('{{ perms_modal }}')">
|
||||
{{ "portfolios.applications.members.menu.edit" | translate }}
|
||||
</a>
|
||||
{% if invite_pending or invite_expired -%}
|
||||
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
|
||||
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
|
||||
<a v-on:click='openModal("{{ resend_invite_modal }}")'>
|
||||
{{ "portfolios.applications.members.menu.resend" | translate }}
|
||||
</a>
|
||||
{% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%}
|
||||
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke' | translate }}</a>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{% endcall %}
|
||||
{%- endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@ -187,7 +179,7 @@
|
||||
member_form.SubmitStep(
|
||||
name=new_member_modal_name,
|
||||
form=member_fields.PermsFields(form=new_member_form, new=True),
|
||||
submit_text="portfolios.applications.members.form.add_member"|translate,
|
||||
submit_text="common.save_changes"|translate,
|
||||
modal=new_member_modal_name,
|
||||
)
|
||||
],
|
||||
|
@ -26,14 +26,14 @@
|
||||
{{ form.csrf_token }}
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
{{ TextInput(form.name, optional=False) }}
|
||||
{{ TextInput(form.name, validation="name", optional=False) }}
|
||||
{{ ('portfolios.applications.new.step_1_form_help_text.name' | translate | safe) }}
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="form-row">
|
||||
<div class="form-col form-col--two-thirds">
|
||||
{{ TextInput(form.description, paragraph=True, optional=True) }}
|
||||
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True, optional=True) }}
|
||||
{{ ('portfolios.applications.new.step_1_form_help_text.description' | translate | safe) }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -27,13 +27,13 @@
|
||||
|
||||
<span class="action-group-footer">
|
||||
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
|
||||
Return to Application Settings
|
||||
{{ "portfolios.applications.new.step_3_button_text" | translate }}
|
||||
</a>
|
||||
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}">
|
||||
Previous
|
||||
{{ "common.previous" | translate }}
|
||||
</a>
|
||||
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
|
||||
Cancel
|
||||
{{ "common.cancel" | translate }}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
|
@ -22,8 +22,8 @@
|
||||
<base-form inline-template>
|
||||
<form method="POST" action="{{ url_for('applications.update', application_id=application.id) }}" class="col col--half">
|
||||
{{ application_form.csrf_token }}
|
||||
{{ TextInput(application_form.name, optional=False) }}
|
||||
{{ TextInput(application_form.description, paragraph=True, optional=True, showOptional=False) }}
|
||||
{{ TextInput(application_form.name, validation="name", optional=False) }}
|
||||
{{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }}
|
||||
<div class="action-group action-group--tight">
|
||||
{{ SaveButton(text='common.save_changes'|translate) }}
|
||||
</div>
|
||||
|
@ -25,7 +25,7 @@
|
||||
|
||||
<div class='usa-alert-body'>
|
||||
{% if vue_template %}
|
||||
<h3 class='usa-alert-heading' v-html='title'></h3>
|
||||
<h3 class='usa-alert-heading' v-text='title'></h3>
|
||||
{% elif title %}
|
||||
<h3 class='usa-alert-heading'>{{ title | safe }}</h3>
|
||||
{% endif %}
|
||||
|
@ -57,7 +57,7 @@
|
||||
<span class='usa-input__message'>{{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}</span>
|
||||
</template>
|
||||
<template v-else-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
<span class='usa-input__message' v-text='validationError'></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class='usa-input__message'></span>
|
||||
|
@ -41,7 +41,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
{% if fields %}
|
||||
{{ TextInput(fields.number, optional=False) }}
|
||||
{{ TextInput(fields.number, validation='clinNumber', optional=False) }}
|
||||
{% else %}
|
||||
<textinput :name="'clins-' + clinIndex + '-number'" inline-template>
|
||||
<div v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
|
||||
@ -68,7 +68,7 @@
|
||||
<input type='hidden' v-bind:value='rawValue' :name='name' />
|
||||
|
||||
<template v-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
<span class='usa-input__message' v-text='validationError'></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class='usa-input__message'></span>
|
||||
@ -118,7 +118,7 @@
|
||||
{{ CLINDollarAmount("obligated", funding_validation=True) }}
|
||||
{% endif %}
|
||||
|
||||
<div class="h5 clin-card__title">Percent Obligated</div>
|
||||
<div class="h5 clin-card__title">{{ "task_orders.form.step_3.percent_obligated" | translate }}</div>
|
||||
<p id="percent-obligated" v-text='percentObligated'></p>
|
||||
|
||||
<hr>
|
||||
|
@ -54,7 +54,7 @@
|
||||
|
||||
|
||||
<template v-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
<span class='usa-input__message' v-text='validationError'></span>
|
||||
</template>
|
||||
|
||||
</fieldset>
|
||||
|
@ -48,7 +48,7 @@
|
||||
{{ field(disabled=disabled) }}
|
||||
|
||||
<template v-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
<span class='usa-input__message' v-text='validationError'></span>
|
||||
</template>
|
||||
|
||||
</fieldset>
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% set class = "usa-button usa-button-primary " + additional_classes %}
|
||||
|
||||
{% if element == "button" %}
|
||||
<button type="submit" class="{{ class }}" tabindex="0" v-bind:disabled="!canSave" {{ form_attr }} >
|
||||
<button type="submit" class="{{ class }}" tabindex="0" v-bind:disabled="!canSave">
|
||||
{{ text }}
|
||||
</button>
|
||||
{% elif element == 'input' %}
|
||||
|
@ -107,7 +107,7 @@
|
||||
/>
|
||||
|
||||
{% if show_validation %}
|
||||
<span v-if='showError' class='usa-input__message' v-html='validationError'></span>
|
||||
<span v-if='showError' class='usa-input__message' v-text='validationError'></span>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
17
templates/components/toggle_menu.html
Normal file
17
templates/components/toggle_menu.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
{% macro ToggleMenu() %}
|
||||
<toggle-menu inline-template v-cloak>
|
||||
<div class="toggle-menu">
|
||||
<span v-if="isVisible" class="accordion-table__item__toggler accordion-table__item__toggler--active">
|
||||
{{ Icon('ellipsis')}}
|
||||
</span>
|
||||
<span v-else class="accordion-table__item__toggler">
|
||||
{{ Icon('ellipsis')}}
|
||||
</span>
|
||||
<div v-show="isVisible" class="accordion-table__item-toggle-content toggle-menu__toggle">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
</toggle-menu>
|
||||
{% endmacro %}
|
@ -49,6 +49,9 @@
|
||||
<template v-if="sizeError">
|
||||
<span class="usa-input__message">{{ "forms.task_order.size_error" | translate }}</span>
|
||||
</template>
|
||||
<template v-if="filenameError">
|
||||
<span class="usa-input__message">{{ "forms.task_order.filename_error" | translate }}</span>
|
||||
</template>
|
||||
{% for error, error_messages in field.errors.items() %}
|
||||
<span class="usa-input__message">{{error_messages[0]}}</span>
|
||||
{% endfor %}
|
||||
|
@ -17,15 +17,6 @@
|
||||
{%- endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<hr>
|
||||
<p>
|
||||
{{ "common.lorem" | translate }}
|
||||
</p>
|
||||
<p>
|
||||
<a href="#">More lorem</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -11,11 +11,11 @@
|
||||
<div class='panel__content'>
|
||||
<div class='form-row'>
|
||||
<div class='form-col form-col--half'>
|
||||
{{ TextInput(form.first_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(form.first_name, validation='name', optional=False) }}
|
||||
</div>
|
||||
|
||||
<div class='form-col form-col--half'>
|
||||
{{ TextInput(form.last_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(form.last_name, validation='name', optional=False) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,137 +0,0 @@
|
||||
{% extends "help/index.html" %}
|
||||
|
||||
{% set subnav = [
|
||||
{"label":"Financial Verification", "href":"#financial-verification"},
|
||||
{"label":"ID/IQ CLINs", "href":"#idiq-clins"},
|
||||
{"label":"JEDI Cloud Applications", "href":"#jedi-cloud-applications"},
|
||||
] %}
|
||||
|
||||
{% block doc_content %}
|
||||
|
||||
<h2 id='financial-verification'>Financial Verification</h2>
|
||||
|
||||
<h3>How to prepare for Financial Verification step?</h3>
|
||||
<p>Once your request is approved, the next step is to create a Task Order (T.O.) associated with the JEDI Cloud ID/IQ. Please contact a Contracting Officer (KO) or Contracting Officer Representative (COR) to help with this step. </p>
|
||||
<p>This may also involve talking to your Financial Manager (FM) to secure funding.</p>
|
||||
<p>Once the Task Order (T.O.) has been created, you will need to provide information related to the task order and funding in AT-AT. This step is referred to as “Financial Verification.”</p>
|
||||
<p><em>We also recommend getting familiar with the <a href="#">JEDI Cloud CLIN structures</a> so that you know which specific services are available under JEDI and categorized for contracting purposes. This will help you and the Contracting Officer create a Task Order.</em></p>
|
||||
|
||||
<h3>Why is this important?</h3>
|
||||
<p>This step allows AT-AT and the CCPO to track and report on cloud infrastructure spending across the Department., It also enables you and your team to see your cloud usage and verify your invoices with your budget.</p>
|
||||
|
||||
<h3>What to prepare for Financial Verification?</h3>
|
||||
<p>You will need to have these details on hand before filling out the next step.</p>
|
||||
|
||||
<div class='fixed-table-wrapper'>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item you’ll need to provide</th>
|
||||
<th>Format / Example</th>
|
||||
<th>Explanation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Task Order Number associated with this request*</td>
|
||||
<td><em>Example: <br>1234567899C0001</em></td>
|
||||
<td>
|
||||
<p>Please include the original Task Order number (including the 000X at the end); this field is not requesting any modification numbers.</p>
|
||||
<p>Please note that there may be a lag between the time you have created and approved the task order to the time it is searchable within the electronic.</p>
|
||||
<p>A Contracting Officer will likely be the best source for this number.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Unique Item Identifiers (UII) related to your system(s) if you already have them</td>
|
||||
<td><em>Example: <br>DI 0CVA5786950 OR UN1945326361234786950</em></td>
|
||||
<td>
|
||||
<p>A Unique Investment Identifier is a unique code that helps the Department of Defense track and report on where and how digital assets are stored and where the budget comes from.</p>
|
||||
<p>Not all applications have an existing UII number assigned.</p>
|
||||
<p>This identifier can be found in SNaP-IT.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Program Element (PE) Number related to your request</td>
|
||||
<td><em>Example: <br>0203752A</em></td>
|
||||
<td>Program Element numbers helps the Department of Defense identify which offices' budgets are contributing towards this resource use.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Program Treasury Code</td>
|
||||
<td><em>Example: <br>1200</em></td>
|
||||
<td>The Treasury Code (or Appropriations Code) is a four digit number that identifies resource types.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Program BA Code</td>
|
||||
<td><em>Example: <br>02</em></td>
|
||||
<td>The Budget Activity Code (or BA Code) is a two digit number that is the category within each appropriation and fund account used to identify the purposes, applications, or types of activities financed by the appropriation fund.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><em>*AT-AT will search your Task Order number in available APIs and find other relevant details about your task order automatically. If we are unable to locate your Task Order, you will be asked to manually enter information such as total contract amount, CLIN amounts, and contracting officer information.</em></p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 id='idiq-clins'>ID/IQ CLINs</h2>
|
||||
|
||||
<h3>How are the JEDI ID/IQ CLINs structured?</h3>
|
||||
|
||||
<p>We recommend sharing the following details with your contracting personnel to help accelerate the task order creation process.</p>
|
||||
<p>The JEDI contract vehicle supports the following types of cloud infrastructure services.</p>
|
||||
<p>Your contracting personnel will want to know which services above and contract line item numbers (CLINs) you are interested in procuring and what estimated dollar amounts to use associate. Use the <a href="#">JEDI Cloud Calculator</a> to arrive at a price estimate for each of those.</p>
|
||||
|
||||
<div class='fixed-table-wrapper'>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>JEDI Contract Line Item Numbers (CLINs)</th>
|
||||
<th>Services supported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>CLIN 0001 - Unclassified IaaS and PaaS Amount</td>
|
||||
<td>
|
||||
<p>This CLIN covers infrastructure as a service (IaaS) features including the basic building blocks of networking features, computers (virtual or on dedicated hardware), and data storage space.</p>
|
||||
<p>It also provides platform as a service (PaaS) features including resource procurement, capacity planning, software maintenance, patching, or any of the other undifferentiated heavy lifting involved in running your application.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CLIN 0003 - Unclassified Cloud Support Package</td>
|
||||
<td>This CLIN covers the basic customer service support package offered including _______</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CLIN 1001 - Unclassified IaaS and PaaS Amount OPTION PERIOD 1</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CLIN 1003 - Unclassified Cloud Support Package OPTION PERIOD 1</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CLIN 2001 - Unclassified IaaS and PaaS Amount OPTION PERIOD 2</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CLIN 2003 - Unclassified Cloud Support Package OPTION PERIOD 2</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 id='jedi-cloud-applications'>JEDI Cloud Applications</h2>
|
||||
|
||||
<h3>How are applications organized in the JEDI Cloud?</h3>
|
||||
|
||||
<h4>Application Structure for JEDI Cloud</h4>
|
||||
|
||||
<p>Separate your portfolio into applications and environments; this allows your team to manage user access to systems more securely and track expenditures for each application.</p>
|
||||
<p>Here’s an example:<br>
|
||||
Application A has a development environment, production environment, and sandbox environment. The cloud resources in the development environment are grouped and accessed separately from the production environment and sandbox environment.</p>
|
||||
|
||||
<img src='{{ url_for("static", filename="img/at-at_faqs_content.svg") }}' alt='AT-AT FAQs Content'>
|
||||
|
||||
{% endblock %}
|
@ -1,48 +0,0 @@
|
||||
{% extends "base_public.html" %}
|
||||
{% from "components/sidenav_item.html" import SidenavItem %}
|
||||
|
||||
{% block title %}Help | JEDI Cloud{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class='global-layout'>
|
||||
<div class='global-navigation'>
|
||||
<div class="sidenav-container">
|
||||
<div class="sidenav">
|
||||
<ul class="sidenav__list sidenav__list--no-header">
|
||||
{{ SidenavItem("JEDI Cloud Help",
|
||||
href = url_for("atst.helpdocs"),
|
||||
active = not doc,
|
||||
)}}
|
||||
{% for doc_item in docs %}
|
||||
{% set active = doc and doc == doc_item %}
|
||||
{{ SidenavItem(doc_item | title,
|
||||
href = url_for("atst.helpdocs", doc=doc_item),
|
||||
active = active,
|
||||
)}}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='global-panel-container'>
|
||||
<div class='panel'>
|
||||
<div class='panel__heading panel__heading--divider'>
|
||||
<h1>
|
||||
{% if doc %}
|
||||
<div class='h4'>JEDI Cloud Help Documentation</div>
|
||||
<div class='h1'>{{ doc | title }}</div>
|
||||
{% else %}
|
||||
<div class='h1'>JEDI Cloud Help Documentation</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class='panel__content'>
|
||||
{% block doc_content %}
|
||||
<p>Welcome to the JEDI Cloud help documentation.</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -14,7 +14,7 @@
|
||||
{{ Icon('avatar', classes='topbar__link-icon') }}
|
||||
<span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span>
|
||||
</a>
|
||||
<a href="{{ url_for('atst.helpdocs') }}" class="topbar__link">
|
||||
<a href="#" class="topbar__link">
|
||||
{{ Icon('question', classes='topbar__link-icon') }}
|
||||
<span class="topbar__link-label">Support</span>
|
||||
</a>
|
||||
|
@ -20,7 +20,7 @@
|
||||
<form method="POST" action="{{ url_for('portfolios.edit', portfolio_id=portfolio.id) }}" autocomplete="false">
|
||||
{{ portfolio_form.csrf_token }}
|
||||
{{ TextInput(portfolio_form.name, validation="portfolioName", optional=False) }}
|
||||
{{ TextInput(portfolio_form.description, paragraph=True) }}
|
||||
{{ TextInput(portfolio_form.description, validation="defaultTextAreaField", paragraph=True) }}
|
||||
<div class='edit-portfolio-name action-group'>
|
||||
{{ SaveButton(text='Save Changes', additional_classes='usa-button-big') }}
|
||||
</div>
|
||||
@ -59,10 +59,6 @@
|
||||
|
||||
<hr>
|
||||
|
||||
{% if user_can(permissions.VIEW_PORTFOLIO_POC) %}
|
||||
{% include "portfolios/fragments/primary_point_of_contact.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if user_can(permissions.VIEW_PORTFOLIO_USERS) %}
|
||||
{% include "portfolios/fragments/portfolio_members.html" %}
|
||||
{% endif %}
|
||||
|
@ -1,80 +0,0 @@
|
||||
{% from "components/icon.html" import Icon %}
|
||||
{% from "components/text_input.html" import TextInput %}
|
||||
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
|
||||
{% from "components/alert.html" import Alert %}
|
||||
{% from "components/options_input.html" import OptionsInput %}
|
||||
|
||||
{% set step_one %}
|
||||
<hr class="full-width">
|
||||
<h1>{{ "fragments.ppoc.update_ppoc_title" | translate }}</h1>
|
||||
|
||||
{{
|
||||
Alert(
|
||||
level="warning",
|
||||
title=("fragments.ppoc.alert.title" | translate),
|
||||
message=("fragments.ppoc.alert.message" | translate),
|
||||
)
|
||||
}}
|
||||
|
||||
<div class='form-row'>
|
||||
<div class='form-col form-col--half'>
|
||||
{{
|
||||
OptionsInput(
|
||||
assign_ppoc_form.role_id,
|
||||
optional=False
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class='form-col form-col--half'>
|
||||
</div>
|
||||
</div>
|
||||
<div class='action-group'>
|
||||
<input
|
||||
type='button'
|
||||
v-on:click="next()"
|
||||
v-bind:disabled="!canSave"
|
||||
class='action-group__action usa-button'
|
||||
value='{{ "fragments.ppoc.assign_user_button_text" | translate }}'>
|
||||
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('change-ppoc-form')">
|
||||
{{ "common.cancel" | translate }}
|
||||
</a>
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
{% set step_two %}
|
||||
<hr class="full-width">
|
||||
<h1>{{ "fragments.ppoc.update_ppoc_confirmation_title" | translate }}</h1>
|
||||
|
||||
{{
|
||||
Alert(
|
||||
level="info",
|
||||
title=("fragments.ppoc.confirm_alert.title" | translate),
|
||||
)
|
||||
}}
|
||||
|
||||
<div class='action-group'>
|
||||
<input
|
||||
type="submit"
|
||||
class='action-group__action usa-button'
|
||||
form="change-ppoc-form"
|
||||
value='{{ "common.confirm" | translate }}'>
|
||||
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('change-ppoc-form')">
|
||||
{{ "common.cancel" | translate }}
|
||||
</a>
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
<div class="flex-reverse-row">
|
||||
{% set disable_ppoc_button = 1 == portfolio.members |length %}
|
||||
<button type="button" class="usa-button usa-button-primary" v-on:click="openModal('change-ppoc-form')" {% if disable_ppoc_button %}disabled{% endif %}>
|
||||
{{ "fragments.ppoc.update_btn" | translate }}
|
||||
</button>
|
||||
{{
|
||||
MultiStepModalForm(
|
||||
'change-ppoc-form',
|
||||
assign_ppoc_form,
|
||||
form_action=url_for("portfolios.update_ppoc", portfolio_id=portfolio.id),
|
||||
steps=[step_one, step_two],
|
||||
)
|
||||
}}
|
||||
</div>
|
@ -27,11 +27,11 @@
|
||||
|
||||
{% macro InfoFields(member_form) %}
|
||||
<div class="user-info">
|
||||
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
|
||||
{{ TextInput(member_form.first_name, validation='name', optional=False) }}
|
||||
{{ TextInput(member_form.last_name, validation='name', optional=False) }}
|
||||
{{ TextInput(member_form.email, validation='email', optional=False) }}
|
||||
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
|
||||
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
|
||||
<a href="#">How do I find the DoD ID?</a>
|
||||
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -5,6 +5,92 @@
|
||||
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
|
||||
{% from 'components/save_button.html' import SaveButton %}
|
||||
{% import "portfolios/fragments/member_form_fields.html" as member_form_fields %}
|
||||
{% from "components/toggle_menu.html" import ToggleMenu %}
|
||||
|
||||
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) -%}
|
||||
{% for member in members -%}
|
||||
{% if not member.ppoc -%}
|
||||
{% set invite_pending = member.status == 'invite_pending' %}
|
||||
{% set invite_expired = member.status == 'invite_expired' %}
|
||||
|
||||
{% set modal_name = "edit_member-{}".format(loop.index) %}
|
||||
{% call Modal(modal_name, classes="form-content--app-mem") %}
|
||||
<div class="modal__form--header">
|
||||
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
|
||||
</div>
|
||||
<base-form inline-template>
|
||||
<form id='{{ modal_name }}' method="POST" action="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, portfolio_role_id=member.role_id) }}">
|
||||
{{ member.form.csrf_token }}
|
||||
{{ member_form.SubmitStep(
|
||||
name=modal_name,
|
||||
form=member_form_fields.PermsFields(member.form, member_role_id=member.role_id),
|
||||
submit_text="Save Changes",
|
||||
previous=False,
|
||||
modal=modal_name,
|
||||
) }}
|
||||
</form>
|
||||
</base-form>
|
||||
{% endcall %}
|
||||
|
||||
{% if invite_pending or invite_expired -%}
|
||||
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
|
||||
{% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
|
||||
<div class="modal__form--header">
|
||||
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
|
||||
</div>
|
||||
<base-form inline-template :enable-save="true">
|
||||
<form id='{{ resend_invite_modal }}' method="POST" action="{{ url_for('portfolios.resend_invitation', portfolio_id=portfolio.id, portfolio_token=member.invite_token) }}">
|
||||
{{ member.update_invite_form.csrf_token }}
|
||||
{{ member_form.SubmitStep(
|
||||
name=resend_invite_modal,
|
||||
form=member_form_fields.InfoFields(member.update_invite_form.user_data),
|
||||
submit_text="Resend Invite",
|
||||
previous=False,
|
||||
modal=resend_invite_modal
|
||||
) }}
|
||||
</form>
|
||||
</base-form>
|
||||
{% endcall %}
|
||||
|
||||
{% set revoke_invite_modal = "revoke_invite-{}".format(member.role_id) %}
|
||||
{% call Modal(name=revoke_invite_modal) %}
|
||||
<form method="post" action="{{ url_for('portfolios.revoke_invitation', portfolio_id=portfolio.id, portfolio_token=member.invite_token) }}">
|
||||
{{ member.form.csrf_token }}
|
||||
<h1>{{ "invites.revoke" | translate }}</h1>
|
||||
<hr class="full-width">
|
||||
{{ "invites.revoke_modal_text" | translate({"application": portfolio.name}) }}
|
||||
<div class="action-group">
|
||||
<button class="action-group__action usa-button usa-button-primary" type="submit">{{ "invites.revoke" | translate }}</button>
|
||||
<button class='action-group__action usa-button usa-button-secondary' v-on:click='closeModal("{{revoke_invite_modal}}")' type="button">{{ "common.cancel" | translate }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% else %}
|
||||
{% set remove_manager_modal = "remove_manager-{}".format(member.role_id) %}
|
||||
{% call Modal(name=remove_manager_modal, dismissable=False) %}
|
||||
<h1>{{ "portfolios.admin.alert_header" | translate }}</h1>
|
||||
<hr class="full-width">
|
||||
{{
|
||||
Alert(
|
||||
title="portfolios.admin.alert_title" | translate,
|
||||
message="portfolios.admin.alert_message" | translate,
|
||||
level="warning"
|
||||
)
|
||||
}}
|
||||
<div class="action-group">
|
||||
<form method="POST" action="{{ url_for('portfolios.remove_member', portfolio_id=portfolio.id, portfolio_role_id=member.role_id)}}">
|
||||
{{ member.form.csrf_token }}
|
||||
<button class="usa-button usa-button-danger">
|
||||
{{ "portfolios.members.archive_button" | translate }}
|
||||
</button>
|
||||
</form>
|
||||
<a v-on:click="closeModal('{{ modal_id }}')" class="action-group__action icon-link icon-link--default">{{ "common.cancel" | translate }}</a>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
|
||||
<h3>Portfolio Managers</h3>
|
||||
<div class="panel">
|
||||
@ -19,6 +105,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members -%}
|
||||
{% set invite_pending = member.status == 'invite_pending' %}
|
||||
{% set invite_expired = member.status == 'invite_expired' %}
|
||||
{% set current_user = current_member_id == member.role_id %}
|
||||
{% set perms_modal = "edit_member-{}".format(loop.index) %}
|
||||
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
|
||||
{% set revoke_invite_modal = "revoke_invite-{}".format(member.role_id) %}
|
||||
{% set remove_manager_modal = "remove_manager-{}".format(member.role_id) %}
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ member.user_name }}{% if member.role_id == current_member_id %} (You){% endif %}</strong>
|
||||
@ -28,14 +122,33 @@
|
||||
{% endif %}
|
||||
{{ Label(type=member.status, classes='label--below')}}
|
||||
</td>
|
||||
<td>
|
||||
<td class="toggle-menu__container">
|
||||
{% for perm, value in member.permission_sets.items() -%}
|
||||
<div>
|
||||
{% if value -%}
|
||||
{% if value -%}
|
||||
<div>
|
||||
{{ ("portfolios.admin.members.{}.{}".format(perm, value)) | translate }}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%-endfor %}
|
||||
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) -%}
|
||||
{% call ToggleMenu() %}
|
||||
<a
|
||||
{% if not member.ppoc %}v-on:click="openModal('{{ perms_modal }}')"{% endif %}
|
||||
class="{% if member.ppoc %}disabled{% endif %}">
|
||||
Edit Permissions
|
||||
</a>
|
||||
{% if invite_pending or invite_expired -%}
|
||||
<a v-on:click="openModal('{{ resend_invite_modal }}')">Resend Invite</a>
|
||||
<a v-on:click="openModal('{{ revoke_invite_modal }}')">Revoke Invite</a>
|
||||
{% else %}
|
||||
<a
|
||||
{% if not current_user %}v-on:click="openModal('{{ remove_manager_modal }}')"{% endif %}
|
||||
class="{% if current_user %}disabled{% endif %}">
|
||||
Remove Manager
|
||||
</a>
|
||||
{%- endif %}
|
||||
{% endcall %}
|
||||
{%- endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
@ -60,13 +173,13 @@
|
||||
form=member_form_fields.InfoFields(new_manager_form.user_data),
|
||||
next_button_text="Next: Permissions",
|
||||
previous=False,
|
||||
modal=new_manager_modal_name,
|
||||
modal=new_manager_modal,
|
||||
),
|
||||
member_form.SubmitStep(
|
||||
name=new_manager_modal,
|
||||
form=member_form_fields.PermsFields(new_manager_form),
|
||||
submit_text="Add Mananger",
|
||||
modal=new_manager_modal_name,
|
||||
modal=new_manager_modal,
|
||||
)
|
||||
],
|
||||
) }}
|
||||
|
@ -1,25 +0,0 @@
|
||||
<section id="primary-point-of-contact" class="panel">
|
||||
<div class="panel__content">
|
||||
{% if g.matchesPath("primary-point-of-contact") %}
|
||||
{% include "fragments/flash.html" %}
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ "fragments.ppoc.title" | translate }}</h2>
|
||||
<p>{{ "fragments.ppoc.subtitle" | translate }}</p>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
{{ portfolio.owner.first_name }}
|
||||
{{ portfolio.owner.last_name }}
|
||||
</strong>
|
||||
<br />
|
||||
{{ portfolio.owner.email }}
|
||||
<br />
|
||||
{{ portfolio.owner.phone_number | usPhone }}
|
||||
</p>
|
||||
|
||||
{% if user_can(permissions.EDIT_PORTFOLIO_POC) %}
|
||||
{% include "portfolios/fragments/change_ppoc.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
@ -12,22 +12,22 @@
|
||||
{% include "fragments/flash.html" %}
|
||||
<div class='portfolio-header__name'>
|
||||
<p>{{ "portfolios.header" | translate }}</p>
|
||||
<h1>{{ "New Portfolio" }}</h1>
|
||||
<h1>{{ "portfolios.new.title" | translate }}</h1>
|
||||
</div>
|
||||
{{ StickyCTA(text="Create New Portfolio") }}
|
||||
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
|
||||
<base-form inline-template>
|
||||
<div class="row">
|
||||
<form id="portfolio-create" class="col" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
|
||||
{{ form.csrf_token }}
|
||||
<div class="form-row form-row--bordered">
|
||||
<div class="form-col">
|
||||
{{ TextInput(form.name, optional=False, classes="form-col") }}
|
||||
{{ TextInput(form.name, validation="name", optional=False, classes="form-col") }}
|
||||
{{"forms.portfolio.name.help_text" | translate | safe }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row form-row--bordered">
|
||||
<div class="form-col">
|
||||
{{ TextInput(form.description, paragraph=True) }}
|
||||
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True) }}
|
||||
{{"forms.portfolio.description.help_text" | translate | safe }}
|
||||
</div>
|
||||
</div>
|
||||
@ -39,9 +39,9 @@
|
||||
</div>
|
||||
<div class='action-group-footer'>
|
||||
{% block next_button %}
|
||||
{{ SaveButton(text=('common.save' | translate), form="portfolio-create", element="input") }}
|
||||
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
|
||||
{% endblock %}
|
||||
<a href="{{ url_for('atst.home') }}">
|
||||
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
|
||||
Cancel
|
||||
</a>
|
||||
</form>
|
||||
@ -49,4 +49,3 @@
|
||||
</base-form>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -5,15 +5,15 @@
|
||||
<section class="row">
|
||||
<div class='col col--grow summary-item'>
|
||||
<h5 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Total Portfolio Value</span>
|
||||
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
|
||||
<span class="summary-item__header-text">{{ "portfolios.reports.total_value.header" | translate }}</span>
|
||||
{{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="summary-item__header-icon")}}
|
||||
</h5>
|
||||
<p class="summary-item__value">{{ total_portfolio_value | dollars }}</p>
|
||||
</div>
|
||||
<div class='col col--grow summary-item'>
|
||||
<h5 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Funding Duration</span>
|
||||
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
|
||||
<span class="summary-item__header-text">{{ "portfolios.reports.duration.header" | translate }}</span>
|
||||
{{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="summary-item__header-icon")}}
|
||||
</h5>
|
||||
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
|
||||
{% if earliest_pop_start_date and latest_pop_end_date %}
|
||||
@ -28,8 +28,8 @@
|
||||
</div>
|
||||
<div class='col col--grow summary-item'>
|
||||
<h5 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Days Remaining</span>
|
||||
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
|
||||
<span class="summary-item__header-text">{{ "portfolios.reports.days_remaining.header" | translate }}</span>
|
||||
{{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="summary-item__header-icon")}}
|
||||
</h5>
|
||||
<p class="summary-item__value">{{ portfolio.days_to_funding_expiration }} days</p>
|
||||
</div>
|
||||
|
@ -11,16 +11,7 @@
|
||||
<section class="row">
|
||||
<div class='col col--grow summary-item'>
|
||||
<h4 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Total obligated funds</span>
|
||||
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }}
|
||||
</h4>
|
||||
<p class="summary-item__value--large">
|
||||
{{ obligated_funds | dollars }}
|
||||
</p>
|
||||
</div>
|
||||
<div class='col col--grow summary-item'>
|
||||
<h4 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Total Task Order value</span>
|
||||
<span class="summary-item__header-text">{{ 'task_orders.summary.total' | translate }}</span>
|
||||
{{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="summary-item__header-icon") }}
|
||||
</h4>
|
||||
<p class="summary-item__value--large">
|
||||
@ -29,7 +20,16 @@
|
||||
</div>
|
||||
<div class='col col--grow summary-item'>
|
||||
<h4 class="summary-item__header">
|
||||
<span class="summary-item__header-text">Total expended funds</span>
|
||||
<span class="summary-item__header-text">{{ 'task_orders.summary.obligated' | translate }}</span>
|
||||
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }}
|
||||
</h4>
|
||||
<p class="summary-item__value--large">
|
||||
{{ obligated_funds | dollars }}
|
||||
</p>
|
||||
</div>
|
||||
<div class='col col--grow summary-item'>
|
||||
<h4 class="summary-item__header">
|
||||
<span class="summary-item__header-text">{{ 'task_orders.summary.expended' | translate }}</span>
|
||||
{{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="summary-item__header-icon") }}
|
||||
</h4>
|
||||
<p class="summary-item__value--large">
|
||||
@ -39,7 +39,7 @@
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<h4>Documents</h4>
|
||||
<h4>{{ 'task_orders.form.step_4.documents' | translate }}</h4>
|
||||
<div class="panel panel__content">
|
||||
{% if builder_mode %}
|
||||
{{ Icon('ok', classes="icon--green icon--medium") }}
|
||||
@ -52,7 +52,7 @@
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<h4>CLIN summary</h4>
|
||||
<h4>{{ 'task_orders.form.step_4.clins' | translate }}</h4>
|
||||
<table class="fixed-table-wrapper atat-table clin-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -66,7 +66,7 @@
|
||||
|
||||
|
||||
{% call StickyCTA(text="common.task_orders"|translate) %}
|
||||
{% if user_can(permissions.CREATE_TASK_ORDER) and task_orders %}
|
||||
{% if user_can(permissions.CREATE_TASK_ORDER) and to_count > 0 %}
|
||||
<a href="{{ url_for("task_orders.form_step_one_add_pdf", portfolio_id=portfolio.id) }}" class="usa-button usa-button-primary">
|
||||
{{ "task_orders.add_new_button" | translate }}
|
||||
</a>
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
{% block to_builder_form_field %}
|
||||
{{ TOFormStepHeader(
|
||||
title='task_orders.form.supporting_docs_header' | translate,
|
||||
description='task_orders.form.supporting_docs_text' | translate,
|
||||
title='task_orders.form.step_1.title' | translate,
|
||||
description='task_orders.form.step_1.description' | translate,
|
||||
) }}
|
||||
{{ UploadInput(form.pdf, portfolio.id) }}
|
||||
{% endblock %}
|
||||
|
@ -8,7 +8,7 @@
|
||||
{% set next_button_text = 'task_orders.form.step_5.next_button' | translate %}
|
||||
{% set previous_button_link = url_for("task_orders.form_step_four_review", task_order_id=task_order_id) %}
|
||||
{% set step = "5" %}
|
||||
{% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %}
|
||||
{% set sticky_cta_text = 'task_orders.form.step_5.cta_text' | translate %}
|
||||
|
||||
{% block to_builder_form_field %}
|
||||
{{ TOFormStepHeader(
|
||||
|
1
terraform/.gitignore
vendored
1
terraform/.gitignore
vendored
@ -1 +1,2 @@
|
||||
.terraform
|
||||
.vscode/
|
||||
|
@ -57,6 +57,7 @@ To create all the resources we need for this environment we'll need to enable so
|
||||
This registers the specific feature for _SystemAssigned_ principals
|
||||
```
|
||||
az feature register --namespace Microsoft.ContainerService --name MSIPreview
|
||||
az feature register --namespace Microsoft.ContainerService --name NodePublicIPPreview
|
||||
```
|
||||
|
||||
To apply the registration, run the following
|
||||
@ -133,6 +134,42 @@ module "keyvault" {
|
||||
}
|
||||
```
|
||||
|
||||
## Setting the Redis key in KeyVault
|
||||
Redis auth is provided by a simple key that is randomly generated by Azure. This is a simple task for `secrets-tool`.
|
||||
|
||||
First, get the key from the portal. You can navigate to the redis cluster, and click on either "Show Keys", or "Access Keys"
|
||||
|
||||

|
||||
|
||||
In order to set the secret, make sure you specify the keyvault that is used by the application. In dev, its simply called "keyvault", where the operator keyvault has a different name.
|
||||
|
||||
```
|
||||
secrets-tool secrets --keyvault https://cloudzero-dev-keyvault.vault.azure.net/ create --key REDIS-PASSWORD --value "<redis key>"
|
||||
```
|
||||
You'll see output similar to the following if it was successful
|
||||
|
||||
```
|
||||
2020-01-17 14:04:42,996 - utils.keyvault.secrets - DEBUG - Set value for key: REDIS-PASSWORD
|
||||
```
|
||||
|
||||
## Setting the Azure Storage Key
|
||||
Azure storage is very similar to how Redis has a generated key. This generated key is what is used at the time of writing this doc.
|
||||
|
||||
Grab the key from the "Access Keys" tab on the cloud storage bucket
|
||||
|
||||

|
||||
|
||||
Now create the secret in KeyVault. This secret should also be in the application specific KeyVault.
|
||||
|
||||
```
|
||||
secrets-tool secrets --keyvault https://cloudzero-dev-keyvault.vault.azure.net/ create --key AZURE-STORAGE-KEY --value "<storage key>"
|
||||
```
|
||||
You'll see output similar to the following if it was successful
|
||||
|
||||
```
|
||||
2020-01-17 14:14:59,426 - utils.keyvault.secrets - DEBUG - Set value for key: AZURE-STORAGE-KEY
|
||||
```
|
||||
|
||||
# Shutting down and environment
|
||||
To shutdown and remove an environment completely as to not incur any costs you would need to run a `terraform destroy`.
|
||||
|
||||
@ -170,4 +207,78 @@ https://login.microsoftonline.com/common/oauth2/authorize?client_id=41b23e61-6c1
|
||||
TODO
|
||||
|
||||
## Downloading a client profile
|
||||
TODO
|
||||
TODO
|
||||
|
||||
# Quick Steps
|
||||
Copy paste (mostly)
|
||||
|
||||
*Register Preview features*
|
||||
See [Registering Features](#Preview_Features)
|
||||
|
||||
*Edit provider.tf and turn off remote bucket temporarily (comment out backend {} section)*
|
||||
```
|
||||
provider "azurerm" {
|
||||
version = "=1.40.0"
|
||||
}
|
||||
|
||||
provider "azuread" {
|
||||
# Whilst version is optional, we /strongly recommend/ using it to pin the version of the Provider being used
|
||||
version = "=0.7.0"
|
||||
}
|
||||
|
||||
terraform {
|
||||
#backend "azurerm" {
|
||||
#resource_group_name = "cloudzero-dev-tfstate"
|
||||
#storage_account_name = "cloudzerodevtfstate"
|
||||
#container_name = "tfstate"
|
||||
#key = "dev.terraform.tfstate"
|
||||
#}
|
||||
}
|
||||
```
|
||||
|
||||
`terraform init`
|
||||
|
||||
`terraform plan -target=module.tf_state`
|
||||
|
||||
Ensure the state bucket is created.
|
||||
|
||||
*create the container in the portal (or cli).*
|
||||
This simply involves going to the bucket in the azure portal and creating the container.
|
||||
|
||||
Now is the tricky part. For this, we will be switching from local state (files) to remote state (stored in the azure bucket)
|
||||
|
||||
Uncomment the `backend {}` section in the `provider.tf` file. Once uncommented, we will re-run the init. This will attempt to copy the local state to the remote bucket.
|
||||
|
||||
`terraform init`
|
||||
|
||||
*Say `yes` to the question*
|
||||
|
||||
Now we need to update the Update `variables.tf` with the principals for the users in `admin_users` variable map. If these are not defined yet, just leave it as an empty set.
|
||||
|
||||
Next, we'll create the operator keyvault.
|
||||
|
||||
`terraform plan -target=module.operator_keyvault`
|
||||
|
||||
Next, we'll pre-populate some secrets using the secrets-tool. Follow the install/setup section in the README.md first. Then populate the secrets with a definition file as described in the following link.
|
||||
|
||||
https://github.com/dod-ccpo/atst/tree/staging/terraform/secrets-tool#populating-secrets-from-secrets-definition-file
|
||||
|
||||
*Create service principal for AKS*
|
||||
```
|
||||
az ad sp create-for-rbac
|
||||
```
|
||||
Take note of the output, you'll need it in the next step to store the secret and `client_id` in keyvault.
|
||||
|
||||
This also involves using secrets-tool. Substitute your keyvault url.
|
||||
```
|
||||
secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ create --key k8s-client-id --value [value]
|
||||
secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ create --key k8s-client-secret --value [value]
|
||||
```
|
||||
|
||||
*Next we'll apply the rest of the TF configuration*
|
||||
|
||||
`terraform plan` # Make sure this looks correct
|
||||
|
||||
`terraform apply`
|
||||
|
||||
*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)*
|
BIN
terraform/images/azure-storage.png
Normal file
BIN
terraform/images/azure-storage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 325 KiB |
BIN
terraform/images/redis-keys.png
Normal file
BIN
terraform/images/redis-keys.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 249 KiB |
@ -11,6 +11,20 @@ resource "azurerm_storage_account" "bucket" {
|
||||
account_replication_type = "LRS"
|
||||
}
|
||||
|
||||
resource "azurerm_storage_account_network_rules" "acls" {
|
||||
resource_group_name = azurerm_resource_group.bucket.name
|
||||
storage_account_name = azurerm_storage_account.bucket.name
|
||||
|
||||
default_action = var.policy
|
||||
|
||||
# Azure Storage CIDR ACLs do not accept /32 CIDR ranges.
|
||||
ip_rules = [
|
||||
for cidr in values(var.whitelist) : cidr
|
||||
]
|
||||
virtual_network_subnet_ids = var.subnet_ids
|
||||
bypass = ["AzureServices"]
|
||||
}
|
||||
|
||||
resource "azurerm_storage_container" "bucket" {
|
||||
name = "content"
|
||||
storage_account_name = azurerm_storage_account.bucket.name
|
||||
|
@ -29,3 +29,20 @@ variable "service_name" {
|
||||
description = "Name of the service using this bucket"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of subnet_ids that will have access to this service"
|
||||
type = list
|
||||
}
|
||||
|
||||
variable "policy" {
|
||||
description = "The default policy for the network access rules (Allow/Deny)"
|
||||
default = "Deny"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "whitelist" {
|
||||
type = map
|
||||
description = "A map of whitelisted IPs and CIDR ranges. For single IPs, Azure expects just the IP, NOT a /32."
|
||||
default = {}
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
locals {
|
||||
whitelist = values(var.whitelist)
|
||||
}
|
||||
|
||||
resource "azurerm_resource_group" "acr" {
|
||||
name = "${var.name}-${var.environment}-acr"
|
||||
location = var.region
|
||||
@ -10,4 +14,30 @@ resource "azurerm_container_registry" "acr" {
|
||||
sku = var.sku
|
||||
admin_enabled = var.admin_enabled
|
||||
#georeplication_locations = [azurerm_resource_group.acr.location, var.backup_region]
|
||||
|
||||
network_rule_set {
|
||||
default_action = var.policy
|
||||
|
||||
ip_rule = [
|
||||
for cidr in values(var.whitelist) : {
|
||||
action = "Allow"
|
||||
ip_range = cidr
|
||||
}
|
||||
]
|
||||
# Dynamic rule should work, but doesn't - See https://github.com/hashicorp/terraform/issues/22340#issuecomment-518779733
|
||||
#dynamic "ip_rule" {
|
||||
# for_each = values(var.whitelist)
|
||||
# content {
|
||||
# action = "Allow"
|
||||
# ip_range = ip_rule.value
|
||||
# }
|
||||
#}
|
||||
|
||||
virtual_network = [
|
||||
for subnet in var.subnet_ids : {
|
||||
action = "Allow"
|
||||
subnet_id = subnet.value
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user