Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/trial_artmng.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions

name: Build and deploy Python app to Azure Web App - artmng

on:
push:
branches:
- trial
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read #This is required for actions/checkout

steps:
- uses: actions/checkout@v4

- name: Set up Python version
uses: actions/setup-python@v5
with:
python-version: '3.10'

# 🛠️ Local Build Section (Optional)
# The following section in your workflow is designed to catch build issues early on the client side, before deployment. This can be helpful for debugging and validation. However, if this step significantly increases deployment time and early detection is not critical for your workflow, you may remove this section to streamline the deployment process.
- name: Create and Start virtual environment and Install dependencies
run: |
python -m venv antenv
source antenv/bin/activate
pip install -r requirements.txt

# By default, when you enable GitHub CI/CD integration through the Azure portal, the platform automatically sets the SCM_DO_BUILD_DURING_DEPLOYMENT application setting to true. This triggers the use of Oryx, a build engine that handles application compilation and dependency installation (e.g., pip install) directly on the platform during deployment. Hence, we exclude the antenv virtual environment directory from the deployment artifact to reduce the payload size.
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v4
with:
name: python-app
path: |
.
!antenv/

# 🚫 Opting Out of Oryx Build
# If you prefer to disable the Oryx build process during deployment, follow these steps:
# 1. Remove the SCM_DO_BUILD_DURING_DEPLOYMENT app setting from your Azure App Service Environment variables.
# 2. Refer to sample workflows for alternative deployment strategies: https://github.com/Azure/actions-workflow-samples/tree/master/AppService


deploy:
runs-on: ubuntu-latest
needs: build
permissions:
id-token: write #This is required for requesting the JWT
contents: read #This is required for actions/checkout

steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: python-app

- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_169DE98B41014829AB2C11AA0F6C5290 }}
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_6AD74AE587804472A09570B3A8FC0984 }}
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_82306B0D00B5492E9D05EB24E9259DCE }}

- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
id: deploy-to-webapp
with:
app-name: 'artmng'
slot-name: 'Production'

3 changes: 3 additions & 0 deletions FlaskWebProject/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
app = Flask(__name__)
app.config.from_object(Config)
# TODO: Add any logging levels and handlers with app.logger
streamHandler = logging.StreamHandler()
streamHandler.setLevel(logging.INFO)
app.logger.addHandler(streamHandler)
Session(app)
db = SQLAlchemy(app)
login = LoginManager(app)
Expand Down
1 change: 1 addition & 0 deletions FlaskWebProject/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class LoginForm(FlaskForm):

class PostForm(FlaskForm):
title = StringField('Title', validators=[DataRequired()])
subtitle = StringField('Subtitle', validators=[DataRequired()])
author = StringField('Author', validators=[DataRequired()])
body = TextAreaField('Body', validators=[DataRequired()])
image_path = FileField('Image', validators=[FileAllowed(['jpg', 'png'], 'Images only!')])
Expand Down
2 changes: 2 additions & 0 deletions FlaskWebProject/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150))
subtitle = db.Column(db.String(150))
author = db.Column(db.String(75))
body = db.Column(db.String(800))
image_path = db.Column(db.String(100))
Expand All @@ -47,6 +48,7 @@ def __repr__(self):

def save_changes(self, form, file, userId, new=False):
self.title = form.title.data
self.subtitle = form.subtitle.data
self.author = form.author.data
self.body = form.body.data
self.user_id = userId
Expand Down
2 changes: 2 additions & 0 deletions FlaskWebProject/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<tr>
<th scope="col">ID</th>
<th scope="col">Title</th>
<th scope="col">Subtitle</th>
<th scope="col">Author</th>
<th scope="col">Has Image?</th>
<th scope="col" />
Expand All @@ -19,6 +20,7 @@
<tr>
<th scope="row">{{ post.id }} </th>
<td>{{ post.title }}</td>
<td>{{ post.subtitle }}</td>
<td>{{ post.author }}</td>
<td>{{ post.image_path != None }}</td>
<td><a href="{{ url_for('post', id=post.id) }}">Edit Post</a></td>
Expand Down
7 changes: 7 additions & 0 deletions FlaskWebProject/templates/post.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ <h1>{{ title }}</h1>
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.subtitle.label }}<br>
{{ form.subtitle(size=64) }}
{% for error in form.subtitle.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.author.label }}<br>
{{ form.author(size=32) }}
Expand Down
27 changes: 22 additions & 5 deletions FlaskWebProject/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ def login():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
app.logger.warning('Invalid login attempt for user: {}'.format(form.username.data))
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('home')
app.logger.warning('User logged in successfully : {}'.format(user.username))
return redirect(next_page)
session["state"] = str(uuid.uuid4())
auth_url = _build_auth_url(scopes=Config.SCOPE, state=session["state"])
Expand All @@ -86,15 +88,20 @@ def authorized():
if request.args.get('code'):
cache = _load_cache()
# TODO: Acquire a token from a built msal app, along with the appropriate redirect URI
result = None
result = _build_msal_app(cache=cache).acquire_token_by_authorization_code(
request.args['code'],
scopes=Config.SCOPE,
redirect_uri=url_for('authorized', _external=True, _scheme='https'))
if "error" in result:
app.logger.warning('Azure AD authentication failed')
return render_template("auth_error.html", result=result)
session["user"] = result.get("id_token_claims")
# Note: In a real app, we'd use the 'name' property from session["user"] below
# Here, we'll use the admin username for anyone who is authenticated by MS
user = User.query.filter_by(username="admin").first()
login_user(user)
_save_cache(cache)
app.logger.warning('User logged in via Azure AD')
return redirect(url_for('home'))

