diff --git a/.github/workflows/multi-build-typesense.yaml b/.github/workflows/multi-build-typesense.yaml index 3c7871d4..35b83853 100644 --- a/.github/workflows/multi-build-typesense.yaml +++ b/.github/workflows/multi-build-typesense.yaml @@ -217,7 +217,7 @@ jobs: security-events: write steps: - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.34.0 env: TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db with: diff --git a/.github/workflows/multi-build.yaml b/.github/workflows/multi-build.yaml index 0311c76b..765841b9 100644 --- a/.github/workflows/multi-build.yaml +++ b/.github/workflows/multi-build.yaml @@ -124,6 +124,13 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hardened Images Registry + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Build and push by digest # This step builds and pushes the Docker image using Buildx. # It uses the docker/build-push-action to build the image with the specified context and platforms. @@ -291,7 +298,7 @@ jobs: security-events: write steps: - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.34.0 env: TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db with: diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 5fbfc751..c8a26e9c 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -12,6 +12,8 @@ jobs: secret-scan: name: Scan project for secrets runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 60ea9284..3af1d73a 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ celerybeat-schedule # dotenv .env +.env.act # virtualenv venv/ diff --git a/Dockerfile b/Dockerfile index 6eaf7fd2..a245cb6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,36 @@ # syntax=docker/dockerfile:1 -# Prepare the base environment. -FROM python:3.13-slim-bookworm AS builder_base - -# Install required OS packages. -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential gdal-bin libgdal-dev \ - && rm -rf /var/lib/apt/lists/* - -# This approximately follows this guide: https://hynek.me/articles/docker-uv/ -# Which creates a standalone environment with the dependencies. -# - Silence uv complaining about not being able to use hard links, -# - tell uv to byte-compile packages for faster application startups, -# - prevent uv from accidentally downloading isolated Python builds, -# - pick a Python, -# - and finally declare `/app` as the target for `uv sync`. -ENV UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=never \ - UV_PROJECT_ENVIRONMENT=/app/.venv - -COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/ - -# Since there's no point in shipping lock files, we move them -# into a directory that is NOT copied into the runtime image. -# The trailing slash makes COPY create `/_lock/` automagically. -WORKDIR /_lock -COPY pyproject.toml uv.lock /_lock/ - -# Synchronize dependencies. -# This layer is cached until uv.lock or pyproject.toml change. -RUN --mount=type=cache,target=/root/.cache uv sync --frozen --no-group dev - -################################################################################## - -FROM python:3.13-slim-bookworm +FROM dhi.io/python:3.13-debian13-dev AS build-stage LABEL org.opencontainers.image.authors=asi@dbca.wa.gov.au LABEL org.opencontainers.image.source=https://github.com/dbca-wa/prs -# Install required OS packages. -RUN apt-get update \ - && apt-get upgrade -y \ - && apt-get install -y --no-install-recommends gdal-bin proj-bin libmagic-dev \ +# Install system packages required to run the project +RUN apt-get update -y \ + # Python package dependencies: fiona requires libgdal-dev and gcc, python-magic requires libmagic1t64 + && apt-get install -y --no-install-recommends libgdal-dev gcc g++ gdal-bin proj-bin libmagic1t64 \ + # Run shared library linker after installing packages + && ldconfig \ && rm -rf /var/lib/apt/lists/* -# Create a non-root user. -RUN groupadd -r -g 1000 app \ - && useradd -r -u 1000 -d /app -g app -N app - +# Import uv to install dependencies +COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /bin/ WORKDIR /app -COPY --from=builder_base --chown=app:app /app /app -# Make sure we use the virtualenv by default. -# Run Python unbuffered. -ENV PATH=/app/.venv/bin:$PATH \ - PYTHONUNBUFFERED=1 - -# Install the project. +# Install project dependencies +COPY pyproject.toml uv.lock ./ +RUN uv sync --no-group dev --link-mode=copy --compile-bytecode --no-python-downloads --frozen \ + # Remove uv and lockfile after use + && rm -rf /bin/uv \ + && rm uv.lock + +# Copy the remaining project files to finish building the project COPY gunicorn.py manage.py pyproject.toml ./ COPY prs2 ./prs2 +ENV PYTHONUNBUFFERED=1 +ENV PATH="/app/.venv/bin:$PATH" +# Compile scripts and collect static files RUN python -m compileall prs2 \ && python manage.py collectstatic --noinput -USER app + +# Run the project as the nonroot user +USER nonroot EXPOSE 8080 CMD ["gunicorn", "prs2.wsgi", "--config", "gunicorn.py"] diff --git a/kustomize/base/celery.yaml b/kustomize/base/celery.yaml index 01372fab..cec21af7 100644 --- a/kustomize/base/celery.yaml +++ b/kustomize/base/celery.yaml @@ -32,7 +32,7 @@ spec: cpu: '1000m' securityContext: runAsNonRoot: true - runAsUser: 1000 + runAsUser: 65532 privileged: false allowPrivilegeEscalation: false capabilities: diff --git a/kustomize/base/deployment.yaml b/kustomize/base/deployment.yaml index a2178854..0a71d466 100644 --- a/kustomize/base/deployment.yaml +++ b/kustomize/base/deployment.yaml @@ -25,7 +25,7 @@ spec: resources: requests: memory: '100Mi' - cpu: '5m' + cpu: '10m' limits: memory: '2Gi' cpu: '1000m' @@ -61,7 +61,7 @@ spec: timeoutSeconds: 10 securityContext: runAsNonRoot: true - runAsUser: 1000 + runAsUser: 65532 privileged: false allowPrivilegeEscalation: false capabilities: diff --git a/kustomize/template/cronjob.yaml b/kustomize/template/cronjob.yaml index 9a8b48dc..9a5da24b 100644 --- a/kustomize/template/cronjob.yaml +++ b/kustomize/template/cronjob.yaml @@ -25,7 +25,7 @@ spec: value: 'Australia/Perth' securityContext: runAsNonRoot: true - runAsUser: 1000 + runAsUser: 65532 privileged: false allowPrivilegeEscalation: false capabilities: diff --git a/prs2/harvester/models.py b/prs2/harvester/models.py index d5e4f147..6fbc82dc 100644 --- a/prs2/harvester/models.py +++ b/prs2/harvester/models.py @@ -374,29 +374,29 @@ def harvest(self, create_tasks=True, create_locations=True, create_records=True, else: addresses = app["ADDRESS_DETAIL"]["DOP_ADDRESS_TYPE"] - for a in addresses: + for address in addresses: # Use the long/lat info to intersect DBCA regions. try: - p = Point(x=float(a["LONGITUDE"]), y=float(a["LATITUDE"])) + p = Point(x=float(address["LONGITUDE"]), y=float(address["LATITUDE"])) for r in Region.objects.all(): if r.region_mpoly and r.region_mpoly.intersects(p) and r not in regions: regions.append(r) intersected_region = True except Exception: - log = f"Address long/lat could not be parsed ({a['LONGITUDE']}, {a['LATITUDE']})" + log = f"Address long/lat could not be parsed ({address['LONGITUDE']}, {address['LATITUDE']})" LOGGER.warning(log) self.log = f"{log}\n" actions.append(f"{datetime.now().isoformat()} {log}") intersected_region = False # Use the PIN field to query SLIP for geometry. - if "PIN" in a and a["PIN"]: + if "PIN" in address and address["PIN"]: try: - resp = query_slip_esri(a["PIN"]) + resp = query_slip_esri(address["PIN"]) features = resp["features"] # List of spatial features. if len(features) > 0: - a["FEATURES"] = features - locations.append(a) # A dict for each address location. + address["FEATURES"] = features + locations.append(address) # A dict for each address location. # If we haven't yet, use the feature geom to intersect DBCA regions. if not intersected_region: for f in features: @@ -406,7 +406,7 @@ def harvest(self, create_tasks=True, create_locations=True, create_records=True, for r in Region.objects.all(): if r.region_mpoly and r.region_mpoly.intersects(p) and r not in regions: regions.append(r) - log = f"Address PIN {a['PIN']} returned geometry from SLIP" + log = f"Address PIN {address['PIN']} returned geometry from SLIP" self.log = self.log + f"{log}\n" LOGGER.info(log) except Exception as e: @@ -414,7 +414,7 @@ def harvest(self, create_tasks=True, create_locations=True, create_records=True, LOGGER.error(log) LOGGER.exception(e) else: - log = f"Address PIN could not be parsed ({a['PIN']})" + log = f"Address PIN could not be parsed ({address['PIN']})" LOGGER.warning(log) self.log = self.log + f"{log}\n" diff --git a/prs2/referral/forms.py b/prs2/referral/forms.py index b02284dc..46c3ae94 100644 --- a/prs2/referral/forms.py +++ b/prs2/referral/forms.py @@ -133,7 +133,7 @@ def __init__(self, *args, **kwargs): self.form_class = "form-horizontal" self.form_method = "POST" self.label_class = "col-xs-12 col-sm-4 col-md-3 col-lg-2" - self.field_class = "col-xs-12 col-sm-8 col-md-6 col-lg-4" + self.field_class = "col-xs-12 col-sm-8 col-md-10" self.help_text_inline = True self.attrs = {"novalidate": ""} @@ -407,9 +407,10 @@ def __init__(self, *args, **kwargs): self.fields["infobase_id"].help_text = """To link to an Infobase record, enter the Infobase object ID exactly as it appears in Infobase (i.e. case-sensitive, no spaces). E.g.: eA498596""" + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) # Add in a "Save and add another" button. save_another_button = Submit("save-another", "Save and add another") - save_another_button.field_classes = " btn btn-default" + save_another_button.field_classes = "btn btn-secondary" layout = Layout( "name", "uploaded_file", @@ -528,6 +529,7 @@ def __init__(self, *args, **kwargs): instead.""" self.fields["description"].help_text = """To comment on the completion stage of the task, ADD details to this description.""" + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "state", "complete_date", @@ -562,8 +564,8 @@ class TaskStopForm(BaseForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["description"].required = True - self.fields["description"].help_text = """Please describe why the - task was stopped.""" + self.fields["description"].help_text = "Please describe why the task was stopped." + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "stopped_date", "description", @@ -601,8 +603,8 @@ def __init__(self, *args, **kwargs): self.fields["due_date"].label = "Revised due date" self.fields["restart_date"].required = True self.fields["restart_date"].input_formats = settings.DATE_INPUT_FORMATS - self.fields["description"].help_text += """ ADD to the description a - brief explanation for restarting the task.""" + self.fields["description"].help_text += " ADD to the description a brief explanation for restarting the task." + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "due_date", "restart_date", @@ -633,6 +635,7 @@ def __init__(self, *args, **kwargs): self.fields["due_date"].widget = forms.DateInput(format="%d/%m/%Y") self.fields["due_date"].input_formats = settings.DATE_INPUT_FORMATS self.fields["due_date"].required = True + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "assigned_user", "email_user", @@ -655,8 +658,8 @@ class TaskCancelForm(BaseForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["description"].help_text = """Please record why the task - was cancelled (optional).""" + self.fields["description"].help_text = "Please record why the task was cancelled (optional)." + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "description", Div(self.save_button, self.cancel_button, css_class="col-sm-offset-4 col-md-offset-3 col-lg-offset-2") ) @@ -709,6 +712,7 @@ def __init__(self, *args, **kwargs): self.fields["restart_date"].widget = forms.DateInput(format="%d/%m/%Y") self.fields["restart_date"].input_formats = settings.DATE_INPUT_FORMATS self.fields["state"].queryset = TaskState.objects.current().filter(Q(task_type=self.instance.type) | Q(task_type=None)) + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "assigned_user", @@ -804,6 +808,7 @@ def __init__(self, *args, **kwargs): and "Provide pre-referral/preliminary advice" tasks will be auto-completed if no due date is recorded.""" self.fields["description"].required = True + self.fields["description"].widget = forms.Textarea(attrs={"cols": "40", "rows": "4"}) layout = Layout( "assigned_user", "email_user", @@ -908,7 +913,7 @@ def __init__(self, *args, **kwargs): clicking Save and add another.

