├── .dockerignore ├── .eslintrc ├── .github └── workflows │ ├── codeql-analysis.yaml │ └── lint-filenames.yaml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── app.js ├── app.test.js ├── middleware │ ├── force-https.js │ └── monitor.js ├── server.js └── views │ ├── default.hbs │ ├── home.hbs │ └── layout.hbs ├── assets ├── css │ └── styles.scss └── images │ ├── msft-azure-logo.svg │ └── nodejs-logo.svg ├── azure-architecture.svg ├── azure-pipelines ├── README.md ├── dev.yaml ├── jobs │ ├── app-service.yaml │ ├── asset-pipeline.yaml │ ├── docker.yaml │ └── tests.yaml ├── production.yaml ├── refresh-prod-image.yaml ├── steps │ ├── append-sha.yaml │ └── debug-vars.yaml └── vars │ ├── dev.yaml │ ├── global.yaml │ └── prod.yaml ├── compile-sass.js ├── jest.config.js ├── package-lock.json └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | azure-pipelines/ 2 | coverage/ 3 | diagrams/ 4 | node_modules/ 5 | .DS_Store 6 | *.azcli -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6 4 | }, 5 | "extends": "eslint:recommended", 6 | "env": { 7 | "es6": true, 8 | "jasmine": true, 9 | "node": true 10 | }, 11 | "globals": { 12 | "context": true 13 | }, 14 | "rules": { 15 | "no-console": "off", 16 | "no-unused-vars": [ 17 | "error", 18 | { 19 | "args": "none" 20 | } 21 | ], 22 | "object-curly-spacing": [ 23 | "error", 24 | "always" 25 | ], 26 | "semi": [ 27 | "error", 28 | "never" 29 | ], 30 | "space-before-blocks": "error", 31 | "space-before-function-paren": "error", 32 | "spaced-comment": [ 33 | "error", 34 | "always", 35 | { 36 | "exceptions": [ 37 | "-", 38 | "+" 39 | ] 40 | } 41 | ], 42 | "strict": "warn" 43 | } 44 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yaml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main, production ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 22 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/lint-filenames.yaml: -------------------------------------------------------------------------------- 1 | name: lint-filenames 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'dev' 7 | - 'main' 8 | - 'feat/*' 9 | - 'fix/*' 10 | pull_request: 11 | types: [opened, synchronize, reopened] 12 | branches: 13 | - 'main' 14 | - 'dev' 15 | 16 | jobs: 17 | lint-filenames: 18 | runs-on: ubuntu-latest 19 | name: Lint filenames 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Lint Filenames 25 | uses: julie-ng/lowercase-linter@v1 26 | id: linter 27 | with: 28 | path: '.' 29 | pr-comment: true 30 | repo-token: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Output Pull Request Comment URL 33 | run: echo "${{ steps.linter.outputs.comment-url }}" 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | .DS_Store 4 | *.azcli 5 | 6 | # Compiled CSS 7 | assets/css/*.css -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.11.2](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.11.1...v0.11.2) (2022-09-22) 6 | 7 | 8 | ### Features 9 | 10 | * **devops:** rebuild prod docker image weekly ([21b0fcb](https://github.com/julie-ng/azure-nodejs-demo/commit/21b0fcb64460600832577f335362205850f9ef08)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **deps:** update ([86bf96d](https://github.com/julie-ng/azure-nodejs-demo/commit/86bf96d5d917f4a10b8630fd79dc9b4823448e3c)) 16 | 17 | ### [0.11.1](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.11.0...v0.11.1) (2022-07-13) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **css-url:** use package json version without v- ([3b51a28](https://github.com/julie-ng/azure-nodejs-demo/commit/3b51a282b57899d640c91dd6e65195a4e3920500)) 23 | 24 | ## [0.11.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.10.1...v0.11.0) (2022-07-12) 25 | 26 | 27 | ### Features 28 | 29 | * adjust title ([e85d635](https://github.com/julie-ng/azure-nodejs-demo/commit/e85d6359bfb2b637aeb505ab8b1257fddb475c5a)) 30 | 31 | ### [0.10.1](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.10.0...v0.10.1) (2022-07-12) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **pipelines:** add missing app service settings, fix CDN path ([325fcad](https://github.com/julie-ng/azure-nodejs-demo/commit/325fcad513d562a5ac3cec85ada6549be5a335bd)) 37 | 38 | ## [0.10.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.9.0...v0.10.0) (2022-06-28) 39 | 40 | 41 | ### Features 42 | 43 | * use node v16 ([8ea8211](https://github.com/julie-ng/azure-nodejs-demo/commit/8ea8211a6a143c67bcdfd22231abb5e423cf2322)) 44 | * **dockerfile:** update for node.js container best practices ([58628b0](https://github.com/julie-ng/azure-nodejs-demo/commit/58628b002834518d5e2e9bfa5c19fede9f024dfe)) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * **footer:** pipeline results URL ([ec5c5aa](https://github.com/julie-ng/azure-nodejs-demo/commit/ec5c5aab0afdefe94930814915d585e81d3faa22)) 50 | * **view:** use NODE_ENV for local dev, not package.json version ([96d2d1d](https://github.com/julie-ng/azure-nodejs-demo/commit/96d2d1d48685d454db697c8ec4d572fb6a2d837f)) 51 | 52 | ## [0.9.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.8.0...v0.9.0) (2022-05-03) 53 | 54 | 55 | ### Features 56 | 57 | * **ci:** lint filenames for mixed case ([6b4c105](https://github.com/julie-ng/azure-nodejs-demo/commit/6b4c105a789864e05d7fab0c21d2300559a2e64b)) 58 | * **pipelines:** add git sha tags for dev images ([37a3fa1](https://github.com/julie-ng/azure-nodejs-demo/commit/37a3fa1c7ff5675eacd333e91034ab9a83626daa)) 59 | * **pipelines:** batch changes to limit pipeline runs ([#6](https://github.com/julie-ng/azure-nodejs-demo/issues/6)) ([58ea5ec](https://github.com/julie-ng/azure-nodejs-demo/commit/58ea5ec92b154cd51ff7e3d102e1602613e24265)) 60 | * **pipelines:** refactor, leverage templates ([7b5e4c0](https://github.com/julie-ng/azure-nodejs-demo/commit/7b5e4c0f89db29c11bfc815417f8f9f323a8bebc)) 61 | * **pipelines:** remove unneeded conditionals for envs ([58a3f7b](https://github.com/julie-ng/azure-nodejs-demo/commit/58a3f7b4cb5c8aa0d32e0c52a2e2a6e94fd5a20f)) 62 | * **pipelines:** rename to dev and production yamls ([29471af](https://github.com/julie-ng/azure-nodejs-demo/commit/29471af18c87e508a255f1b64359572e26350f87)) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * **deps:** bump to fix security issues ([52d65e4](https://github.com/julie-ng/azure-nodejs-demo/commit/52d65e49efbee8bffc76326369e9d0e1d1164894)) 68 | * package.json & package-lock.json to reduce vulnerabilities ([fe52594](https://github.com/julie-ng/azure-nodejs-demo/commit/fe52594b4b7504f9ebf5da3f7a4671ad852820c8)) 69 | 70 | ## [0.8.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.7.1...v0.8.0) (2021-04-12) 71 | 72 | 73 | ### Features 74 | 75 | * **pipeline:** refactored into separate files, templates ([fd23811](https://github.com/julie-ng/azure-nodejs-demo/commit/fd23811e35e946ab600d95c99c61ffb80464f2a3)) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * **app-insights:** only load and setup once ([14cac41](https://github.com/julie-ng/azure-nodejs-demo/commit/14cac41314e425c9e3963f872092f98011ca2e72)) 81 | 82 | ### [0.7.1](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.7.0...v0.7.1) (2021-04-09) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * **healthcheck:** bump dep for proper uptime calculation ([441010b](https://github.com/julie-ng/azure-nodejs-demo/commit/441010b7c8af659f6478418e5961fc90aa7a9293)) 88 | * package.json & package-lock.json to reduce vulnerabilities ([0125b79](https://github.com/julie-ng/azure-nodejs-demo/commit/0125b7931ab4f786f05fbc0bd184c597cf4fdb4d)) 89 | 90 | ## [0.7.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.6.0...v0.7.0) (2020-09-09) 91 | 92 | 93 | ### Features 94 | 95 | * **azure-pipelines:** add snyk container security scan ci build step ([82f8bb4](https://github.com/julie-ng/azure-nodejs-demo/commit/82f8bb49163979acbff8aea8982af317975f9ea1)) 96 | * **security:** update node docker base image for vulnerability fixes ([599c961](https://github.com/julie-ng/azure-nodejs-demo/commit/599c961cdcbd417a942e4e46c78095cdc57ea4a2)) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * **azure-pipelines:** do not deploy if tests stage fails ([a398ca2](https://github.com/julie-ng/azure-nodejs-demo/commit/a398ca20a667546efb29714e3ea8b90fcac56f98)) 102 | 103 | ## [0.6.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.5.0...v0.6.0) (2020-06-14) 104 | 105 | 106 | ### Features 107 | 108 | * rename base branch from master to main ([222689f](https://github.com/julie-ng/azure-nodejs-demo/commit/222689f0afa191abfcbf842247e0267abd5b7189)) 109 | * update badge urls after project rename ([5e6f085](https://github.com/julie-ng/azure-nodejs-demo/commit/5e6f0852149b19233080574d50c711f32675b805)) 110 | 111 | ## [0.5.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.4.1...v0.5.0) (2020-05-24) 112 | 113 | 114 | ### Features 115 | 116 | * **pipeline:** add purge cdn step after upload ([6a482d9](https://github.com/julie-ng/azure-nodejs-demo/commit/6a482d9a771258ef8abd9ef3821294a824766689)) 117 | * **pipeline:** refactor, removing job complexity, simplify setting prod values ([fad72ad](https://github.com/julie-ng/azure-nodejs-demo/commit/fad72adfb0cb948c658a17f759da82f6ea86b413)) 118 | * externalize images and sass to own asset pipeline to azure blob storage ([a8e2559](https://github.com/julie-ng/azure-nodejs-demo/commit/a8e2559001d50d2e1d3cd34cef2d37646e8cc38a)) 119 | * re-design, add ci badge, app version, logos ([03b23bc](https://github.com/julie-ng/azure-nodejs-demo/commit/03b23bc03ac556ec4acbf38bc515e09f9da1a9eb)) 120 | * **azure-pipelines:** adjust audit level ([2576a3f](https://github.com/julie-ng/azure-nodejs-demo/commit/2576a3f2a0d948aa0892c3118f10513b4898c08f)) 121 | * **azure-pipelines:** refactor variables, use lock image template ([66e3768](https://github.com/julie-ng/azure-nodejs-demo/commit/66e3768cd2a36f77bb4c6457392ddcdea5613284)) 122 | * **healthcheck:** use version 1.0 of standard-healthcheck ([f22d708](https://github.com/julie-ng/azure-nodejs-demo/commit/f22d7086952f4def5b0cc235582a57d60f13cb45)) 123 | * **pipeline:** indicate variable comes from library ([4304c84](https://github.com/julie-ng/azure-nodejs-demo/commit/4304c84959dca6376d199e16b7b7b8bef7294e5c)) 124 | * **pipeline:** separate prod and dev dependency audit ([106b2f1](https://github.com/julie-ng/azure-nodejs-demo/commit/106b2f12f9d4c7e570a8e34a0a3343482d01c86f)) 125 | 126 | ### [0.4.1](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.4.0...v0.4.1) (2020-03-02) 127 | 128 | 129 | ### Features 130 | 131 | * **pipelines:** lock release images in azure container registry ([8c27622](https://github.com/julie-ng/azure-nodejs-demo/commit/8c276223b8a51cb521f809e406ad54b20a8bb486)) 132 | 133 | 134 | ### Bug Fixes 135 | 136 | * **pipelines:** service connection variable name ([fd52434](https://github.com/julie-ng/azure-nodejs-demo/commit/fd52434a7e43e812ec417d01c55c4beef8031d76)) 137 | 138 | ## [0.4.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.3.0...v0.4.0) (2020-03-02) 139 | 140 | 141 | ### Features 142 | 143 | * **pipeline:** use templates ([0d8cfd1](https://github.com/julie-ng/azure-nodejs-demo/commit/0d8cfd115ec37122c591fa50b7cb67bdfc22ecf8)) 144 | 145 | 146 | ### Bug Fixes 147 | 148 | * **pipeline:** need to use release tag when deploying ([0b2cd9b](https://github.com/julie-ng/azure-nodejs-demo/commit/0b2cd9b023cc9b9655a0f8f2a018f0515849f300)) 149 | 150 | ## [0.3.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.2.1...v0.3.0) (2020-02-25) 151 | 152 | 153 | ### Features 154 | 155 | * **health:** use snakecase ([43b7a6b](https://github.com/julie-ng/azure-nodejs-demo/commit/43b7a6bcf44b8b6345c00710066cb6c1c2f81e86)) 156 | * **healthcheck:** use standard-healthcheck pkg ([d36c8c2](https://github.com/julie-ng/azure-nodejs-demo/commit/d36c8c22e53f22fc8b2e9813501bbdd72c47a9b4)) 157 | * **pipeline:** use azure-devops variable groups ([954ac50](https://github.com/julie-ng/azure-nodejs-demo/commit/954ac506d1d82344d21abca766515638c7a8c54b)) 158 | * **release:** add npm script to get my project version from package.json ([b12d071](https://github.com/julie-ng/azure-nodejs-demo/commit/b12d071c784f6d315a67d5146a45ece1b824f50d)) 159 | * **release-pipeline:** adjust docker image tags, lock image for release branch ([c4812c5](https://github.com/julie-ng/azure-nodejs-demo/commit/c4812c5c8d559a5631c9ce000da2663ae224c060)) 160 | 161 | 162 | ### Bug Fixes 163 | 164 | * **pipeline:** include registry so app service pulls from ACR not Docker Hub ([4e4bf67](https://github.com/julie-ng/azure-nodejs-demo/commit/4e4bf6708f765ef5bd1f49e83dec33bd1677decc)) 165 | * **release-pipeline:** npm script name ([935c098](https://github.com/julie-ng/azure-nodejs-demo/commit/935c09841d06db985fa4664e9a77357aab79c4ad)) 166 | 167 | 168 | ## [0.2.1](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.2.0...v0.2.1) (2019-10-19) 169 | 170 | 171 | ### Bug Fixes 172 | 173 | * **pipelines:** use global image tag so deploy job also has value ([4fa42bc](https://github.com/julie-ng/azure-nodejs-demo/commit/4fa42bc)) 174 | 175 | 176 | 177 | 178 | # [0.2.0](https://github.com/julie-ng/azure-nodejs-demo/compare/v0.1.0...v0.2.0) (2019-10-19) 179 | 180 | 181 | ### Features 182 | 183 | * **pipeline:** add registry namespace, clean up variables ([9f6081f](https://github.com/julie-ng/azure-nodejs-demo/commit/9f6081f)) 184 | 185 | 186 | 187 | 188 | # 0.1.0 (2019-09-22) 189 | 190 | 191 | ### Bug Fixes 192 | 193 | * **pipeline:** image tag for deployment ([04c3f9d](https://github.com/julie-ng/azure-nodejs-demo/commit/04c3f9d)) 194 | 195 | 196 | ### Features 197 | 198 | * **handlebars:** add templating, homepage content ([5a79585](https://github.com/julie-ng/azure-nodejs-demo/commit/5a79585)) 199 | * **middleware:** create subdir, force https ([ed13db7](https://github.com/julie-ng/azure-nodejs-demo/commit/ed13db7)) 200 | * **monitoring:** add appication insights ([84c40f5](https://github.com/julie-ng/azure-nodejs-demo/commit/84c40f5)) 201 | * **pipeline:** continue if audit fails ([64d1734](https://github.com/julie-ng/azure-nodejs-demo/commit/64d1734)) 202 | * **pipeline:** improve display names ([77fb213](https://github.com/julie-ng/azure-nodejs-demo/commit/77fb213)) 203 | * **pipeline:** use newer docker tasks ([b3eb65f](https://github.com/julie-ng/azure-nodejs-demo/commit/b3eb65f)) 204 | * **webhook:** add test path ([77718f3](https://github.com/julie-ng/azure-nodejs-demo/commit/77718f3)) 205 | * **webhook:** log payload ([c748b77](https://github.com/julie-ng/azure-nodejs-demo/commit/c748b77)) 206 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | LABEL maintainer="Julie Ng " 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 | | [![Build Status](https://dev.azure.com/julie-msft/public-demos/_apis/build/status/azure-nodejs-demo%20(dev)?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 | | [![Build Status](https://dev.azure.com/julie-msft/public-demos/_apis/build/status/azure-nodejs-demo%20(production)?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 | Demo Architecture 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 |
12 |

13 | {{ title }} 14 | {{ version }} 15 |

16 |
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 | 19 | 21 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 65 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /assets/images/nodejs-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /azure-architecture.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
 pull 
 pull 
pull
pull
app.com/products
app.com/products
cdn.app.com/logo.png
cdn.app.com/logo.png
Azure Container RegistryAzure Web AppAzure Blob StorageAzure CDN
Viewer does not support full SVG 1.1
-------------------------------------------------------------------------------- /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 | [![YouTube Video](https://img.youtube.com/vi/e0bF1LlclEs/0.jpg)](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 | --------------------------------------------------------------------------------