@app.route('/logout')
Expand All @@ -112,17 +119,27 @@ def logout():

def _load_cache():
# TODO: Load the cache from `msal`, if it exists
cache = None
cache = msal.SerializableTokenCache()
if session.get('token_cache'):
cache.deserialize(session['token_cache'])
return cache

def _save_cache(cache):
# TODO: Save the cache, if it has changed
pass
if cache.has_state_changed:
session['token_cache'] = cache.serialize()

def _build_msal_app(cache=None, authority=None):
# TODO: Return a ConfidentialClientApplication
return None
return msal.ConfidentialClientApplication(
Config.CLIENT_ID,
authority=authority or Config.AUTHORITY,
client_credential=Config.CLIENT_SECRET,
token_cache=cache)

def _build_auth_url(authority=None, scopes=None, state=None):
# TODO: Return the full Auth Request URL with appropriate Redirect URI
return None
return _build_msal_app(authority=authority).get_authorization_request_url(
scopes or [],
state=state or str(uuid.uuid4()),
redirect_uri=url_for('authorized', _external=True, _scheme='https'))
26 changes: 25 additions & 1 deletion WRITEUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@
- *Choose the appropriate solution (VM or App Service) for deploying the app*
- *Justify your choice*

#### Azure Virtual Machine (IaaS)
- Cost is higher
- Scalability and Availability are developer's responsibility, managed via VM scale sets and load balancers
- Full control over VM but heavy operational overhead

#### Azure App Service (PaaS)
- Cost is lower and more predicatable
- Scalability and Availability are automatic and fast
- Almost zero server management

#### Chosen solution
**Azure App Service (Web App)**
##### Justification for choosing App Service over VM
- The application is described as a lightweight article management system so it's a perfect fit for PaaS.
- No need for access or control over the OS.
- Significantly lower operational overhead.
- Faster and cheaper scaling.
- Lower total cost.
- Higher SLA and better availability.


### Assess app changes that would change your decision.

*Detail how the app and any other needs would have to change for you to change your decision in the last section.*
*Detail how the app and any other needs would have to change for you to change your decision in the last section.*

- Requirement for accessing or controlling OS.
- Extremly high compute requirements the exceed the App Service limits.
20 changes: 10 additions & 10 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'secret-key'

BLOB_ACCOUNT = os.environ.get('BLOB_ACCOUNT') or 'ENTER_STORAGE_ACCOUNT_NAME'
BLOB_STORAGE_KEY = os.environ.get('BLOB_STORAGE_KEY') or 'ENTER_BLOB_STORAGE_KEY'
BLOB_CONTAINER = os.environ.get('BLOB_CONTAINER') or 'ENTER_IMAGES_CONTAINER_NAME'

SQL_SERVER = os.environ.get('SQL_SERVER') or 'ENTER_SQL_SERVER_NAME.database.windows.net'
SQL_DATABASE = os.environ.get('SQL_DATABASE') or 'ENTER_SQL_DB_NAME'
SQL_USER_NAME = os.environ.get('SQL_USER_NAME') or 'ENTER_SQL_SERVER_USERNAME'
SQL_PASSWORD = os.environ.get('SQL_PASSWORD') or 'ENTER_SQL_SERVER_PASSWORD'
BLOB_ACCOUNT = os.environ.get('BLOB_ACCOUNT') or 'artmngstorageaccount'
BLOB_STORAGE_KEY = os.environ.get('BLOB_STORAGE_KEY') or 'hah4cR+NDvmF8Df6e1e1jNcoI+/yAb1Sy4nT0PpGZ6PQ4uPq+lxwJT+7YJJAjH1QP3hHqkPcLMz/+ASta+7eTw=='
BLOB_CONTAINER = os.environ.get('BLOB_CONTAINER') or 'images'