""") # Add in a "Save and add another" button. save_another_button = Submit("save-another", "Save and add another") - save_another_button.field_classes = "btn btn-default" + save_another_button.field_classes = "btn btn-secondary" layout = Layout( help_html, "model_condition", diff --git a/prs2/referral/templates/referral/change_form.html b/prs2/referral/templates/referral/change_form.html index a32c8637..5cb655b9 100644 --- a/prs2/referral/templates/referral/change_form.html +++ b/prs2/referral/templates/referral/change_form.html @@ -1,26 +1,31 @@ {% extends "base_prs.html" %} {% load static %} {% load crispy_forms_tags %} - {% block extra_head %} -{{ block.super }} -{{ form.media }} - + {{ block.super }} + {{ form.media }} + {% endblock %} - {% block page_content_inner %} -

{{ title }}

-{% if form.errors %} - -{% endif %} -{% crispy form %} +

{{ title }}

+ {% if form.errors %}{% endif %} + {% crispy form %} {% endblock %} - {% block extra_js %} -{{ block.super }} - - - + + + {% endblock %} diff --git a/prs2/referral/templates/referral/referral_create.html b/prs2/referral/templates/referral/referral_create.html index 3e07e2e5..c46d27fa 100644 --- a/prs2/referral/templates/referral/referral_create.html +++ b/prs2/referral/templates/referral/referral_create.html @@ -1,44 +1,53 @@ {% extends "base_prs.html" %} {% load static %} {% load crispy_forms_tags %} - {% block extra_style %} -{{ block.super }} - + {{ block.super }} + {% endblock %} - {% block page_content_inner %} -

{{ title }}

-{% if form.errors %} - -{% endif %} - -{% crispy form %} - - -