Adding localstack to docker and S3 file storage testing (#26)

* Adding localstack to docker

* Unit tests for localstack S3 working

* Github flow

* Github flow fix s3 tests

* Playing with github actions, tests working on dev via localhost and docker

* Try using IP

* Try using github services in CI

* Try without volumes on services

* Try something else..'

* Use seperate docker compose yaml for CI

* Specify docker compose ym;l file to use for test

* Fix -f option

* volume mounting

* try mount again

* try use permissions

* Update dir for permissioning

* Update create bucket script to output commands

* Try to create bucket

* Try using awscli not awslocal

* Add region

* Add connection timeout

* Add overwrite

* Add debug

* More debug

* Use conftest to create s3 bucket instead

* Adding health check for localstack service

* Try netwrok mode bridge for tests

* Try some other stuff

* Ignore when failing create bucket if exists

* Make sure github actions is using the localhost as the ip for selenium

* Try setting values from the docker compose for diff envs

* Try using network mode host

* Remove ports

* Use container name instead of docker-compose run

* Clean up host variables

* Clean up code

Co-authored-by: Thu Trang Pham <thu@joinmodernhealth.com>
main
Thu Trang Pham 2021-06-22 11:17:21 -07:00 committed by GitHub
parent 50e42fa8e7
commit 5a085012c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 372 additions and 22 deletions

View File

@ -16,6 +16,34 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
selenium:
image: selenium/standalone-firefox:latest
ports:
- "4444:4444" # Selenium
- "5900:5900" # VNC
localstack:
image: localstack/localstack:latest
env:
SERVICES: s3
DEFAULT_REGION: us-west-1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
# enable persistance
DATA_DIR: /tmp/localstack/data
LAMBDA_EXECUTOR: local
DOCKER_HOST: unix:///var/run/docker.sock
DEBUG: true
volumes:
# It doesn't seem like the scripts in entrypoint are being ran... or they are not copied over since
# the checkout action happens after init services on Github Actions
# - "${{ github.workspace }}/docker-entrypoint-initaws.d:/docker-entrypoint-initaws.d"
- "${{ github.workspace }}/tmp/localstack:/tmp/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
ports:
- 4566:4566
- 4571:4571
options: --health-cmd="curl http://localhost:4566/health?reload" --health-interval=10s --health-timeout=5s --health-retries=3
strategy: strategy:
matrix: matrix:
python-version: [3.6, 3.7, 3.8, 3.9] python-version: [3.6, 3.7, 3.8, 3.9]
@ -24,22 +52,36 @@ jobs:
DJANGO_VERSION: ${{ matrix.django-version }} DJANGO_VERSION: ${{ matrix.django-version }}
PYTHON_VERSION: ${{ matrix.python-version }} PYTHON_VERSION: ${{ matrix.python-version }}
COMPOSE_INTERACTIVE_NO_CLI: 1 COMPOSE_INTERACTIVE_NO_CLI: 1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-west-1
steps: steps:
- name: Update Permissions
run: |
sudo chown -R $USER:$USER ${{ github.workspace }}
# required because actions/checkout@2 wants to delete the /tmp/localstack folder
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build Docker for Python 3.6 - name: Build Docker for Python 3.6
if: ${{ matrix.python-version == 3.6 }} if: ${{ matrix.python-version == 3.6 }}
run: | run: |
export SELENIUM_VERSION=3.141.0 export SELENIUM_VERSION=3.141.0
docker-compose build docker-compose -f docker-compose.ci.yml up -d --build
- name: Build Docker for other Python versions - name: Build Docker for other Python versions
if: ${{ matrix.python-version != 3.6 }} if: ${{ matrix.python-version != 3.6 }}
run: | run: |
export SELENIUM_VERSION=4.0.0a7 export SELENIUM_VERSION=4.0.0a7
docker-compose build docker-compose -f docker-compose.ci.yml up -d --build
- name: Start Docker - name: Attempt to connect to localstack and create bucket
run: docker-compose up -d run: |
curl -X GET http://localhost:4566/health
aws --endpoint-url http://localhost:4566 s3 mb s3://mybucket 2> /dev/null || true
# Since docker-entrypoint-initaws.d can't be used to create the s3 bucket on CI
- name: Integration Test - name: Integration Test
run: docker-compose run web make test-all run: |
docker exec -t web_main make test-all
# Known Issue: docker-compose cannot run/exec in container via service name when in host network_mode.
# See: https://github.com/docker/compose/issues/4548
# IE: this doesn't work: docker-compose -f docker-compose.ci.yml run web make test-all
- name: Coveralls - name: Coveralls
uses: AndreMiras/coveralls-python-action@develop uses: AndreMiras/coveralls-python-action@develop
with: with:

View File

@ -5,8 +5,10 @@ ENV USE_DOCKER=true
WORKDIR /code WORKDIR /code
COPY . /code/ COPY . /code/
ARG DJANGO_VERSION="3.1.7" ARG DJANGO_VERSION="3.1.7"
RUN echo "Installing Django Version: ${DJANGO_VERSION}"
RUN pip install django==${DJANGO_VERSION} RUN pip install django==${DJANGO_VERSION}
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN pip install -e . RUN pip install -e .
ARG SELENIUM_VERSION="4.0.0a7" ARG SELENIUM_VERSION="4.0.0a7"
RUN echo "Installing Selenium Version: ${SELENIUM_VERSION}"
RUN pip install selenium~=${SELENIUM_VERSION} RUN pip install selenium~=${SELENIUM_VERSION}

View File

@ -4,6 +4,8 @@ from django.core.cache import cache
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import LiveServerTestCase from django.test import LiveServerTestCase
from tests.test_project.settings import SELENIUM_HOST
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.support.ui import Select from selenium.webdriver.support.ui import Select
@ -76,7 +78,7 @@ class AdminConfirmIntegrationTestCase(LiveServerTestCase):
def setUpClass(cls): def setUpClass(cls):
cls.host = socket.gethostbyname(socket.gethostname()) cls.host = socket.gethostbyname(socket.gethostname())
cls.selenium = webdriver.Remote( cls.selenium = webdriver.Remote(
command_executor="http://selenium:4444/wd/hub", command_executor=f"http://{SELENIUM_HOST}:4444/wd/hub",
desired_capabilities=DesiredCapabilities.FIREFOX, desired_capabilities=DesiredCapabilities.FIREFOX,
) )
super().setUpClass() super().setUpClass()

View File

@ -12,7 +12,6 @@ from tests.market.admin import item_admin, shoppingmall_admin
from django.contrib.admin import VERTICAL from django.contrib.admin import VERTICAL
from admin_confirm.constants import CONFIRM_ADD, CONFIRM_CHANGE from admin_confirm.constants import CONFIRM_ADD, CONFIRM_CHANGE
from selenium.webdriver.support.ui import Select
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By

View File

@ -0,0 +1,200 @@
"""
Tests confirmation of add/change
on ModelAdmin that utilize caches
and S3 as a storage backend
"""
import os
import pytest
import pkg_resources
import localstack_client.session
from importlib import reload
from selenium.webdriver.remote.file_detector import LocalFileDetector
from selenium.webdriver.common.by import By
from django.core.files.uploadedfile import SimpleUploadedFile
from django.conf import settings
from tests.market.models import Item
from admin_confirm.tests.helpers import AdminConfirmIntegrationTestCase
from tests.market.admin import shoppingmall_admin
from admin_confirm.constants import CONFIRM_CHANGE
class ConfirmWithS3StorageTests(AdminConfirmIntegrationTestCase):
def setUp(self):
self.selenium.file_detector = LocalFileDetector()
session = localstack_client.session.Session(region_name="us-west-1")
self.s3 = session.resource("s3")
self.bucket = self.s3.Bucket(settings.AWS_STORAGE_BUCKET_NAME)
# Delete all current files
for obj in self.bucket.objects.all():
obj.delete()
super().setUp()
def tearDown(self):
reload(shoppingmall_admin)
# Delete all current files
for obj in self.bucket.objects.all():
obj.delete()
super().tearDown()
def test_s3_is_being_used(self):
self.assertTrue(settings.USE_S3)
self.assertIsNotNone(settings.AWS_ACCESS_KEY_ID)
self.assertEqual(
settings.DEFAULT_FILE_STORAGE,
"tests.storage_backends.PublicMediaStorage",
)
def test_should_save_file_additions(self):
selenium_version = pkg_resources.get_distribution("selenium").parsed_version
if selenium_version.major < 4:
pytest.skip(
"Known issue `https://github.com/SeleniumHQ/selenium/issues/8762` with this selenium version."
)
item = Item.objects.create(
name="item", price=1, currency=Item.VALID_CURRENCIES[0][0]
)
self.selenium.get(
self.live_server_url + f"/admin/market/item/{item.id}/change/"
)
self.assertIn(CONFIRM_CHANGE, self.selenium.page_source)
# Make a change to trigger confirmation page
price = self.selenium.find_element(By.NAME, "price")
price.send_keys(2)
# Upload a new file
self.selenium.find_element(By.ID, "id_file").send_keys(
os.getcwd() + "/screenshot.png"
)
self.selenium.find_element(By.NAME, "_continue").click()
# Should have hidden form containing the updated price
self.assertIn("Confirm", self.selenium.page_source)
hidden_form = self.selenium.find_element(By.ID, "hidden-form")
price = hidden_form.find_element(By.NAME, "price")
self.assertEqual("21.00", price.get_attribute("value"))
self.selenium.find_element(By.NAME, "_confirmation_received")
self.selenium.find_element(By.NAME, "_continue").click()
item.refresh_from_db()
self.assertRegex(item.file.name, r"screenshot.*\.png$")
self.assertEqual(21, int(item.price))
# Check S3 for the file
objects = [obj for obj in self.bucket.objects.all()]
self.assertEqual(len(objects), 1)
self.assertRegex(objects[0].key, r"screenshot.*\.png$")
def test_should_save_file_changes(self):
selenium_version = pkg_resources.get_distribution("selenium").parsed_version
if selenium_version.major < 4:
pytest.skip(
"Known issue `https://github.com/SeleniumHQ/selenium/issues/8762` with this selenium version."
)
file = SimpleUploadedFile(
name="old_file.jpg",
content=open("screenshot.png", "rb").read(),
content_type="image/jpeg",
)
item = Item.objects.create(
name="item", price=1, currency=Item.VALID_CURRENCIES[0][0], file=file
)
self.selenium.get(
self.live_server_url + f"/admin/market/item/{item.id}/change/"
)
self.assertIn(CONFIRM_CHANGE, self.selenium.page_source)
# Make a change to trigger confirmation page
price = self.selenium.find_element(By.NAME, "price")
price.send_keys(2)
# Upload a new file
self.selenium.find_element(By.ID, "id_file").send_keys(
os.getcwd() + "/screenshot.png"
)
self.selenium.find_element(By.NAME, "_continue").click()
# Should have hidden form containing the updated price
self.assertIn("Confirm", self.selenium.page_source)
hidden_form = self.selenium.find_element(By.ID, "hidden-form")
price = hidden_form.find_element(By.NAME, "price")
self.assertEqual("21.00", price.get_attribute("value"))
self.selenium.find_element(By.NAME, "_confirmation_received")
self.selenium.find_element(By.NAME, "_continue").click()
item.refresh_from_db()
self.assertEqual(21, int(item.price))
self.assertRegex(item.file.name, r"screenshot.*\.png$")
# Check S3 for the file
objects = [obj for obj in self.bucket.objects.all()]
self.assertEqual(len(objects), 2)
get_last_modified = lambda obj: int(obj.last_modified.strftime("%s"))
objects_by_last_modified = [
obj for obj in sorted(objects, key=get_last_modified)
]
self.assertRegex(objects_by_last_modified[-1].key, r"screenshot.*\.png$")
self.assertRegex(objects_by_last_modified[0].key, r"old_file.*\.jpg$")
def test_should_remove_file_if_clear_selected(self):
file = SimpleUploadedFile(
name="old_file.jpg",
content=open("screenshot.png", "rb").read(),
content_type="image/jpeg",
)
item = Item.objects.create(
name="item", price=1, currency=Item.VALID_CURRENCIES[0][0], file=file
)
self.selenium.get(
self.live_server_url + f"/admin/market/item/{item.id}/change/"
)
self.assertIn(CONFIRM_CHANGE, self.selenium.page_source)
# Make a change to trigger confirmation page
price = self.selenium.find_element(By.NAME, "price")
price.send_keys(2)
# Choose to clear the existing file
self.selenium.find_element(By.ID, "file-clear_id").click()
self.assertTrue(
self.selenium.find_element(
By.XPATH, ".//*[@id='file-clear_id']"
).get_attribute("checked")
)
self.selenium.find_element(By.NAME, "_continue").click()
# Should have hidden form containing the updated price
self.assertIn("Confirm", self.selenium.page_source)
hidden_form = self.selenium.find_element(By.ID, "hidden-form")
price = hidden_form.find_element(By.NAME, "price")
self.assertEqual("21.00", price.get_attribute("value"))
self.selenium.find_element(By.NAME, "_confirmation_received")
self.selenium.find_element(By.NAME, "_continue").click()
item.refresh_from_db()
self.assertEqual(21, int(item.price))
# Should have cleared `file` since clear was selected
self.assertFalse(item.file)
# Check S3 for the file
# Since deleting from model instance in Django does not automatically
# delete from storage, the old file should still be in S3
objects = [obj for obj in self.bucket.objects.all()]
self.assertEqual(len(objects), 1)
self.assertRegex(objects[0].key, r"old_file.*\.jpg$")

View File

@ -12,15 +12,5 @@ services:
command: python tests/manage.py runserver 0.0.0.0:8000 command: python tests/manage.py runserver 0.0.0.0:8000
volumes: volumes:
- .:/code - .:/code
ports: network_mode: host
- "8000:8000" container_name: web_main
depends_on:
- selenium
selenium:
# image: selenium/standalone-firefox
image: selenium/standalone-firefox-debug:latest
ports:
- "4444:4444" # Selenium
- "5900:5900" # VNC
volumes:
- .:/code

View File

@ -0,0 +1,51 @@
version: "3.9"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
PYTHON_VERSION: "$PYTHON_VERSION"
DJANGO_VERSION: "$DJANGO_VERSION"
SELENIUM_VERSION: "$SELENIUM_VERSION"
command: python tests/manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
ports:
- "8000:8000"
depends_on:
- selenium
- localstack
environment:
- SELENIUM_HOST=selenium
# Used for localstack_client as well as our project
- LOCALSTACK_HOST=host.docker.internal
selenium:
image: selenium/standalone-firefox
ports:
- "4444:4444" # Selenium
- "5900:5900" # VNC
volumes:
- .:/code
localstack:
image: localstack/localstack
container_name: localstack_main
network_mode: bridge
ports:
- "4566:4566"
- "4571:4571"
environment:
- SERVICES=s3
- DEBUG=true
# enable persistance
- DATA_DIR=/tmp/localstack/data
- LAMBDA_EXECUTOR=docker
- DOCKER_HOST=unix:///var/run/docker.sock
- HOSTNAME_EXTERNAL=localstack
volumes:
- "./docker-entrypoint-initaws.d:/docker-entrypoint-initaws.d"
- "./tmp/localstack:/tmp/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"

View File

@ -0,0 +1,4 @@
#!/bin/bash
set -x
awslocal s3 mb s3://mybucket
set +x

View File

@ -17,7 +17,7 @@ These are some areas which might/probably have issues that are not currently tes
## Save Options ## Save Options
- [x] Save - [x] Save
- [x] Conitnue - [x] Continue
- [x] Save As New - [x] Save As New
- [x] Add another - [x] Add another

View File

@ -8,9 +8,17 @@ twine~=3.3.0
coveralls~=3.0.0 coveralls~=3.0.0
Pillow~=8.1.0 # For ImageField Pillow~=8.1.0 # For ImageField
### SELENIUM ###
# Known issue: https://github.com/SeleniumHQ/selenium/issues/8762 # Known issue: https://github.com/SeleniumHQ/selenium/issues/8762
# Python 3.6 should use because selenium 4 doesn't work with py3.6 # Python 3.6 should use because selenium 4 doesn't work with py3.6
# selenium~=3.141.0 # selenium~=3.141.0
# Others should use # Others should use
selenium~=4.0.0.a5 selenium~=4.0.0.a5
### END SELENIUM ###
### S3 ###
localstack~=0.12.9.1 # For testing with S3
django-storages~=1.11.1
boto3~=1.17.47
### END S3 ###

View File

@ -25,4 +25,4 @@ branch = True
[tool:pytest] [tool:pytest]
DJANGO_SETTINGS_MODULE=tests.test_project.settings.test DJANGO_SETTINGS_MODULE=tests.test_project.settings.test
addopts = --doctest-modules -ra -l --tb=short --show-capture=all --color=yes addopts = --doctest-modules -ra -l --tb=short --show-capture=all --color=yes
testpaths = admin_confirm testpaths = admin_confirm/tests

View File

@ -0,0 +1,12 @@
from storages.backends.s3boto3 import S3Boto3Storage
class StaticStorage(S3Boto3Storage):
location = "static"
default_acl = "public-read"
class PublicMediaStorage(S3Boto3Storage):
location = "media"
default_acl = "public-read"
file_overwrite = False

View File

@ -43,6 +43,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"storages",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -114,8 +115,43 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
# Setting the hostnames of the services which we are running
# On github actions, services can be configured on the jobs themselves
# and can be accessed at localhost. See: https://docs.github.com/en/actions/guides/about-service-containers#communicating-with-service-containers
LOCALSTACK_HOST = os.getenv("LOCALSTACK_HOST", "localhost")
SELENIUM_HOST = os.getenv("SELENIUM_HOST", "localhost")
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/ # https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = "/static/" # STATIC_URL = "/static/"
# S3 Storage settings
USE_S3 = os.getenv("USE_S3", "true").lower() == "true"
if USE_S3:
# aws settings
AWS_S3_ENDPOINT_URL = f"http://{LOCALSTACK_HOST}:4566"
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "test")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "test")
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "mybucket")
AWS_DEFAULT_ACL = None
AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
# s3 static settings
STATIC_LOCATION = "static"
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
STATICFILES_STORAGE = "tests.storage_backends.StaticStorage"
# s3 public media settings
PUBLIC_MEDIA_LOCATION = "media"
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}/"
DEFAULT_FILE_STORAGE = "tests.storage_backends.PublicMediaStorage"
else:
STATIC_URL = "/staticfiles/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
MEDIA_URL = "/mediafiles/"
MEDIA_ROOT = os.path.join(BASE_DIR, "mediafiles")
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)

View File

@ -3,3 +3,5 @@ from .base import *
INSTALLED_APPS = INSTALLED_APPS + ["market"] INSTALLED_APPS = INSTALLED_APPS + ["market"]
WSGI_APPLICATION = "test_project.wsgi.application" WSGI_APPLICATION = "test_project.wsgi.application"
ROOT_URLCONF = "test_project.urls" ROOT_URLCONF = "test_project.urls"
USE_S3 = "True"

View File

@ -3,3 +3,5 @@ from .base import *
INSTALLED_APPS = INSTALLED_APPS + ["tests.market"] INSTALLED_APPS = INSTALLED_APPS + ["tests.market"]
WSGI_APPLICATION = "tests.test_project.wsgi.application" WSGI_APPLICATION = "tests.test_project.wsgi.application"
ROOT_URLCONF = "tests.test_project.urls" ROOT_URLCONF = "tests.test_project.urls"
USE_S3 = "True"