SQL_SERVER = os.environ.get('SQL_SERVER') or 'article-management-server.database.windows.net'
SQL_DATABASE = os.environ.get('SQL_DATABASE') or 'article-management-db'
SQL_USER_NAME = os.environ.get('SQL_USER_NAME') or 'artmngadmin'
SQL_PASSWORD = os.environ.get('SQL_PASSWORD') or 'AMS357#ams'
# Below URI may need some adjustments for driver version, based on your OS, if running locally
SQLALCHEMY_DATABASE_URI = 'mssql+pyodbc://' + SQL_USER_NAME + '@' + SQL_SERVER + ':' + SQL_PASSWORD + '@' + SQL_SERVER + ':1433/' + SQL_DATABASE + '?driver=ODBC+Driver+17+for+SQL+Server'
SQLALCHEMY_TRACK_MODIFICATIONS = False

### Info for MS Authentication ###
### As adapted from: https://github.com/Azure-Samples/ms-identity-python-webapp ###
CLIENT_SECRET = "ENTER_CLIENT_SECRET_HERE"
CLIENT_SECRET = "Uhf8Q~u0xgqNK-BtoCC16VerEeez7XQU00AuXbvN"
# In your production app, Microsoft recommends you to use other ways to store your secret,
# such as KeyVault, or environment variable as described in Flask's documentation here:
# https://flask.palletsprojects.com/en/1.1.x/config/#configuring-from-environment-variables
Expand All @@ -30,7 +30,7 @@ class Config(object):
AUTHORITY = "https://login.microsoftonline.com/common" # For multi-tenant app, else put tenant name
# AUTHORITY = "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here"

CLIENT_ID = "ENTER_CLIENT_ID_HERE"
CLIENT_ID = "91ab10c6-b9ea-46aa-a138-aaec5aad33ed"

REDIRECT_PATH = "/getAToken" # Used to form an absolute URL; must match to app's redirect_uri set in AAD

Expand Down
3 changes: 0 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
alembic==1.4.0
azure-common==1.1.24
azure-nspkg==3.0.2
azure-storage==0.36.0
certifi==2019.11.28
cffi==1.14.6
chardet==3.0.4
Click==7.1.2
cryptography==2.8
Expand All @@ -15,7 +13,6 @@ Flask-WTF==0.14.3
idna==2.8
itsdangerous==2.0.1
Jinja2==3.0.1
Mako==1.1.1
MarkupSafe==2.0.1
msal==1.13.0
pip==10.0.1
Expand Down
Binary file added screenshots/article-cms-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/blob-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invalid-login-log-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/sql-storage-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/successful-login-log-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/uri-redirects-solution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion sql_scripts/posts-table-init.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
CREATE TABLE POSTS(
id INT NOT NULL IDENTITY(1, 1),
title VARCHAR(150) NOT NULL,
subtitle VARCHAR(250) NOT NULL,
author VARCHAR(75) NOT NULL,
body VARCHAR(800) NOT NULL,
image_path VARCHAR(100) NULL,
Expand All @@ -10,9 +11,10 @@ CREATE TABLE POSTS(
FOREIGN KEY (user_id) REFERENCES users(id)
);

INSERT INTO dbo.posts (title, author, body, user_id)
INSERT INTO dbo.posts (title, subtitle, author, body, user_id)
VALUES (
'Lorem ipsum dolor sit amet',
'here is additional subtitle',
'John Smith',
'Proin sit amet mi ornare, ultrices augue quis, facilisis tellus. Quisque neque dui, tincidunt sed volutpat quis, maximus sed est. Sed justo orci, rhoncus ac nulla eu, rhoncus luctus justo. Etiam maximus, felis eu varius fermentum, libero orci egestas purus, id condimentum mauris orci nec nibh. Vivamus risus ipsum, semper vel nibh in, suscipit commodo massa. Suspendisse non velit vitae neque condimentum viverra vel eget enim. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Vivamus fermentum sagittis ligula et fringilla. Aenean nec lacinia lacus.',
1
Expand Down