"
3 |
4 | # Use `dumb-init` to follow security best practices
5 | # https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/
6 | RUN apk add --update --no-cache curl dumb-init
7 |
8 | # so app defaults to using TLS
9 | ENV NODE_ENV production
10 |
11 | # bind to host
12 | ENV HOST '0.0.0.0'
13 |
14 | # default to port 80
15 | EXPOSE ${PORT:-80}
16 |
17 | WORKDIR /usr/workspace
18 |
19 | # cache dependencies as layer
20 | COPY ["package.json", "package-lock.json", "./"]
21 | RUN npm ci --production
22 |
23 | # copy rest of app (respects .dockerignore)
24 | COPY [".", "./"]
25 |
26 | # Don't run as root
27 | USER node
28 |
29 | HEALTHCHECK --interval=5m --timeout=3s \
30 | CMD curl -f http://localhost:${PORT}/health || exit 1
31 |
32 | CMD ["dumb-init", "node", "app/server.js"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2021 Julie Ng
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # azure-nodejs-demo
2 |
3 | Containerized Node.js Demo App for Azure App Service.
4 |
5 | | Status | Pipeline | Website URL | CDN Endpoint |
6 | |:--|:--|:--|:--|
7 | | [?branchName=main)](https://dev.azure.com/julie-msft/public-demos/_build/latest?definitionId=36&branchName=main) | [dev.yaml](./azure-pipelines/dev.yaml) | [azure-nodejs-demo-dev.azurewebsites.net](https://azure-nodejs-demo-dev.azurewebsites.net/) | [nodejsdemo-dev.azureedge.net](https://nodejsdemo-dev.azureedge.net/css/styles.css) |
8 | | [?branchName=production)](https://dev.azure.com/julie-msft/public-demos/_build/latest?definitionId=37&branchName=production) | [production.yaml](./azure-pipelines/production.yaml) | [azure-nodejs-demo.azurewebsites.net](https://azure-nodejs-demo.azurewebsites.net/) | [nodejsdemo-prod.azureedge.net](https://nodejsdemo-prod.azureedge.net/css/styles.css) |
9 |
10 | Note: the `dev.yaml` pipeline generally "partially" fails because of security vulnerabilities found when running `npm audit`. Personally I allow the pipeline to continue and I'm not super concerned because these are only used for local development, e.g. watchers and hot reload.
11 |
12 | The `production.yaml` pipeline only checks for non-development dependency vulnerabilities. And that's usually passing and green :)
13 |
14 | ## Architecture
15 |
16 | This demo leverages the [Static Content Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/static-content-hosting) in Cloud Architecture.
17 |
18 | The images and CSS can be served from app itself or pulled from external host, e.g. CDN via `ASSETS_BASE_URL` environment variable.
19 |
20 |
21 |
22 | ## Real-World CI/CD Pipelines
23 |
24 | In real life you have more than one environment.
25 |
26 | - **Azure Pipelines Best Practices**
27 | - CI pipeline for feature branches and `main`
28 | - Deploy when pushing to `main` or `production` branches
29 | - See [./azure-pipelines](./azure-pipelines) for working pipelines and details
30 |
31 | - **Asset Pipeline**
32 | The included cd.yaml pipeline shows how to upload assets to Blob Storage and purge the Azure CDN cache as part of your deployment process.
33 |
34 | - **CI/CD & Versioning**
35 | Example versioning and promotion strategies leveraging git commit messages, git tags and `package.json`.
36 |
37 | ## Azure Demo
38 |
39 | - **Azure PaaS**
40 | This app is hosted on [Web App for Containers](https://azure.microsoft.com/en-us/services/app-service/containers/)
41 |
42 | - **Azure App Insights integration**
43 | Using [appinsights npm package](https://www.npmjs.com/package/applicationinsights) and Express.js middleware with just a few lines of code. See [monitor.js](./app/middleware/monitor.js)
44 |
45 |
46 | ## Endpoints
47 |
48 | | Method | Path | Description |
49 | |:--|:--|:--|
50 | | GET | `/` | root |
51 | | GET | `/health` | health check endpoint |
52 | | POST | `/webhooks/test` | accepts JSON and logs output |
53 |
54 | ### Healthcheck
55 |
56 | This is an example healthcheck endpoint with standardized JSON per draft [IETF standard](https://tools.ietf.org/html/draft-inadarei-api-health-check-04)
57 |
58 | ```json
59 | {
60 | "status": "pass",
61 | "version": "0.7.1",
62 | "details": {
63 | "uptime": {
64 | "component_type": "system",
65 | "observed_value": 24208698,
66 | "human_readable": "0 days, 6 hours, 43 minutes, 28 seconds",
67 | "observed_unit": "ms",
68 | "status": "pass",
69 | "time": "2021-04-12T11:45:32.508Z"
70 | },
71 | "env": {
72 | "WEBSITE_HOSTNAME": "azure-nodejs-demo-dev.azurewebsites.net",
73 | "WEBSITE_INSTANCE_ID": "03e7481d3d5ff1e67e297f158abd943ce8c8b920fa55dc7bf0565e86886404a8",
74 | "ASSETS_BASE_URL": "https://nodejsdemo-dev.azureedge.net"
75 | }
76 | }
77 | }
78 | ```
79 |
80 | ## Local Docker Development
81 |
82 | Note: in local development, I tend to reference the image sha256. But for the docs, we'll leverage a custom tag `azure-nodejs-demo` to make this doc easier to follow.
83 |
84 | ### Build Image
85 |
86 | ```bash
87 | docker build --tag azure-nodejs-demo .
88 | ```
89 |
90 | ### Run Image
91 |
92 | To ensure it runs locally, we need to change the default `NODE_ENV` to disable `https://` redirect. Otherwise browser throws insecure connection message and will not load page.
93 |
94 | ```bash
95 | docker run -it -p 3000:3000 -e NODE_ENV=development azure-nodejs-demo
96 | ```
97 |
98 | ### Target Platform (Edge Case)
99 |
100 | In general, **you should _never_ publish local builds**. In case you decide to do this (I was debugging some Azure Container Registry behavior ;-)) from an m1 Mac, you need to specify Linux as the target platform.
101 |
102 | ```
103 | docker build --platform linux/amd64 .
104 | ```
--------------------------------------------------------------------------------
/app/app.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | // eslint-disable-next-line no-unused-vars
4 | const hbs = require('express-handlebars')
5 | const express = require('express')
6 | const path = require('path')
7 | const helmet = require('helmet')
8 | const logger = require('morgan')
9 | const monitor = require('./middleware/monitor')
10 | const forceHttps = require('./middleware/force-https')
11 | const bodyParser = require('body-parser')
12 | const healthcheck = require('standard-healthcheck')
13 |
14 | const packageJsonVersion = require('./../package.json').version
15 |
16 | const PORT = process.env.PORT || '3000'
17 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'
18 | const APP_VERSION = IS_PRODUCTION
19 | ? 'v-' + packageJsonVersion
20 | : 'dev'
21 |
22 | const AZURE_APP_SERVICE_HOSTNAME = process.env.WEBSITE_HOSTNAME
23 | ? `https://${process.env.WEBSITE_HOSTNAME}`
24 | : false
25 |
26 | const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL
27 | || AZURE_APP_SERVICE_HOSTNAME
28 | || `http://localhost:${PORT}`
29 |
30 | let app = express()
31 |
32 |
33 | // --- Static Assets ---
34 |
35 | const assetsDir = path.join(__dirname, './../assets')
36 | const cssFile = IS_PRODUCTION
37 | ? `styles-${packageJsonVersion}.css`
38 | : 'styles.css'
39 | const cssFileUrl = `${ASSETS_BASE_URL}/css/${cssFile}`
40 |
41 | app.use('/css', express.static(`${assetsDir}/css`))
42 | app.use('/images', express.static(`${assetsDir}/images`))
43 |
44 | // --- Middleware ---
45 |
46 | app.use(forceHttps)
47 | app.use(helmet())
48 | app.use(logger('dev'))
49 | app.use(monitor)
50 |
51 | // --- Views ---
52 |
53 | app.set('views', path.join(__dirname, '/views'))
54 | app.set('view engine', 'hbs')
55 | app.set('view options', { layout: 'layout' })
56 |
57 | app.get('/', (req, res) => {
58 | res.render('home', {
59 | title: 'Node.js on Azure App Service Demo',
60 | version: APP_VERSION,
61 | cssFileUrl: cssFileUrl,
62 | assetsBaseUrl: ASSETS_BASE_URL
63 | })
64 | })
65 |
66 | app.post('/webhooks/test', bodyParser.json(), (req, res) => {
67 | let payload = {
68 | status: 'OK',
69 | payload: {
70 | headers: req.headers,
71 | body: req.body
72 | }
73 | }
74 | console.log(payload)
75 | res.send(JSON.stringify(payload))
76 | })
77 |
78 | app.get('/health', healthcheck({
79 | version: process.env.npm_package_version,
80 | description: process.env.npm_package_description,
81 | includeEnv: ['WEBSITE_HOSTNAME', 'WEBSITE_INSTANCE_ID', 'ASSETS_BASE_URL', 'NODE_ENV']
82 | }))
83 |
84 | app.use((req, res, next) => {
85 | res.status(404).send('Oops - page not found.')
86 | })
87 |
88 | module.exports = app
89 |
--------------------------------------------------------------------------------
/app/app.test.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-unused-vars
2 | 'use strict'
3 |
4 | const app = require('./app')
5 | const request = require('request')
6 | const http = require('http')
7 |
8 | const port = process.env.PORT || 3001
9 |
10 | describe ('app', () => {
11 | let server
12 |
13 | beforeAll ((done) => {
14 | server = http.createServer(app).listen(port, done)
15 | })
16 |
17 | afterAll ((done) => {
18 | server.close(done)
19 | })
20 |
21 | describe ('GET /', () => {
22 | it (`returns Homepage`, (done) => {
23 | const url = getUrl('/')
24 | const content = 'Node.js on Azure App Service Demo'
25 | request.get(url, (error, response, body) => {
26 | expect(response.statusCode).toBe(200)
27 | expect(response.body.includes(content)).toBe(true)
28 | done()
29 | })
30 | })
31 | })
32 |
33 | describe ('GET /health', () => {
34 | it (`returns 200`, (done) => {
35 | const url = getUrl('/health')
36 | request.get(url, (error, response, body) => {
37 | expect(response.statusCode).toBe(200)
38 | done()
39 | })
40 | })
41 | })
42 |
43 | describe ('POST /webhooks/test', () => {
44 | it (`returns 200`, (done) => {
45 | const url = getUrl('/webhooks/test')
46 | request.post(url, (error, response, body) => {
47 | expect(response.statusCode).toBe(200)
48 | done()
49 | })
50 | })
51 | })
52 | })
53 |
54 | function getUrl (path) {
55 | return `http://localhost:${port}` + path
56 | }
--------------------------------------------------------------------------------
/app/middleware/force-https.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function isDeployed () {
4 | return process.env.NODE_ENV === 'production'
5 | }
6 |
7 | function https (req, res, next) {
8 | if (!req.secure && req.get('X-Forwarded-Proto') !== 'https' && isDeployed()) {
9 | res.redirect('https://' + req.hostname + req.url)
10 | } else {
11 | next()
12 | }
13 | }
14 |
15 | module.exports = https
--------------------------------------------------------------------------------
/app/middleware/monitor.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const appInsights = require('applicationinsights')
4 | const appInsightsKey = process.env.APPINSIGHTS_INSTRUMENTATION_KEY || false
5 |
6 | if (appInsightsKey) {
7 | appInsights
8 | .setup(appInsightsKey)
9 | .start()
10 | }
11 |
12 | function middleware (req, res, next) {
13 | if (appInsightsKey) {
14 | appInsights.defaultClient.trackNodeHttpRequest({
15 | request: req,
16 | response: res
17 | })
18 | }
19 | next()
20 | }
21 |
22 | module.exports = middleware
--------------------------------------------------------------------------------
/app/server.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const app = require('./app')
3 | const http = require('http')
4 | const port = process.env.PORT || 3000
5 |
6 | const server = http.createServer(app)
7 |
8 | server.listen(port, () => {
9 | console.log(`Listening on port ${port}`)
10 | })
11 |
--------------------------------------------------------------------------------
/app/views/default.hbs:
--------------------------------------------------------------------------------
1 | {{{ body }}}
--------------------------------------------------------------------------------
/app/views/home.hbs:
--------------------------------------------------------------------------------
1 |
2 |

3 |

4 |
5 |
6 |
7 |
8 |
Express.js Setup
9 |
15 |
16 |
17 |
18 |
Azure Integration
19 |
23 |
24 |
25 |
26 |
36 |
--------------------------------------------------------------------------------
/app/views/layout.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title }}
7 |
8 |
9 |
10 |
11 |
17 | {{{ body }}}
18 |
19 |
20 |
--------------------------------------------------------------------------------
/assets/css/styles.scss:
--------------------------------------------------------------------------------
1 | $light-text-color: #999;
2 |
3 | body {
4 | font-family: Helvetica, sans-serif;
5 | line-height: 1.3;
6 | color: #333;
7 | }
8 |
9 | a:link, a:visited {
10 | color: #044b7f;
11 | }
12 |
13 | section {
14 | width: 95%;
15 | max-width: 800px;
16 | margin: 1em auto;
17 | border: 1px solid transparent;
18 | }
19 |
20 | h1 {
21 | margin: 1em 0;
22 | font-size: 2rem;
23 | color: #222;
24 |
25 | .version {
26 | color: $light-text-color;
27 | font-weight: normal;
28 | }
29 | }
30 |
31 | h2 {
32 | margin: 2em 0 1em 0;
33 | }
34 |
35 | h3 {
36 | font-weight: 500;
37 | }
38 |
39 | ul {
40 | padding: 0;
41 | margin: 0;
42 | }
43 |
44 | li {
45 | margin-bottom: 1em;
46 | list-style-type: none;
47 | }
48 |
49 | .row {
50 | display: flex;
51 | flex-direction: row;
52 | flex-wrap: wrap;
53 | width: 100%;
54 | margin-left: auto;
55 | margin-right: auto;
56 |
57 | }
58 |
59 | .column {
60 | display: flex;
61 | flex-direction: column;
62 | flex-basis: 100%;
63 | flex: 1;
64 | }
65 |
66 | .logos {
67 | margin-bottom: 3em;
68 | }
69 | .img-logo {
70 | display: inline-block;
71 | vertical-align: top;
72 | }
73 |
74 | .img-logo--nodejs {
75 | height: 70px;
76 | }
77 |
78 | .img-logo--azure {
79 | margin-left: 20px;
80 | margin-top: 5px;
81 | height: 40px;
82 | }
83 |
84 | footer {
85 | margin: 3rem auto;
86 | padding-top: 1rem;
87 | border-top: 1px solid #dcdcdc;
88 | font-size: 0.85rem;
89 | line-height: 1.6em;
90 | color: $light-text-color;
91 | text-align: left;
92 | }
93 |
94 | footer {
95 | p {
96 | margin: 1em 0 2em;
97 | }
98 |
99 | a:link,
100 | a:visited {
101 | color: $light-text-color;
102 | }
103 | }
--------------------------------------------------------------------------------
/assets/images/msft-azure-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
73 |
--------------------------------------------------------------------------------
/assets/images/nodejs-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/azure-architecture.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/azure-pipelines/README.md:
--------------------------------------------------------------------------------
1 | # Opinionated CI/CD Pipelines
2 |
3 | The pipelines in this repository follow opinionated best practices. They are documented here for reference and easier debugging.
4 |
5 | ### Video Walkthrough
6 |
7 | And there's a ca. 20 minute video walkthrough explaining the pipelines and why I organize them this way:
8 | [youtube.com/watch?v=e0bF1LlclEs](https://www.youtube.com/watch?v=e0bF1LlclEs)
9 |
10 | [](https://www.youtube.com/watch?v=e0bF1LlclEs)
11 |
12 | ## File Structure
13 |
14 | The main pipelines sit in this `azure-pipelines` directory and use subfolders for templating.
15 |
16 | ```
17 | ├── README.md
18 | ├── production.yaml
19 | ├── dev.yaml
20 | ├── jobs
21 | │ ├── app-service.yaml
22 | │ ├── asset-pipeline.yaml
23 | │ ├── docker.yaml
24 | │ └── tests.yaml
25 | ├── steps
26 | │ └── debug-vars.yaml
27 | └── vars
28 | ├── dev.yaml
29 | ├── global.yaml
30 | └── prod.yaml
31 | ```
32 |
33 | ## Templates and Variables
34 |
35 | ### Local Templates
36 |
37 | The jobs and steps are intended for local use only and thus do not require `parameters:` definitions at each job scope. Therefore any varable referenced in a job, e.g. `variables.isProduction` can be found at a higher scope.
38 |
39 | ### Global Variables
40 |
41 | Due to the sheer amount of variables set, the conditionals and global defaults are set in `vars/global.yaml`.
42 |
43 | ```yaml
44 | # Global scope
45 | trigger:
46 | - main
47 | variables:
48 | - template: vars/global.yaml
49 | ```
50 |
51 | ### Environment Specific Variables
52 |
53 | _**Note**: this is no longer used since switching to environment specific pipelines, but kept here for reference._
54 |
55 | Some parameters, e.g. app name are dependent on the deployment target environment. Using conditionals at `stages:` scope, the defaults are overwritten.
56 |
57 | ```yaml
58 | - stage: StageName
59 | variables:
60 | - ${{ if eq(variables.isMain, 'True') }}:
61 | - template: vars/dev.yaml
62 | - ${{ if eq(variables.isProduction, 'True') }}:
63 | - template: vars/prod.yaml
64 | ```
65 |
66 | _Important: Environment specific variables must be set in a non-root, e.g. downstream scope._
67 |
68 | ## Triggers and Deployments
69 |
70 | Please also see [Docker Images](#docker-images) section, which describes the git tag trigger.
71 |
72 | | Pipeline | Branch Triggers | Pull Request Triggers | Deployment |
73 | |:--|:--|:--|:--|
74 | | [`dev.yaml`](./dev.yaml) | • `main`
• `feat/*`
• `fix/*` | `main` | Dev |
75 | | [`production.yaml`](./production.yaml) | • `production` | (none) | Production |
76 |
77 | ### Zero Trust Principle
78 |
79 | Pull Requests only runs tests and does not build any images. The YAML pedanticly excludes forks, pull requests and scheduled runs. In this manner only `git merge` events, which requires human intervention will trigger deployments. This is configured using branch production configurations.
80 |
81 | See [`vars/global.yaml`](./vars/global.yaml) for details:
82 |
83 | ```yaml
84 | # Excerpt
85 | variables:
86 | isFork: ${{ eq(variables['System.PullRequest.IsFork'], 'True') }}
87 | isPR: ${{ eq(variables['Build.Reason'], 'PullRequest') }}
88 | isScheduled: ${{ eq(variables['Build.Reason'], 'Schedule') }}
89 | isTrustedCI: ${{ and( eq(variables.isFork,'False'), eq(variables.isPR,'False'), eq(variables.isScheduled,'False') ) }}
90 | ```
91 |
92 | # Docker Images
93 |
94 | ### Tag Definitions
95 |
96 | - **Image Tag**
97 | Refers to version of particular image, e.g. `0.1.0` or `latest`
98 |
99 | - **Docker Tag**
100 | Refers to full image name that includes private registry hostname, for example:
101 | `nodejsdemo.azurecr.io/azure-nodejs-demo:0.1.0`
102 |
103 | ### Build Triggers & Tags
104 |
105 | - Docker Images are only built in the `dev.yaml` CI pipeline.
106 | - Production-ready images are [locked](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-image-lock) (Azure Container Registry specific feature) and thus immutable.
107 |
108 | | Triggers | Image Tag | Immutable |
109 | |:--|:--|:--|
110 | | `main` branch | `dev-$(git rev-parse --short HEAD)` | - |
111 | | `v*` tag | e.g. `0.1.0` | True |
112 |
113 | ### Deployment
114 |
115 | Deployment pipeline only deploys existing images, which have passed previous stages and are thus assumed valid and secure.
116 |
117 | | Branch | Image Tag |
118 | |:--|:--|
119 | | `main` | `dev` |
120 | | `production` | Version per `package.json` |
121 |
--------------------------------------------------------------------------------
/azure-pipelines/dev.yaml:
--------------------------------------------------------------------------------
1 | name: $(BuildID)
2 |
3 | trigger:
4 | batch: true
5 | branches:
6 | include:
7 | - fix/*
8 | - feat/*
9 | - main
10 | tags:
11 | include:
12 | - v*
13 | paths:
14 | exclude:
15 | - README.md
16 |
17 | pr:
18 | - main
19 |
20 | pool:
21 | vmImage: 'ubuntu-latest'
22 |
23 | schedules:
24 | - cron: "0 12 * * 0"
25 | displayName: Weekly Sunday build
26 | always: true
27 | branches:
28 | include:
29 | - main
30 |
31 | variables:
32 | - template: vars/global.yaml
33 | - template: vars/dev.yaml
34 |
35 | stages:
36 |
37 | # Tests
38 | # -----
39 | - stage: TestStage
40 | displayName: Tests (Node.js)
41 | condition: eq(variables.isTag, 'False')
42 | jobs:
43 | - template: jobs/tests.yaml
44 |
45 | # Build
46 | # -----
47 | - stage: DockerStage
48 | displayName: Build (Docker)
49 | condition: or(eq(variables.isTag,'True'), and(succeeded(), eq(variables.deployMain,'True')))
50 | jobs:
51 | - template: jobs/docker.yaml
52 |
53 | # Deploy
54 | # ------
55 | - stage: DeployStage
56 | displayName: Deploy (App Service)
57 | condition: and(succeeded(), eq(variables.deployMain,'True'))
58 | jobs:
59 | - template: jobs/app-service.yaml
60 | - template: jobs/asset-pipeline.yaml
61 |
--------------------------------------------------------------------------------
/azure-pipelines/jobs/app-service.yaml:
--------------------------------------------------------------------------------
1 | jobs:
2 | - job: AppService
3 | displayName: Deploy to App Service
4 | steps:
5 |
6 | - script: echo "##vso[task.setvariable variable=imageTag]$(npm run --silent my-version)"
7 | displayName: Image Tag - use app version
8 | condition: eq(variables.isProduction, 'True')
9 |
10 | - template: ../steps/append-sha.yaml
11 |
12 | - template: ../steps/debug-vars.yaml
13 |
14 | - task: AzureWebAppContainer@1
15 | displayName: Deploy Web App Container
16 | inputs:
17 | appName: $(webAppName)
18 | azureSubscription: $(armConnection)
19 | imageName: $(dockerImage):$(imageTag)
20 | appSettings: -PORT 8080 -WEBSITES_PORT 8080 -NODE_ENV $(nodeEnvName) -ASSETS_BASE_URL "https://$(cdnEndpoint).azureedge.net"
21 |
--------------------------------------------------------------------------------
/azure-pipelines/jobs/asset-pipeline.yaml:
--------------------------------------------------------------------------------
1 | jobs:
2 | - job: AssetPipeline
3 | displayName: Deploy Static Assets
4 | steps:
5 |
6 | - template: ../steps/debug-vars.yaml
7 |
8 | - script: |
9 | npm ci
10 | npm run compile-sass
11 | displayName: Compile CSS
12 |
13 | - task: AzureCLI@2
14 | displayName: Upload to Blob Storage
15 | inputs:
16 | azureSubscription: $(armConnection)
17 | scriptType: bash
18 | scriptLocation: inlineScript
19 | inlineScript: |
20 | az version
21 | az storage blob upload-batch \
22 | --account-name $(storageAccount) \
23 | --source ./assets \
24 | --destination $(blobContainer)
25 |
26 | - task: AzureCLI@2
27 | displayName: Purge Cache
28 | inputs:
29 | azureSubscription: $(armConnection)
30 | scriptType: bash
31 | scriptLocation: inlineScript
32 | inlineScript: |
33 | az version
34 | az cdn endpoint purge \
35 | --resource-group $(resourceGroup) \
36 | --name $(cdnEndpoint) \
37 | --profile-name $(cdnProfileName) \
38 | --content-paths '/css/*' '/images/*'
39 |
--------------------------------------------------------------------------------
/azure-pipelines/jobs/docker.yaml:
--------------------------------------------------------------------------------
1 | jobs:
2 | - job: BuildAndScan
3 | displayName: Build and Scan
4 | steps:
5 | - template: ../steps/debug-vars.yaml
6 |
7 | - bash: |
8 | npm ci
9 | npm run compile-sass
10 | docker build -t $(dockerImage):$(imageTag) .
11 | displayName: Docker - build
12 |
13 | - task: SnykSecurityScan@0
14 | displayName: Snyk - security scan
15 | continueOnError: true
16 | inputs:
17 | serviceConnectionEndpoint: 'snyk-api-connection'
18 | testType: 'container'
19 | dockerImageName: $(dockerImage):$(imageTag)
20 | dockerfilePath: 'Dockerfile'
21 | monitorOnBuild: true
22 |
23 | - job: DockerPush
24 | displayName: Push
25 | dependsOn: BuildAndScan
26 | steps:
27 | - template: ../steps/debug-vars.yaml
28 |
29 | - bash: |
30 | npm ci
31 | npm run compile-sass
32 | displayName: Compile CSS
33 |
34 | - task: Docker@2
35 | displayName: Docker - Login
36 | inputs:
37 | command: login
38 | containerRegistry: $(acrConnection)
39 |
40 | - template: ../steps/append-sha.yaml
41 |
42 | - bash: |
43 | docker build -t $(dockerImage):$(imageTag) .
44 | docker push $(dockerImage):$(imageTag)
45 | displayName: Docker - Build and Push
46 |
47 | - task: Docker@2
48 | displayName: Docker - Logout
49 | inputs:
50 | command: logout
51 | containerRegistry: $(acrConnection)
52 |
53 | - task: AzureCLI@2
54 | displayName: 'ACR - lock production image'
55 | condition: eq(variables.isTag, 'True')
56 | inputs:
57 | azureSubscription: $(armConnection)
58 | scriptType: bash
59 | scriptLocation: inlineScript
60 | inlineScript: |
61 | az acr repository update \
62 | --name $(dockerRegistry) \
63 | --image $(imageName):$(imageTag) \
64 | --write-enabled false
--------------------------------------------------------------------------------
/azure-pipelines/jobs/tests.yaml:
--------------------------------------------------------------------------------
1 | jobs:
2 | - job: Audit
3 | displayName: Audit Dependencies
4 | steps:
5 | - script: npm audit --audit-level=moderate
6 | displayName: Audit (Dev)
7 | continueOnError: true
8 |
9 | - script: npm audit --production --audit-level=high
10 | displayName: Audit (Prod)
11 |
12 | - job: Linter
13 | displayName: Lint Code
14 | steps:
15 | - script: npm ci && npm run lint
16 | displayName: Lint Code
17 |
18 | - job: UnitTests
19 | displayName: Unit Tests
20 | steps:
21 | - script: npm ci && npm run test
22 | displayName: Run Tests
23 |
--------------------------------------------------------------------------------
/azure-pipelines/production.yaml:
--------------------------------------------------------------------------------
1 | name: $(BuildID)
2 |
3 | trigger:
4 | batch: true
5 | branches:
6 | include:
7 | - production
8 |
9 | pr: none
10 |
11 | pool:
12 | vmImage: 'ubuntu-latest'
13 |
14 | variables:
15 | - template: vars/global.yaml
16 | - template: vars/prod.yaml
17 |
18 | stages:
19 | - stage: Deployment
20 | jobs:
21 | - template: jobs/app-service.yaml
22 | - template: jobs/asset-pipeline.yaml
23 |
--------------------------------------------------------------------------------
/azure-pipelines/refresh-prod-image.yaml:
--------------------------------------------------------------------------------
1 | schedules:
2 | - cron: "0 6 * * 1" # Mondays, 6am
3 | displayName: Weekly Production Build Refresh
4 | always: true # run even if no code changes
5 | branches:
6 | include:
7 | - production
8 |
9 | trigger: none
10 | pr: none
11 |
12 | pool:
13 | vmImage: 'ubuntu-latest'
14 |
15 | variables:
16 | - template: vars/global.yaml
17 |
18 | stages:
19 | - stage: RefreshImage
20 | displayName: Refresh Image
21 | jobs:
22 |
23 | - job: RebuildImage
24 | displayName: Rebuild Image
25 | steps:
26 | - script: |
27 | echo "##vso[task.setvariable variable=imageTag]$(npm run --silent my-version)-refresh"
28 | displayName: Latest Tag # e.g. v0.11.1-refresh
29 |
30 | - bash: |
31 | npm ci
32 | npm run compile-sass
33 | docker build -t $(dockerImage):$(imageTag) .
34 | displayName: Docker - Build
35 |
36 | - task: Docker@2
37 | displayName: Docker - Login
38 | inputs:
39 | command: login
40 | containerRegistry: $(acrConnection)
41 |
42 | - bash: |
43 | docker push $(dockerImage):$(imageTag)
44 | displayName: Docker - Push
45 |
46 | - task: Docker@2
47 | displayName: Docker - Logout
48 | inputs:
49 | command: logout
50 | containerRegistry: $(acrConnection)
51 |
--------------------------------------------------------------------------------
/azure-pipelines/steps/append-sha.yaml:
--------------------------------------------------------------------------------
1 | # Prefer shorter ~7 git sha
2 | steps:
3 | - script: |
4 | echo "##vso[task.setvariable variable=imageTag]$(imageTag)-$(git rev-parse --short HEAD)"
5 | displayName: Image Tag - append git sha
6 | condition: and( eq(variables.isTag, 'False'), eq(variables.isProduction, 'False') )
--------------------------------------------------------------------------------
/azure-pipelines/steps/debug-vars.yaml:
--------------------------------------------------------------------------------
1 | steps:
2 | - bash: |
3 | echo ''
4 | echo '===== Conditions ====='
5 | echo 'isMain: $(isMain)'
6 | echo 'isTag: $(isTag)'
7 | echo 'isProduction: $(isProduction)'
8 | echo 'isFork: $(isFork)'
9 | echo 'isPR: $(isPR)'
10 | echo 'isScheduled: $(isScheduled)'
11 | echo 'isTrustedCI: $(isTrustedCI)'
12 | echo ''
13 |
14 | echo '===== DOCKER ====='
15 | echo 'imageTag: $(imageTag)'
16 | echo 'dockerImage: $(dockerImage)'
17 | echo ''
18 |
19 | echo '===== DEPLOYMENT ====='
20 | echo 'webAppName: $(webAppName)'
21 | echo 'blobContainer: $(blobContainer)'
22 | echo 'cdnEndpoint: $(cdnEndpoint)'
23 | echo ''
24 | displayName: Debug - Variables
25 |
--------------------------------------------------------------------------------
/azure-pipelines/vars/dev.yaml:
--------------------------------------------------------------------------------
1 | variables:
2 | webAppName: azure-nodejs-demo-dev
3 | blobContainer: nodejs-dev
4 | cdnEndpoint: nodejsdemo-dev
5 | nodeEnvName: staging
--------------------------------------------------------------------------------
/azure-pipelines/vars/global.yaml:
--------------------------------------------------------------------------------
1 | variables:
2 |
3 | # ARM
4 | armConnection: nodejs-demo-rg-conn
5 | acrConnection: nodejs-demo-acr-conn
6 | resourceGroup: nodejs-demo-rg
7 | storageAccount: demoassetpipeline
8 | cdnProfileName: demoassetpipeline
9 |
10 | # Pipeline conditions
11 | isMain: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/main') }}
12 | isProduction: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/production') }}
13 | isTag: ${{ startsWith(variables['Build.SourceBranch'], 'refs/tags/v') }}
14 | isFork: ${{ eq(variables['System.PullRequest.IsFork'], 'True') }}
15 | isPR: ${{ eq(variables['Build.Reason'], 'PullRequest') }}
16 | isScheduled: ${{ eq(variables['Build.Reason'], 'Schedule') }}
17 | isTrustedCI: ${{ and( eq(variables.isFork,'False'), eq(variables.isPR,'False'), eq(variables.isScheduled,'False') ) }}
18 | deployMain: ${{ and( eq(variables.isMain,'True'), eq(variables.isTrustedCI,'True') ) }}
19 |
20 | # Docker
21 | dockerRegistry: nodejsdemo.azurecr.io
22 | imageName: azure-nodejs-demo
23 | ${{ if eq(variables.isTag, 'False') }}:
24 | imageTag: dev
25 | ${{ if eq(variables.isTag, 'True') }}:
26 | imageTag: ${{ replace(variables['Build.SourceBranch'], 'refs/tags/v', '') }}
27 | dockerImage: ${{ variables.dockerRegistry }}/${{ variables.imageName }}
28 |
29 | # To be overwritten by dev.yaml or prod.yaml
30 | webAppName: unset
31 | blobContainer: unset
32 | cdnEndpoint: unset
--------------------------------------------------------------------------------
/azure-pipelines/vars/prod.yaml:
--------------------------------------------------------------------------------
1 | variables:
2 | webAppName: azure-nodejs-demo
3 | blobContainer: nodejs
4 | cdnEndpoint: nodejsdemo-prod
5 | nodeEnvName: production
--------------------------------------------------------------------------------
/compile-sass.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fs = require('fs')
4 | const path = require('path')
5 | const sass = require('sass')
6 |
7 | const appVersion = process.env.npm_package_version
8 |
9 | const sourceFile = path.join(__dirname, 'assets/css/styles.scss')
10 | const outFile = path.join(__dirname, `assets/css/styles.css`)
11 | const outFileVersioned = path.join(__dirname, `assets/css/styles-${appVersion}.css`)
12 | const opts = {
13 | file: sourceFile,
14 | outputStyle: 'compressed'
15 | }
16 |
17 | sass.render(opts, function (err, result) {
18 | if (result) {
19 | const css = result.css.toString()
20 | console.log('[Compiled CSS]')
21 | console.log(css)
22 |
23 | // Always write styles.css
24 | fs.writeFileSync(outFile, css)
25 | console.log('[Created] styles.css')
26 |
27 | // Only write versioned file if doesn't exist
28 | try {
29 | fs.writeFileSync(outFileVersioned, css, { flag: 'wx' })
30 | console.log(`[Created] styles-${appVersion}.css`)
31 | } catch (e) {
32 | console.log(`[Skipped] styles-${appVersion}.css already exists`)
33 | }
34 | process.exit(0)
35 | }
36 |
37 | if (err) {
38 | console.error('Error: could not compile sass:')
39 | console.log(err)
40 | }
41 |
42 | process.exit(1)
43 | })
44 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | // For a detailed explanation regarding each configuration property, visit:
4 | // https://jestjs.io/docs/en/configuration.html
5 |
6 | module.exports = {
7 | // All imported modules in your tests should be mocked automatically
8 | // automock: false,
9 |
10 | // Stop running tests after the first failure
11 | // bail: false,
12 |
13 | // Respect "browser" field in package.json when resolving modules
14 | // browser: false,
15 |
16 | // The directory where Jest should store its cached dependency information
17 | // cacheDirectory: "/var/folders/_9/36np89bs4rn7zlz3zs10g45c0000gn/T/jest_dx",
18 |
19 | // Automatically clear mock calls and instances between every test
20 | clearMocks: true,
21 |
22 | // Indicates whether the coverage information should be collected while executing the test
23 | collectCoverage: true,
24 |
25 | // An array of glob patterns indicating a set of files for which coverage information should be collected
26 | // collectCoverageFrom: null,
27 |
28 | // The directory where Jest should output its coverage files
29 | coverageDirectory: "coverage",
30 |
31 | // An array of regexp pattern strings used to skip coverage collection
32 | coveragePathIgnorePatterns: [
33 | "/node_modules/",
34 | "jest.config.js"
35 | ],
36 |
37 | // A list of reporter names that Jest uses when writing coverage reports
38 | coverageReporters: [
39 | "json",
40 | "text",
41 | "lcov",
42 | "clover",
43 | "cobertura"
44 | ],
45 |
46 | // An object that configures minimum threshold enforcement for coverage results
47 | // coverageThreshold: null,
48 |
49 | // Make calling deprecated APIs throw helpful error messages
50 | // errorOnDeprecated: false,
51 |
52 | // Force coverage collection from ignored files usin a array of glob patterns
53 | // forceCoverageMatch: [],
54 |
55 | // A path to a module which exports an async function that is triggered once before all test suites
56 | // globalSetup: null,
57 |
58 | // A path to a module which exports an async function that is triggered once after all test suites
59 | // globalTeardown: null,
60 |
61 | // A set of global variables that need to be available in all test environments
62 | // globals: {},
63 |
64 | // An array of directory names to be searched recursively up from the requiring module's location
65 | // moduleDirectories: [
66 | // "node_modules"
67 | // ],
68 |
69 | // An array of file extensions your modules use
70 | moduleFileExtensions: [
71 | "js"
72 | // "json",
73 | // "jsx",
74 | // "node"
75 | ],
76 |
77 | // A map from regular expressions to module names that allow to stub out resources with a single module
78 | // moduleNameMapper: {},
79 |
80 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
81 | // modulePathIgnorePatterns: [],
82 |
83 | // Activates notifications for test results
84 | // notify: false,
85 |
86 | // An enum that specifies notification mode. Requires { notify: true }
87 | // notifyMode: "always",
88 |
89 | // A preset that is used as a base for Jest's configuration
90 | // preset: null,
91 |
92 | // Run tests from one or more projects
93 | // projects: null,
94 |
95 | // Use this configuration option to add custom reporters to Jest
96 | // reporters: undefined,
97 |
98 | // Automatically reset mock state between every test
99 | // resetMocks: false,
100 |
101 | // Reset the module registry before running each individual test
102 | // resetModules: false,
103 |
104 | // A path to a custom resolver
105 | // resolver: null,
106 |
107 | // Automatically restore mock state between every test
108 | // restoreMocks: false,
109 |
110 | // The root directory that Jest should scan for tests and modules within
111 | // rootDir: null,
112 |
113 | // A list of paths to directories that Jest should use to search for files in
114 | // roots: [
115 | // ""
116 | // ],
117 |
118 | // Allows you to use a custom runner instead of Jest's default test runner
119 | // runner: "jest-runner",
120 |
121 | // The paths to modules that run some code to configure or set up the testing environment before each test
122 | // setupFiles: [],
123 |
124 | // The path to a module that runs some code to configure or set up the testing framework before each test
125 | // setupTestFrameworkScriptFile: null,
126 |
127 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
128 | // snapshotSerializers: [],
129 |
130 | // The test environment that will be used for testing
131 | testEnvironment: "jsdom",
132 |
133 | // Options that will be passed to the testEnvironment
134 | // testEnvironmentOptions: {},
135 |
136 | // Adds a location field to test results
137 | // testLocationInResults: false,
138 |
139 | // The glob patterns Jest uses to detect test files
140 | // testMatch: [
141 | // "**/__tests__/**/*.js?(x)",
142 | // "**/?(*.)+(spec|test).js?(x)"
143 | // ],
144 |
145 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
146 | testPathIgnorePatterns: [
147 | "/node_modules/",
148 | "/docs/"
149 | ],
150 |
151 | // The regexp pattern Jest uses to detect test files
152 | // testRegex: "",
153 |
154 | // This option allows the use of a custom results processor
155 | // testResultsProcessor: null,
156 |
157 | // This option allows use of a custom test runner
158 | // testRunner: "jasmine2",
159 |
160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
161 | // testURL: "http://localhost",
162 |
163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
164 | // timers: "real",
165 |
166 | // A map from regular expressions to paths to transformers
167 | // transform: null,
168 |
169 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
170 | // transformIgnorePatterns: [
171 | // "/node_modules/"
172 | // ],
173 |
174 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
175 | // unmockedModulePathPatterns: undefined,
176 |
177 | // Indicates whether each individual test should be reported during the run
178 | // verbose: null,
179 |
180 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
181 | // watchPathIgnorePatterns: [
182 | // "docs"
183 | // ],
184 |
185 | // Whether to use watchman for file crawling
186 | // watchman: true,
187 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "azure-nodejs-demo",
3 | "version": "0.11.2",
4 | "author": "Julie Ng ",
5 | "license": "MIT",
6 | "description": "A multipurpose dummy node.js app for cloud architecture demos",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/julie-ng/azure-nodejs-demo"
10 | },
11 | "private": true,
12 | "scripts": {
13 | "start": "node app/server.js",
14 | "dev": "NODE_ENV=development nodemon -w app app/server.js",
15 | "lint": "eslint .",
16 | "my-version": "node -e \"console.log(require('./package.json').version)\"",
17 | "release": "standard-version --sign",
18 | "test": "jest",
19 | "test:watch": "jest --watch",
20 | "preflight": "NODE_ENV=test npm audit && npm run lint && npm run test",
21 | "compile-sass": "node compile-sass.js"
22 | },
23 | "dependencies": {
24 | "applicationinsights": "^1.8.10",
25 | "body-parser": "^1.19.0",
26 | "express": "^4.16.3",
27 | "express-handlebars": "^5.3.1",
28 | "handlebars": "^4.7.7",
29 | "hbs": "^4.1.2",
30 | "helmet": "^3.23.3",
31 | "http": "0.0.0",
32 | "morgan": "^1.10.0",
33 | "standard-healthcheck": "^1.0.1"
34 | },
35 | "devDependencies": {
36 | "eslint": "^8.0.0",
37 | "jest": "^27.2.5",
38 | "mem": "^8.0.0",
39 | "node-notifier": ">=8.0.1",
40 | "nodemon": "^2.0.7",
41 | "request": "^2.88.2",
42 | "sass": "^1.32.8"
43 | },
44 | "engines": {
45 | "node": ">12.0.0",
46 | "npm": ">6.1.0"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------