diff --git a/pipelines/deploy_dv.yaml b/pipelines/deploy_dv.yaml new file mode 100644 index 00000000..15fb4e2c --- /dev/null +++ b/pipelines/deploy_dv.yaml @@ -0,0 +1,91 @@ +trigger: none + +parameters: + - name: Image + displayName: Build Container Image + type: string + default: "prod" + values: + # - dev + - prod + +variables: + registry: "hcsccrrc.azurecr.io" + repository: "nsp/sdse-spib-hopic-dv" + tag: "$(Build.SourceVersion)" + appName: was-spib-sdse-hopic-dv + subscription: SPIB-SDSE-HOPIC-CICDLZSP-DT + resourceGroup: rg-spib-sdse-hopic-dv + envars: > + DB_HOST=pgsql-spib-sdse-hopic-dv.postgres.database.azure.com + DB_PORT=5432 + DB_SSLMODE=require + SECRET_KEY='@Microsoft.KeyVault(SecretUri=https://kvspibsdsehopicdv.vault.azure.net/secrets/hopic-django-secret-key/)' + WEBSITES_PORT=8000 + LATEST_COMMIT_SHA=$(Build.SourceVersion) + ALLOWED_HOSTS=was-spib-sdse-hopic-dv.azurewebsites.net + CSRF_TRUSTED_ORIGINS=https://was-spib-sdse-hopic-dv.azurewebsites.net + DB_NAME=hopicdb_migration_test + DB_PASSWORD='@Microsoft.KeyVault(SecretUri=https://kvspibsdsehopicdv.vault.azure.net/secrets/hopicapp-pgsql-password/)' + DB_USER=hopicapp + ENV=dev + AZCOPY_AUTO_LOGIN_TYPE=MSI + + +pool: + name: spib-sdse-hopic-agents-dv + +jobs: + - job: Deploy_DV + steps: + - script: | + sudo apt-get update + sudo apt-get install unzip + displayName: "Install Unzip" + + - script: | + sudo apt install -y docker.io + sudo apt install docker-buildx + sudo systemctl start docker + sudo usermod -aG docker $(id -un) + sudo chmod 666 /var/run/docker.sock + displayName: "Install and Configure Docker" + + - script: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + displayName: "Install AZ CLI" + + - task: AzureCLI@2 + displayName: "Login to ACR" + inputs: + azureSubscription: $(subscription) + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + az --version + az acr login --name hcsccrrc + + - ${{ if eq(parameters.Image, 'prod')}}: + - script: | + docker build -t $(registry)/$(repository):$(tag) -f server/Dockerfile.prod . + docker push $(registry)/$(repository):$(tag) + displayName: "Build and Push $(repository) Image" + + - task: AzureWebAppContainer@1 + displayName: "Install $(repository) into $(appName)" + inputs: + azureSubscription: "$(subscription)" + appName: "$(appName)" + deployToSlotOrASE: true + resourceGroupName: "$(resourceGroup)" + containers: "$(registry)/$(repository):$(tag)" + containerCommand: "gunicorn --bind 0.0.0.0:8000 server.wsgi --timeout 1000" + + - task: AzureCLI@2 + displayName: "AppSettings for $(appName)" + inputs: + azureSubscription: $(subscription) + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + az webapp config appsettings set -g $(resourceGroup) -n $(appName) --settings ${{ variables.envars }} diff --git a/pipelines/secret_expiry_check.yaml b/pipelines/secret_expiry_check.yaml new file mode 100644 index 00000000..b08aa4b2 --- /dev/null +++ b/pipelines/secret_expiry_check.yaml @@ -0,0 +1,31 @@ +trigger: none + +pool: spib-sdse-hopic-agents-dv + +resources: + repositories: + - repository: templates + type: github + name: PHACDataHub/ADO-Pipeline-Templates + # Service connection to GitHub configured in Azure DevOps + endpoint: DMIA + - repository: test_secret_check + type: github + name: PHACDataHub/ADO-Pipeline-Templates + ref: refs/heads/tm-secret-expiry-check + endpoint: DMIA + +steps: +- script: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + displayName: Install AZ CLI + +- template: run_bash_secret_expiry_check.yaml@test_secret_check + parameters: + azureSubscription: SPIB-SDSE-HOPIC-CICDLZSP-DT + warningDays: 30 + appNames: + - 'SPIB-SDSE-HOPIC-CICDLZSP-DT' + - 'CDSB-DMIA-HOPIC-SSO-pr' + - 'CDSB-DMIA-HOPIC-SSO-dv' + - 'HOPIC/CPHO' diff --git a/server/Dockerfile.prod b/server/Dockerfile.prod index 38810aad..f01e89d1 100644 --- a/server/Dockerfile.prod +++ b/server/Dockerfile.prod @@ -1,60 +1,104 @@ -###################### -# DEPENDENCY BUILDER # -###################### -# NOTE: builder layer must mach python and distribution versions of distroless runtime layer! -FROM python:3.11-bookworm as build_env - -# MUST keep these envs in sync with the Dockerfile.prod "FINAL" layer AND with Dockerfile.dev-management -ENV HOME=/cpho -ENV APP_HOME=$HOME/web -ENV PYTHON_DEPS=$HOME/python_deps +# Builds a Prod Image expecting to write to ACR and run the image in an app service container -RUN mkdir "${HOME}" && \ - mkdir "${APP_HOME}" && \ - mkdir "${PYTHON_DEPS}" +########### +# BUILDER # +########### +FROM python:3.11-slim-bookworm as builder -# Update pip -RUN pip install --upgrade pip +# set work directory +WORKDIR /usr/src/app -COPY ./requirements.txt . -COPY ./requirements_dev.txt . -COPY ./requirements_formatting.txt . +# set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 -ARG DEPENDENCY_SET="prod" +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* -RUN [ "${DEPENDENCY_SET}" = "prod" ] \ - && pip install --no-cache-dir --target "${PYTHON_DEPS}" -r requirements.txt \ - || : +RUN pip install --upgrade pip wheel setuptools -RUN [ "${DEPENDENCY_SET}" = "test" ] \ - && pip install --no-cache-dir --target "${PYTHON_DEPS}" -r requirements.txt -r requirements_dev.txt -r requirements_formatting.txt \ - || : +# requirements and wheels +COPY server/requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt ######### # FINAL # ######### -FROM gcr.io/distroless/python3-debian12 +# pull official base image +FROM python:3.11-slim-bookworm + +# Make sure setuptools is always up to date +RUN pip install --upgrade pip setuptools -# MUST keep these envs in sync with the Dockerfile.prod "DEPENDENCY BUILDER" layer AND with Dockerfile.dev-management -ENV HOME=/cpho +# Environment Variables +ENV APP_NAME=hopicapp +ENV APP_USER=${APP_NAME}user +ENV HOME=/${APP_NAME} ENV APP_HOME=$HOME/web -ENV PYTHON_DEPS=$HOME/python_deps +ENV VIRTUALENV=$HOME/env +ENV WHEELDIR=$HOME/wheels +ENV PATH=$VIRTUALENV/bin:$PATH +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +# Install minimal runtime dependencies +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + gosu \ + libpq5 \ + netcat-traditional \ + openssh-server \ + postgresql-common \ + && addgroup --system $APP_NAME \ + && adduser --disabled-password --shell /bin/bash $APP_USER --system --ingroup $APP_NAME --home $HOME \ + && mkdir -p $APP_HOME $VIRTUALENV $WHEELDIR \ + && chown -R $APP_USER:$APP_NAME $HOME \ + && echo "root:Docker!" | chpasswd \ + && echo -e "HOPIC Container\n\nFor management commands run: \ncd $APP_HOME \npython manage\n\n" > /etc/motd \ + && echo -e "cd $APP_HOME\n" >> /etc/profile -# this is the ID of the distroless "nonroot" user, using ID instead of user name because the k8s runAsNonRoot security context -# can't verify non-rootness when the docker file sets user by name -# https://github.com/GoogleContainerTools/distroless/blob/9c5d2c431825d7aa21017551b2ec75c29c1f23c6/common/variables.bzl#L18 -ENV NONROOT_USER_ID=65532 +WORKDIR $APP_HOME -ENV PATH="${PYTHON_DEPS}/bin:${PATH}" -ENV PYTHONPATH="${PYTHON_DEPS}:${PYTHONPATH}" +# Copy wheels from builder stage +COPY --chown=$APP_USER:$APP_NAME --from=builder /usr/src/app/wheels $WHEELDIR -COPY --chown=$NONROOT_USER_ID:$NONROOT_USER_ID --from=build_env $HOME $HOME -COPY --chown=$NONROOT_USER_ID:$NONROOT_USER_ID . $APP_HOME -WORKDIR $APP_HOME -USER $NONROOT_USER_ID +# Create and configure virtual environment +RUN python -m venv $VIRTUALENV && \ + $VIRTUALENV/bin/pip install --no-cache-dir $WHEELDIR/* && \ + rm -rf $WHEELDIR + +# Make sure setuptools is always up to date +RUN pip install --upgrade setuptools + +# Copy project files +COPY --chown=$APP_USER:$APP_NAME server/ $APP_HOME + +# copy sshd_config file +COPY server/sshd_config /etc/ssh/ + +# copy version file +# COPY version.txt $APP_HOME + +# Setup entrypoint and create staticfiles +RUN chmod +x $APP_HOME/entrypoint.prod.sh && \ + rm -f $APP_HOME/sshd_config && \ + mkdir -p $APP_HOME/staticfiles && \ + SECRET_KEY=t ALLOWED_HOSTS=* DB_NAME=d DB_USER=d DB_PASSWORD=d DB_HOST=d DB_PORT=1 python -m manage collectstatic --no-input -EXPOSE 8080 +# Set entrypoint and default command +# Wrapping the dynamic entrypoint call to ensure parameters are passed through correctly +RUN echo '#!/bin/bash\n"${APP_HOME}/entrypoint.prod.sh" "$@"' > /entrypoint-wrapper.sh && \ + chmod +x /entrypoint-wrapper.sh -ENTRYPOINT [ "python", "./entrypoint.prod.py" ] +ENTRYPOINT ["/entrypoint-wrapper.sh"] +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "server.wsgi"] diff --git a/server/entrypoint.prod.sh b/server/entrypoint.prod.sh new file mode 100644 index 00000000..7b106f4c --- /dev/null +++ b/server/entrypoint.prod.sh @@ -0,0 +1,25 @@ +#!/bin/bash +echo "Starting SSH server..." +service ssh start + +echo "Generating static files..." +python manage.py collectstatic --no-input + +if [ ! -z "$DB_HOST" ] && [ ! -z "$DB_PORT" ]; then + echo "Waiting for postgres ($DB_HOST:$DB_PORT)..." + + while ! nc -z $DB_HOST $DB_PORT; do + sleep 0.1 + done + sleep 1 + + echo "PostgreSQL started" + echo "applying migrations..." + python manage.py migrate + echo "migrations applied" + +fi + +eval $(printenv | sed -n "s/^\([^=]\+\)=\(.*\)$/export \1=\2/p" | sed 's/"/\\\"/g' | sed '/=/s//="/' | sed 's/$/"/' >> /etc/profile) + +exec gosu ${APP_USER} "$@" diff --git a/server/server/settings.py b/server/server/settings.py index 03d2bf47..d37826e1 100644 --- a/server/server/settings.py +++ b/server/server/settings.py @@ -16,7 +16,7 @@ from django.urls import reverse_lazy -from decouple import Csv +from decouple import Csv, config from server.config_util import get_project_config, is_running_tests @@ -25,7 +25,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent -config = get_project_config() +# config = get_project_config() IS_LOCAL = config("IS_LOCAL", cast=bool, default=False) IS_DEV = config("IS_DEV", cast=bool, default=False) @@ -96,7 +96,7 @@ # Additional CORS allowed and CSRF trusted origins should be empty until if/when the app # is serving a REST/GraphQL API for external consumption CORS_ALLOWED_ORIGINS = [] -CSRF_TRUSTED_ORIGINS = [] +CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default="", cast=Csv()) # Prod only security settings if not IS_DEV: diff --git a/server/sshd_config b/server/sshd_config new file mode 100644 index 00000000..c6ceb581 --- /dev/null +++ b/server/sshd_config @@ -0,0 +1,13 @@ +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes +Subsystem sftp internal-sftp +