From 18f631c1ad01254d0d6fa8273e4409278949071c Mon Sep 17 00:00:00 2001 From: Emah1 Date: Sun, 11 Oct 2026 23:17:31 +0100 Subject: [PATCH 01/16] Add three-candidate voting and duplicate vote protection --- docker-compose.yml | 4 ++ result/server.js | 6 ++- result/views/app.js | 61 ++++++++++++++++++------------ result/views/index.html | 43 +++++++++++++-------- result/views/stylesheets/style.css | 35 ++++++++++------- vote/app.py | 27 ++++++++++++- vote/static/stylesheets/style.css | 28 ++++++++++++++ vote/templates/index.html | 29 ++++++++------ worker/Program.cs | 36 +++++++++--------- 9 files changed, 182 insertions(+), 87 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5915ffd741..7c35659397 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,10 @@ services: build: context: ./vote target: dev + environment: + OPTION_A: "NDC / Peter Obi" + OPTION_B: "ADC / Atiku" + OPTION_C: "APC / Tinubu" depends_on: redis: condition: service_healthy diff --git a/result/server.js b/result/server.js index 1c8593e7ee..84cbce1710 100644 --- a/result/server.js +++ b/result/server.js @@ -54,10 +54,12 @@ function getVotes(client) { } function collectVotesFromResult(result) { - var votes = {a: 0, b: 0}; + var votes = {a: 0, b: 0, c: 0}; result.rows.forEach(function (row) { - votes[row.vote] = parseInt(row.count); + if (Object.prototype.hasOwnProperty.call(votes, row.vote)) { + votes[row.vote] = parseInt(row.count, 10); + } }); return votes; diff --git a/result/views/app.js b/result/views/app.js index fe8b60044b..e9285f9729 100644 --- a/result/views/app.js +++ b/result/views/app.js @@ -1,50 +1,63 @@ + var app = angular.module('catsvsdogs', []); var socket = io.connect(); var bg1 = document.getElementById('background-stats-1'); var bg2 = document.getElementById('background-stats-2'); +var bg3 = document.getElementById('background-stats-3'); -app.controller('statsCtrl', function($scope){ - $scope.aPercent = 50; - $scope.bPercent = 50; +app.controller('statsCtrl', function ($scope) { + $scope.aPercent = 33.3; + $scope.bPercent = 33.3; + $scope.cPercent = 33.4; + $scope.total = 0; - var updateScores = function(){ + var updateScores = function () { socket.on('scores', function (json) { - data = JSON.parse(json); - var a = parseInt(data.a || 0); - var b = parseInt(data.b || 0); + var data = JSON.parse(json); + + var a = parseInt(data.a || 0, 10); + var b = parseInt(data.b || 0, 10); + var c = parseInt(data.c || 0, 10); - var percentages = getPercentages(a, b); + var percentages = getPercentages(a, b, c); - bg1.style.width = percentages.a + "%"; - bg2.style.width = percentages.b + "%"; + bg1.style.width = percentages.a + '%'; + bg2.style.width = percentages.b + '%'; + bg3.style.width = percentages.c + '%'; - $scope.$apply(function () { - $scope.aPercent = percentages.a; - $scope.bPercent = percentages.b; - $scope.total = a + b; - }); + $scope.$apply(function () { + $scope.aPercent = percentages.a; + $scope.bPercent = percentages.b; + $scope.cPercent = percentages.c; + $scope.total = a + b + c; + }); }); }; - var init = function(){ - document.body.style.opacity=1; + var init = function () { + document.body.style.opacity = 1; updateScores(); }; - socket.on('message',function(data){ + + socket.on('message', function () { init(); }); }); -function getPercentages(a, b) { +function getPercentages(a, b, c) { var result = {}; + var total = a + b + c; - if (a + b > 0) { - result.a = Math.round(a / (a + b) * 100); - result.b = 100 - result.a; + if (total > 0) { + result.a = Math.round((a / total) * 1000) / 10; + result.b = Math.round((b / total) * 1000) / 10; + result.c = Math.round((100 - result.a - result.b) * 10) / 10; } else { - result.a = result.b = 50; + result.a = 33.3; + result.b = 33.3; + result.c = 33.4; } return result; -} \ No newline at end of file +} diff --git a/result/views/index.html b/result/views/index.html index 4427ffa1af..9f4f75664a 100644 --- a/result/views/index.html +++ b/result/views/index.html @@ -2,7 +2,7 @@ - Cats vs Dogs -- Result + Votes Cast -- Result @@ -11,24 +11,35 @@
-
-
-
+
+
+
+
-
-
-
Cats
-
{{aPercent | number:1}}%
-
-
-
-
Dogs
-
{{bPercent | number:1}}%
-
-
+ +
+
+
NDC / Peter Obi
+
{{aPercent | number:1}}%
+
+ +
+ +
+
ADC / Atiku
+
{{bPercent | number:1}}%
+
+ +
+ +
+
APC / Tinubu
+
{{cPercent | number:1}}%
+
+
+
diff --git a/result/views/stylesheets/style.css b/result/views/stylesheets/style.css index 6842773ebb..7ad04d4620 100644 --- a/result/views/stylesheets/style.css +++ b/result/views/stylesheets/style.css @@ -15,7 +15,7 @@ body{ } .divider{ - height: 150px; + height: 120px; width:2px; background-color: #C0C9CE; position: relative; @@ -29,7 +29,11 @@ body{ } #background-stats-2{ - background-color: #00cbca; + background-color: #ff9800; +} + +#background-stats-3{ + background-color: #e4260d; } #content-container{ @@ -64,8 +68,8 @@ body{ vertical-align:middle; font-size:40px; font-weight: 600; - width: 450px; - height: 200px; + width: 650px; + height: 240px; } #choice a{ text-decoration:none; @@ -76,27 +80,32 @@ body{ } #choice .choice{ - width: 49%; + width: 33.333%; position: relative; top: 50%; transform: translateY(-50%); - text-align: left; - padding-left: 50px; + text-align: center; + padding: 0 10px; + float: left; + font-size: 22px; } #choice .choice .label{ text-transform: uppercase; } -#choice .choice.dogs{ - color: #00cbca; - float: right; +#choice .choice.a{ + color: #2196f3; } -#choice .choice.cats{ - color: #2196f3; - float: left; +#choice .choice.b{ + color: #ff9800; +} + +#choice .choice.c{ + color: #e4260d; } + #background-stats{ z-index:1; height:100%; diff --git a/vote/app.py b/vote/app.py index 596546612a..f1823bba4c 100644 --- a/vote/app.py +++ b/vote/app.py @@ -8,6 +8,7 @@ option_a = os.getenv('OPTION_A', "Cats") option_b = os.getenv('OPTION_B', "Dogs") +option_c = os.getenv('OPTION_C', "Birds") hostname = socket.gethostname() app = Flask(__name__) @@ -29,17 +30,39 @@ def hello(): vote = None + if request.method == 'POST': + vote = request.form.get('vote') + + if vote not in ('a', 'b', 'c'): + return 'Invalid voting option.', 400 + + redis = get_redis() - vote = request.form['vote'] + + # Reserve this browser's voter ID only if it has not voted before. + reservation_key = 'voted:' + voter_id + reserved = redis.set(reservation_key, vote, nx=True) + + if not reserved: + return 'A vote has already been submitted from this browser.', 409 + app.logger.info('Received vote for %s', vote) data = json.dumps({'voter_id': voter_id, 'vote': vote}) - redis.rpush('votes', data) + + try: + redis.rpush('votes', data) + except Exception: + # Release the reservation if queueing fails. + redis.delete(reservation_key) + raise + resp = make_response(render_template( 'index.html', option_a=option_a, option_b=option_b, + option_c=option_c, hostname=hostname, vote=vote, )) diff --git a/vote/static/stylesheets/style.css b/vote/static/stylesheets/style.css index 53de54388d..25a2a3a0fa 100644 --- a/vote/static/stylesheets/style.css +++ b/vote/static/stylesheets/style.css @@ -127,3 +127,31 @@ button.b{ width:50%; height:100%; } + +/* Candidate button colours */ +#choice button.a { + background-color: #1aaaf8; +} + +#choice button.b { + background-color: #00cbca; +} + +#choice button.c { + background-color: #e4260d; +} + +#choice button.a:hover, +#choice button.a:focus { + background-color: #1488c6; +} + +#choice button.b:hover, +#choice button.b:focus { + background-color: #00a2a1; +} + +#choice button.c:hover, +#choice button.c:focus { + background-color: #b51d0a; +} diff --git a/vote/templates/index.html b/vote/templates/index.html index fb58924dc7..1e993c9ffe 100644 --- a/vote/templates/index.html +++ b/vote/templates/index.html @@ -2,7 +2,7 @@ - {{option_a}} vs {{option_b}}! + Choose your candidate @@ -13,10 +13,11 @@
-

{{option_a}} vs {{option_b}}!

+

Choose your candidate

+
(Tip: you can change your vote) @@ -29,20 +30,24 @@

{{option_a}} vs {{option_b}}!

+ {% if vote %} {% endif %} diff --git a/worker/Program.cs b/worker/Program.cs index 9b5fb74d1a..01267487bd 100644 --- a/worker/Program.cs +++ b/worker/Program.cs @@ -130,25 +130,25 @@ private static string GetIp(string hostname) .First(a => a.AddressFamily == AddressFamily.InterNetwork) .ToString(); + private static void UpdateVote(NpgsqlConnection connection, string voterId, string vote) { - var command = connection.CreateCommand(); - try - { - command.CommandText = "INSERT INTO votes (id, vote) VALUES (@id, @vote)"; - command.Parameters.AddWithValue("@id", voterId); - command.Parameters.AddWithValue("@vote", vote); - command.ExecuteNonQuery(); - } - catch (DbException) - { - command.CommandText = "UPDATE votes SET vote = @vote WHERE id = @id"; - command.ExecuteNonQuery(); - } - finally + using (var command = connection.CreateCommand()) { - command.Dispose(); + try + { + command.CommandText = "INSERT INTO votes (id, vote) VALUES (@id, @vote)"; + command.Parameters.AddWithValue("@id", voterId); + command.Parameters.AddWithValue("@vote", vote); + command.ExecuteNonQuery(); + + Console.WriteLine($"Vote accepted for voter '{voterId}'"); + } + catch (PostgresException ex) when (ex.SqlState == "23505") + { + Console.WriteLine($"Duplicate vote rejected for voter '{voterId}'"); + } } - } - } -} \ No newline at end of file +} +} +} From e404f939f25235e5d83f2dab0c709364db67c0e5 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Mon, 12 Oct 2026 20:52:50 +0100 Subject: [PATCH 02/16] Add CI validation and Docker build workflow --- .github/workflows/validate-voting-app.yml | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/validate-voting-app.yml diff --git a/.github/workflows/validate-voting-app.yml b/.github/workflows/validate-voting-app.yml new file mode 100644 index 0000000000..5d740c869a --- /dev/null +++ b/.github/workflows/validate-voting-app.yml @@ -0,0 +1,45 @@ + +name: Validate Voting App + +on: + push: + branches: + - main + - feature/three-voting-options + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: Validate and Build + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Check Python syntax + run: python -m py_compile vote/app.py + + - name: Check patch formatting + run: git diff --check origin/main...HEAD + + - name: Build Vote image + run: docker build -t voting-app-vote:ci ./vote + + - name: Build Result image + run: docker build -t voting-app-result:ci ./result + + - name: Build Worker image + run: docker build -t voting-app-worker:ci ./worker From b2fd9f74d064d7d66a5c170891ea3baa35d60966 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 09:55:09 +0100 Subject: [PATCH 03/16] Fix Docker registry authentication in CI --- .github/workflows/call-docker-build-result.yaml | 5 ++--- .github/workflows/call-docker-build-vote.yaml | 5 ++--- .github/workflows/call-docker-build-worker.yaml | 6 ++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/call-docker-build-result.yaml b/.github/workflows/call-docker-build-result.yaml index a946a87b03..1efc59a43c 100644 --- a/.github/workflows/call-docker-build-result.yaml +++ b/.github/workflows/call-docker-build-result.yaml @@ -44,15 +44,14 @@ jobs: ### ENABLE ONE OR BOTH REGISTRIES ### tell docker where to push. ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true + dockerhub-enable: false ghcr-enable: true ### REQUIRED ### A list of the account/repo names for docker build. List should match what's enabled above ### defaults to: image-names: | - ghcr.io/dockersamples/example-voting-app-result - dockersamples/examplevotingapp_result + ghcr.io/emah1/example-voting-app-result ### REQUIRED set rules for tagging images, based on special action syntax: ### https://github.com/docker/metadata-action#tags-input diff --git a/.github/workflows/call-docker-build-vote.yaml b/.github/workflows/call-docker-build-vote.yaml index cb4a484a2a..7109916966 100644 --- a/.github/workflows/call-docker-build-vote.yaml +++ b/.github/workflows/call-docker-build-vote.yaml @@ -44,15 +44,14 @@ jobs: ### ENABLE ONE OR BOTH REGISTRIES ### tell docker where to push. ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true + dockerhub-enable: false ghcr-enable: true ### REQUIRED ### A list of the account/repo names for docker build. List should match what's enabled above ### defaults to: image-names: | - ghcr.io/dockersamples/example-voting-app-vote - dockersamples/examplevotingapp_vote + ghcr.io/emah1/example-voting-app-vote ### REQUIRED set rules for tagging images, based on special action syntax: ### https://github.com/docker/metadata-action#tags-input diff --git a/.github/workflows/call-docker-build-worker.yaml b/.github/workflows/call-docker-build-worker.yaml index 5abfb6bc9c..45a2af9c5e 100644 --- a/.github/workflows/call-docker-build-worker.yaml +++ b/.github/workflows/call-docker-build-worker.yaml @@ -44,16 +44,14 @@ jobs: ### ENABLE ONE OR BOTH REGISTRIES ### tell docker where to push. ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true + dockerhub-enable: false ghcr-enable: true ### REQUIRED ### A list of the account/repo names for docker build. List should match what's enabled above ### defaults to: image-names: | - ghcr.io/dockersamples/example-voting-app-worker - dockersamples/examplevotingapp_worker - + ghcr.io/emah1/example-voting-app-worker ### REQUIRED set rules for tagging images, based on special action syntax: ### https://github.com/docker/metadata-action#tags-input ### defaults to: From 5fca6f3c0abaa90bcd0ae57a20258dfb01273e56 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 10:53:48 +0100 Subject: [PATCH 04/16] Update voting results interface --- result/views/index.html | 95 +++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/result/views/index.html b/result/views/index.html index 9f4f75664a..c654989a3a 100644 --- a/result/views/index.html +++ b/result/views/index.html @@ -1,54 +1,65 @@ + - - - Votes Cast -- Result - - - - - - - -
-
-
-
-
-
-
-
+ + + Votes Cast -- Result + + + + + + + + -
-
-
NDC / Peter Obi
-
{{aPercent | number:1}}%
+ +
+
+
+
-
+ +
+
-
-
ADC / Atiku
-
{{bPercent | number:1}}%
-
+
-
+
+
NDC / PETER OBI
+
{{aPercent | number:1}}%
+
-
-
APC / Tinubu
-
{{cPercent | number:1}}%
-
-
+
+ +
+
ADC / ATIKU
+
{{bPercent | number:1}}%
+
+ +
+ +
+
APC / TINUBU
+
{{cPercent | number:1}}%
+
+
-
- No votes yet - {{total}} vote - {{total}} votes -
- - - - +
+ + +
+ No votes yet + {{total}} vote + {{total}} votes +
+ + + + + + From 0fb78b6d14494f81e63b3773b0f44e49b23c1f27 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 11:37:27 +0100 Subject: [PATCH 05/16] Update voting results interface --- result/views/index.html | 2 +- result/views/stylesheets/style.css | 113 +++++++++++++++++++---------- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/result/views/index.html b/result/views/index.html index c654989a3a..1003d0cde6 100644 --- a/result/views/index.html +++ b/result/views/index.html @@ -25,7 +25,7 @@
- +
NDC / PETER OBI
{{aPercent | number:1}}%
diff --git a/result/views/stylesheets/style.css b/result/views/stylesheets/style.css index 7ad04d4620..e8eb568ce6 100644 --- a/result/views/stylesheets/style.css +++ b/result/views/stylesheets/style.css @@ -14,14 +14,16 @@ body{ transition: all 1s linear; } -.divider{ + +#choice .divider { + position: static; + float: none; + top: auto; + transform: none; + flex: 0 0 2px; + width: 2px; height: 120px; - width:2px; - background-color: #C0C9CE; - position: relative; - top: 50%; - float: left; - transform: translateY(-50%); + background-color: #c0c9ce; } #background-stats-1{ @@ -60,17 +62,24 @@ body{ font-size: 45px; font-weight: 600; } -#choice{ - transition: all 300ms linear; - line-height:1.3em; - background:#fff; + +#choice { + z-index: 3; + position: relative; + width: 100%; + max-width: 1000px; + min-height: 240px; + background: #fff; box-shadow: 10px 0 0 #fff, -10px 0 0 #fff; - vertical-align:middle; - font-size:40px; + display: flex; + align-items: center; + justify-content: space-around; + padding: 30px 15px; + font-size: 22px; font-weight: 600; - width: 650px; - height: 240px; + line-height: 1.3em; } + #choice a{ text-decoration:none; } @@ -79,43 +88,73 @@ body{ text-decoration:underline; } -#choice .choice{ +#choice .choice { + position: static; + float: none; + transform: none; + flex: 1 1 0; width: 33.333%; - position: relative; - top: 50%; - transform: translateY(-50%); - text-align: center; + min-width: 0; padding: 0 10px; - float: left; + text-align: center; font-size: 22px; } -#choice .choice .label{ +#choice .choice .label { text-transform: uppercase; } -#choice .choice.a{ +#choice .choice.a { color: #2196f3; } -#choice .choice.b{ +#choice .choice.b { color: #ff9800; } -#choice .choice.c{ +#choice .choice.c { color: #e4260d; } -#background-stats{ - z-index:1; - height:100%; - width:100%; - position:absolute; -} -#background-stats div{ - transition: width 400ms ease-in-out; - display:inline-block; - margin-bottom:-4px; - width:50%; - height:100%; + +#background-stats { + position: fixed; + inset: 0; + z-index: 1; + display: flex; + width: 100%; + height: 100%; + overflow: hidden; +} + +#background-stats div { + display: block; + flex: 1; + width: auto; + height: 100%; + margin: 0; +} + +@media (max-width: 650px) { + #content-container { + width: 100%; + padding: 15px; + } + + #choice { + flex-direction: column; + gap: 20px; + padding: 25px 10px; + } + + #choice .choice { + width: 100%; + flex: auto; + } + + #choice .divider { + width: 70%; + height: 2px; + flex: 0 0 2px; + } } From 5d0f0f02cdb5904aac76b3a8023fb939cd14aea7 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 12:14:42 +0100 Subject: [PATCH 06/16] Update voting results interface --- result/views/index.html | 15 +++++++++++---- result/views/stylesheets/style.css | 31 +++++++++++++++--------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/result/views/index.html b/result/views/index.html index 1003d0cde6..d8d8b45836 100644 --- a/result/views/index.html +++ b/result/views/index.html @@ -14,18 +14,25 @@ +
-
-
-
+
+ +
+ +
+
- +
NDC / PETER OBI
{{aPercent | number:1}}%
diff --git a/result/views/stylesheets/style.css b/result/views/stylesheets/style.css index e8eb568ce6..0bdbd34602 100644 --- a/result/views/stylesheets/style.css +++ b/result/views/stylesheets/style.css @@ -26,18 +26,6 @@ body{ background-color: #c0c9ce; } -#background-stats-1{ - background-color: #2196f3; -} - -#background-stats-2{ - background-color: #ff9800; -} - -#background-stats-3{ - background-color: #e4260d; -} - #content-container{ z-index:2; position:relative; @@ -116,7 +104,6 @@ body{ color: #e4260d; } - #background-stats { position: fixed; inset: 0; @@ -128,13 +115,25 @@ body{ } #background-stats div { - display: block; - flex: 1; - width: auto; + flex: 0 0 auto; height: 100%; margin: 0; + transition: width 600ms ease-in-out; } +#background-stats-1 { + background-color: #2196f3; +} + +#background-stats-2 { + background-color: #ff9800; +} + +#background-stats-3 { + background-color: #e4260d; +} + + @media (max-width: 650px) { #content-container { width: 100%; From 85235cd29bddb3a1a4cf7e88dc56c5eda7acfe8b Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 12:19:34 +0100 Subject: [PATCH 07/16] Update live voting results dashboard --- result/views/app.js | 93 ++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 40 deletions(-) diff --git a/result/views/app.js b/result/views/app.js index e9285f9729..0878aa4ce8 100644 --- a/result/views/app.js +++ b/result/views/app.js @@ -2,62 +2,75 @@ var app = angular.module('catsvsdogs', []); var socket = io.connect(); -var bg1 = document.getElementById('background-stats-1'); -var bg2 = document.getElementById('background-stats-2'); -var bg3 = document.getElementById('background-stats-3'); - app.controller('statsCtrl', function ($scope) { - $scope.aPercent = 33.3; - $scope.bPercent = 33.3; - $scope.cPercent = 33.4; + $scope.aPercent = 0; + $scope.bPercent = 0; + $scope.cPercent = 0; $scope.total = 0; - var updateScores = function () { - socket.on('scores', function (json) { - var data = JSON.parse(json); + // Reveal the page + document.body.style.opacity = 1; - var a = parseInt(data.a || 0, 10); - var b = parseInt(data.b || 0, 10); - var c = parseInt(data.c || 0, 10); + // Listen for score updates continuously + socket.on('scores', function (json) { + var data; - var percentages = getPercentages(a, b, c); + try { + data = typeof json === 'string' + ? JSON.parse(json) + : json; + } catch (error) { + console.error('Invalid scores data:', error); + return; + } - bg1.style.width = percentages.a + '%'; - bg2.style.width = percentages.b + '%'; - bg3.style.width = percentages.c + '%'; + if (!data) return; - $scope.$apply(function () { - $scope.aPercent = percentages.a; - $scope.bPercent = percentages.b; - $scope.cPercent = percentages.c; - $scope.total = a + b + c; - }); - }); - }; + var a = Math.max(0, Number(data.a) || 0); + var b = Math.max(0, Number(data.b) || 0); + var c = Math.max(0, Number(data.c) || 0); - var init = function () { - document.body.style.opacity = 1; - updateScores(); - }; + var total = a + b + c; + + var percentages = getPercentages(a, b, c); + // Update AngularJS safely + $scope.$applyAsync(function () { + $scope.aPercent = percentages.a; + $scope.bPercent = percentages.b; + $scope.cPercent = percentages.c; + $scope.total = total; + }); + }); + + // Support the existing page initialization event socket.on('message', function () { - init(); + document.body.style.opacity = 1; }); }); function getPercentages(a, b, c) { - var result = {}; var total = a + b + c; - if (total > 0) { - result.a = Math.round((a / total) * 1000) / 10; - result.b = Math.round((b / total) * 1000) / 10; - result.c = Math.round((100 - result.a - result.b) * 10) / 10; - } else { - result.a = 33.3; - result.b = 33.3; - result.c = 33.4; + if (total === 0) { + return { + a: 0, + b: 0, + c: 0 + }; } - return result; + var aPercent = Math.round((a / total) * 1000) / 10; + var bPercent = Math.round((b / total) * 1000) / 10; + + // Make the displayed percentages total exactly 100% + var cPercent = Math.round( + (100 - aPercent - bPercent) * 10 + ) / 10; + + return { + a: aPercent, + b: bPercent, + c: cPercent + }; } From b1c153fda9368df9b4394f4bacde72c518f4c15b Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 15:25:20 +0100 Subject: [PATCH 08/16] feat(terraform): add voting app infrastructure configuration --- .gitignore | 6 ++++++ terraform/.terraform.lock.hcl | 25 ++++++++++++++++++++++ terraform/README.md | 40 +++++++++++++++++++++++++++++++++++ terraform/main.tf | 13 ++++++++++++ terraform/outputs.tf | 14 ++++++++++++ terraform/variables.tf | 17 +++++++++++++++ terraform/versions.tf | 11 ++++++++++ 7 files changed, 126 insertions(+) create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/README.md create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.gitignore b/.gitignore index 9a6f694990..ce6d8ccd31 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ bin/ obj/ .vs/ node_modules/ +# Terraform +**/.terraform/* +*.tfstate +*.tfstate.* +crash.log +crash.*.log diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..39a6fbd36d --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..bc3a30783f --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,40 @@ +# Voting App Infrastructure + +This directory contains Terraform configuration for the Voting App DevOps capstone. + +## Infrastructure + +The current configuration declares an Amazon S3 bucket for project artifacts. + +The AWS region, project name, and environment are configurable through variables. + +## Prerequisites + +- Terraform 1.5 or later +- AWS CLI +- AWS credentials with the permissions required for planned operations + +## Initialize Terraform + +Run from this directory: + + terraform init + +## Format and validate + + terraform fmt + terraform validate + +## Preview infrastructure changes + + terraform plan + +Review the plan before applying any changes. + +**Important:** `terraform plan` previews changes. `terraform apply` can create or modify AWS resources and may incur charges. Do not run it without reviewing the plan and obtaining approval. + +## Outputs + +The configuration declares outputs for the AWS region, project name, and artifacts bucket name. + +The bucket name output becomes available after the bucket is created and Terraform has recorded its state. diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..470f1afeb1 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,13 @@ +provider "aws" { + region = var.aws_region +} + +resource "aws_s3_bucket" "voting_app_artifacts" { + bucket_prefix = "${var.project_name}-${var.environment}-artifacts-" + + tags = { + Name = "${var.project_name}-artifacts" + Environment = var.environment + ManagedBy = "Terraform" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..b83e6f3239 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "aws_region" { + description = "AWS region configured for the Voting App infrastructure" + value = var.aws_region +} + +output "project_name" { + description = "Name of the Voting App project" + value = var.project_name +} + +output "artifacts_bucket_name" { + description = "Name of the S3 artifacts bucket, once created" + value = aws_s3_bucket.voting_app_artifacts.bucket +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..f943b941f6 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,17 @@ +variable "aws_region" { + description = "AWS region for the Voting App infrastructure" + type = string + default = "eu-west-1" +} + +variable "project_name" { + description = "Name of the DevOps capstone project" + type = string + default = "voting-app" +} + +variable "environment" { + description = "Deployment environment" + type = string + default = "dev" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000000..4625c7b360 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,11 @@ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} From 8b9e3b8dc899ca62abe3f9ae853875f6f6aebc59 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Wed, 14 Oct 2026 16:19:45 +0100 Subject: [PATCH 09/16] ci(terraform): add Terraform validation workflow --- .github/workflows/terraform-validate.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/terraform-validate.yml diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml new file mode 100644 index 0000000000..249c3dc48b --- /dev/null +++ b/.github/workflows/terraform-validate.yml @@ -0,0 +1,44 @@ +name: Terraform Validation + +on: + push: + branches: + - main + - feature/terraform-setup + paths: + - 'terraform/**' + - '.github/workflows/terraform-validate.yml' + pull_request: + branches: + - main + paths: + - 'terraform/**' + - '.github/workflows/terraform-validate.yml' + +permissions: + contents: read + +jobs: + validate: + name: Format and Validate Terraform + runs-on: ubuntu-latest + + defaults: + run: + working-directory: terraform + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Check formatting + run: terraform fmt -check -recursive + + - name: Initialize Terraform without a backend + run: terraform init -backend=false -input=false + + - name: Validate configuration + run: terraform validate From 44793f10b06eedc5673aabb7441048895695110a Mon Sep 17 00:00:00 2001 From: Emah1 Date: Thu, 15 Oct 2026 14:51:44 +0100 Subject: [PATCH 10/16] Harden Docker build contexts --- result/.dockerignore | 9 +++++++++ vote/.dockerignore | 14 ++++++++++++++ worker/.dockerignore | 12 ++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 vote/.dockerignore create mode 100644 worker/.dockerignore diff --git a/result/.dockerignore b/result/.dockerignore index c2658d7d1b..5661b57fe1 100644 --- a/result/.dockerignore +++ b/result/.dockerignore @@ -1 +1,10 @@ +.git +.gitignore +.env +.env.* +!.env.example node_modules/ +npm-debug.log* +*.log +coverage/ +.nyc_output/ diff --git a/vote/.dockerignore b/vote/.dockerignore new file mode 100644 index 0000000000..f906d06d7f --- /dev/null +++ b/vote/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +*.log +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ diff --git a/worker/.dockerignore b/worker/.dockerignore new file mode 100644 index 0000000000..bb036b2428 --- /dev/null +++ b/worker/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.env +.env.* +!.env.example +bin/ +obj/ +*.user +*.suo +*.cache +*.log +TestResults/ From 245a0f927b5fac330d16fd6c2e3c3e977646fb44 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Thu, 15 Oct 2026 15:50:31 +0100 Subject: [PATCH 11/16] Harden Terraform S3 artifacts bucket --- terraform/main.tf | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/terraform/main.tf b/terraform/main.tf index 470f1afeb1..61abe9a1a5 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,3 +1,4 @@ + provider "aws" { region = var.aws_region } @@ -5,9 +6,38 @@ provider "aws" { resource "aws_s3_bucket" "voting_app_artifacts" { bucket_prefix = "${var.project_name}-${var.environment}-artifacts-" + force_destroy = false + tags = { Name = "${var.project_name}-artifacts" Environment = var.environment ManagedBy = "Terraform" } } + +resource "aws_s3_bucket_public_access_block" "voting_app_artifacts" { + bucket = aws_s3_bucket.voting_app_artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "voting_app_artifacts" { + bucket = aws_s3_bucket.voting_app_artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "voting_app_artifacts" { + bucket = aws_s3_bucket.voting_app_artifacts.id + + versioning_configuration { + status = "Enabled" + } +} From 206e04fea8b8386f4c3a1de8f380086468d5fbc7 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Thu, 15 Oct 2026 16:30:49 +0100 Subject: [PATCH 12/16] Tag public subnets for EKS --- terraform/main.tf | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/terraform/main.tf b/terraform/main.tf index 61abe9a1a5..7ee9bf9ad0 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -41,3 +41,41 @@ resource "aws_s3_bucket_versioning" "voting_app_artifacts" { status = "Enabled" } } + +# Identify existing public subnets for the Voting App EKS cluster. +resource "aws_ec2_tag" "eks_cluster_subnet_a" { + resource_id = "subnet-073da4d7e92dc34ad" + key = "kubernetes.io/cluster/${var.project_name}-${var.environment}" + value = "shared" +} + +resource "aws_ec2_tag" "eks_cluster_subnet_b" { + resource_id = "subnet-0961a5f5b1f0a3750" + key = "kubernetes.io/cluster/${var.project_name}-${var.environment}" + value = "shared" +} + +resource "aws_ec2_tag" "eks_cluster_subnet_c" { + resource_id = "subnet-0a8d7c8667f511e5d" + key = "kubernetes.io/cluster/${var.project_name}-${var.environment}" + value = "shared" +} + +# Mark the subnets as public for AWS load-balancer discovery. +resource "aws_ec2_tag" "public_subnet_elb_a" { + resource_id = "subnet-073da4d7e92dc34ad" + key = "kubernetes.io/role/elb" + value = "1" +} + +resource "aws_ec2_tag" "public_subnet_elb_b" { + resource_id = "subnet-0961a5f5b1f0a3750" + key = "kubernetes.io/role/elb" + value = "1" +} + +resource "aws_ec2_tag" "public_subnet_elb_c" { + resource_id = "subnet-0a8d7c8667f511e5d" + key = "kubernetes.io/role/elb" + value = "1" +} From 309400a026cf839a7d0ac2cb0e1b680799ccddb5 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Fri, 16 Oct 2026 08:54:44 +0100 Subject: [PATCH 13/16] feat: add Helm deployment and configure worker database credentials --- .gitignore | 1 + helm/voting-app/Chart.yaml | 6 ++ helm/voting-app/templates/db.yaml | 76 ++++++++++++++ helm/voting-app/templates/redis.yaml | 36 +++++++ helm/voting-app/templates/result.yaml | 42 ++++++++ helm/voting-app/templates/vote.yaml | 46 +++++++++ helm/voting-app/templates/worker.yaml | 25 +++++ helm/voting-app/values.yaml | 14 +++ terraform/main.tf | 137 ++++++++++++++++++++++++++ terraform/outputs.tf | 15 +++ terraform/variables.tf | 11 +++ worker/Program.cs | 6 +- 12 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 helm/voting-app/Chart.yaml create mode 100644 helm/voting-app/templates/db.yaml create mode 100644 helm/voting-app/templates/redis.yaml create mode 100644 helm/voting-app/templates/result.yaml create mode 100644 helm/voting-app/templates/vote.yaml create mode 100644 helm/voting-app/templates/worker.yaml create mode 100644 helm/voting-app/values.yaml diff --git a/.gitignore b/.gitignore index ce6d8ccd31..a05581124b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ node_modules/ *.tfstate.* crash.log crash.*.log +.local/ diff --git a/helm/voting-app/Chart.yaml b/helm/voting-app/Chart.yaml new file mode 100644 index 0000000000..5acf3e97fb --- /dev/null +++ b/helm/voting-app/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: voting-app +description: Local Kubernetes Helm chart for the Voting App +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/voting-app/templates/db.yaml b/helm/voting-app/templates/db.yaml new file mode 100644 index 0000000000..6d453117d5 --- /dev/null +++ b/helm/voting-app/templates/db.yaml @@ -0,0 +1,76 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-db +type: Opaque +stringData: + username: {{ .Values.postgres.username | quote }} + password: {{ .Values.postgres.password | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Release.Name }}-db-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.postgres.storage }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-db +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-db + template: + metadata: + labels: + app: {{ .Release.Name }}-db + spec: + containers: + - name: postgres + image: postgres:15-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-db + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-db + key: password + - name: POSTGRES_DB + value: {{ .Values.postgres.database | quote }} + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + initialDelaySeconds: 5 + periodSeconds: 5 + volumeMounts: + - name: db-data + mountPath: /var/lib/postgresql/data + volumes: + - name: db-data + persistentVolumeClaim: + claimName: {{ .Release.Name }}-db-data +--- +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + selector: + app: {{ .Release.Name }}-db + ports: + - name: postgres + port: 5432 + targetPort: 5432 diff --git a/helm/voting-app/templates/redis.yaml b/helm/voting-app/templates/redis.yaml new file mode 100644 index 0000000000..9b7a27ac3c --- /dev/null +++ b/helm/voting-app/templates/redis.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-redis +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-redis + template: + metadata: + labels: + app: {{ .Release.Name }}-redis + spec: + containers: + - name: redis + image: redis:alpine + ports: + - containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 3 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis +spec: + selector: + app: {{ .Release.Name }}-redis + ports: + - name: redis + port: 6379 + targetPort: 6379 diff --git a/helm/voting-app/templates/result.yaml b/helm/voting-app/templates/result.yaml new file mode 100644 index 0000000000..db3ae86d97 --- /dev/null +++ b/helm/voting-app/templates/result.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-result +spec: + replicas: {{ .Values.replicas.result }} + selector: + matchLabels: + app: {{ .Release.Name }}-result + template: + metadata: + labels: + app: {{ .Release.Name }}-result + spec: + containers: + - name: result + image: {{ .Values.images.result }} + imagePullPolicy: IfNotPresent + env: + - name: PORT + value: "80" + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: result +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-result + ports: + - name: http + port: 8081 + targetPort: 80 diff --git a/helm/voting-app/templates/vote.yaml b/helm/voting-app/templates/vote.yaml new file mode 100644 index 0000000000..84e90a6cc9 --- /dev/null +++ b/helm/voting-app/templates/vote.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-vote +spec: + replicas: {{ .Values.replicas.vote }} + selector: + matchLabels: + app: {{ .Release.Name }}-vote + template: + metadata: + labels: + app: {{ .Release.Name }}-vote + spec: + containers: + - name: vote + image: {{ .Values.images.vote }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + env: + - name: OPTION_A + value: "NDC / Peter Obi" + - name: OPTION_B + value: "ADC / Atiku" + - name: OPTION_C + value: "APC / Tinubu" + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: vote +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-vote + ports: + - name: http + port: 8080 + targetPort: 80 diff --git a/helm/voting-app/templates/worker.yaml b/helm/voting-app/templates/worker.yaml new file mode 100644 index 0000000000..80a495e663 --- /dev/null +++ b/helm/voting-app/templates/worker.yaml @@ -0,0 +1,25 @@ + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-worker +spec: + replicas: {{ .Values.replicas.worker }} + selector: + matchLabels: + app: {{ .Release.Name }}-worker + template: + metadata: + labels: + app: {{ .Release.Name }}-worker + spec: + containers: + - name: worker + image: {{ .Values.images.worker }} + imagePullPolicy: IfNotPresent + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-db + key: password diff --git a/helm/voting-app/values.yaml b/helm/voting-app/values.yaml new file mode 100644 index 0000000000..1baf60205c --- /dev/null +++ b/helm/voting-app/values.yaml @@ -0,0 +1,14 @@ +images: + vote: voting-app-vote:local + result: voting-app-result:local + worker: voting-app-worker:local + +postgres: + username: postgres + database: postgres + storage: 1Gi + +replicas: + vote: 1 + result: 1 + worker: 1 diff --git a/terraform/main.tf b/terraform/main.tf index 7ee9bf9ad0..a840e782b4 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -79,3 +79,140 @@ resource "aws_ec2_tag" "public_subnet_elb_c" { key = "kubernetes.io/role/elb" value = "1" } + +# IAM role assumed by the EKS control plane. +resource "aws_iam_role" "eks_cluster" { + name = "${var.project_name}-${var.environment}-eks-cluster-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "eks.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) + + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } +} + +resource "aws_iam_role_policy_attachment" "eks_cluster_policy" { + role = aws_iam_role.eks_cluster.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +# IAM role assumed by EC2 worker nodes. +resource "aws_iam_role" "eks_node" { + name = "${var.project_name}-${var.environment}-eks-node-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) + + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } +} + +resource "aws_iam_role_policy_attachment" "eks_node_worker" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" +} + +resource "aws_iam_role_policy_attachment" "eks_node_cni" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" +} + +resource "aws_iam_role_policy_attachment" "eks_node_ecr" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +# EKS control plane. +resource "aws_eks_cluster" "voting_app" { + name = "${var.project_name}-${var.environment}" + role_arn = aws_iam_role.eks_cluster.arn + version = var.cluster_version + + access_config { + authentication_mode = "API_AND_CONFIG_MAP" + bootstrap_cluster_creator_admin_permissions = true + } + + vpc_config { + subnet_ids = [ + "subnet-073da4d7e92dc34ad", + "subnet-0961a5f5b1f0a3750", + "subnet-0a8d7c8667f511e5d" + ] + + endpoint_private_access = true + endpoint_public_access = true + public_access_cidrs = var.cluster_endpoint_public_access_cidrs + } + + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } + + depends_on = [ + aws_iam_role_policy_attachment.eks_cluster_policy + ] +} + +# Small managed worker-node group for learning. +resource "aws_eks_node_group" "voting_app" { + cluster_name = aws_eks_cluster.voting_app.name + node_group_name = "${var.project_name}-${var.environment}-workers" + node_role_arn = aws_iam_role.eks_node.arn + + subnet_ids = [ + "subnet-073da4d7e92dc34ad", + "subnet-0961a5f5b1f0a3750", + "subnet-0a8d7c8667f511e5d" + ] + + instance_types = ["t3.small"] + capacity_type = "ON_DEMAND" + disk_size = 20 + + scaling_config { + desired_size = 2 + min_size = 1 + max_size = 2 + } + + update_config { + max_unavailable = 1 + } + + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } + + depends_on = [ + aws_iam_role_policy_attachment.eks_node_worker, + aws_iam_role_policy_attachment.eks_node_cni, + aws_iam_role_policy_attachment.eks_node_ecr + ] +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index b83e6f3239..734156b148 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -12,3 +12,18 @@ output "artifacts_bucket_name" { description = "Name of the S3 artifacts bucket, once created" value = aws_s3_bucket.voting_app_artifacts.bucket } + +output "eks_cluster_name" { + description = "Name of the EKS cluster" + value = aws_eks_cluster.voting_app.name +} + +output "eks_cluster_endpoint" { + description = "Kubernetes API endpoint" + value = aws_eks_cluster.voting_app.endpoint +} + +output "eks_node_group_name" { + description = "Name of the managed EKS worker node group" + value = aws_eks_node_group.voting_app.node_group_name +} diff --git a/terraform/variables.tf b/terraform/variables.tf index f943b941f6..fd08ec266c 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -15,3 +15,14 @@ variable "environment" { type = string default = "dev" } + +variable "cluster_version" { + description = "Kubernetes version for the EKS cluster" + type = string + default = "1.36" +} + +variable "cluster_endpoint_public_access_cidrs" { + description = "Public IPv4 CIDRs allowed to access the Kubernetes API" + type = list(string) +} diff --git a/worker/Program.cs b/worker/Program.cs index 01267487bd..85d36deb9a 100644 --- a/worker/Program.cs +++ b/worker/Program.cs @@ -16,7 +16,8 @@ public static int Main(string[] args) { try { - var pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); + var pgsql = OpenDbConnection( + $"Server=db;Username=postgres;Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")};"); var redisConn = OpenRedisConnection("redis"); var redis = redisConn.GetDatabase(); @@ -46,7 +47,8 @@ public static int Main(string[] args) if (!pgsql.State.Equals(System.Data.ConnectionState.Open)) { Console.WriteLine("Reconnecting DB"); - pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); + pgsql = OpenDbConnection( + $"Server=db;Username=postgres;Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")};"); } else { // Normal +1 vote requested From 556310595be250ba50195f5c32d6254e9ca9daaa Mon Sep 17 00:00:00 2001 From: Emah1 Date: Fri, 16 Oct 2026 11:17:51 +0100 Subject: [PATCH 14/16] security: run application containers as non-root --- result/.dockerignore | 5 +++++ result/Dockerfile | 20 ++++++++++++++------ vote/.dockerignore | 5 +++++ vote/Dockerfile | 21 +++++++-------------- worker/.dockerignore | 5 +++++ worker/Dockerfile | 33 +++++++++++++++++++-------------- 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/result/.dockerignore b/result/.dockerignore index 5661b57fe1..725fe31813 100644 --- a/result/.dockerignore +++ b/result/.dockerignore @@ -8,3 +8,8 @@ npm-debug.log* *.log coverage/ .nyc_output/ +.vscode/ +.idea/ +.DS_Store +*.swp +*.tmp diff --git a/result/Dockerfile b/result/Dockerfile index 33f0ba86fb..b1897fa29f 100644 --- a/result/Dockerfile +++ b/result/Dockerfile @@ -1,25 +1,33 @@ + FROM node:18-slim -# add curl for healthcheck +# Install runtime utilities and create a dedicated non-root user RUN apt-get update && \ apt-get install -y --no-install-recommends curl tini && \ - rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/apt/lists/* && \ + groupadd --system app && \ + useradd --system --gid app --home-dir /home/app --create-home app WORKDIR /usr/local/app -# have nodemon available for local dev use (file watching) +# Install nodemon for the development workflow RUN npm install -g nodemon COPY package*.json ./ RUN npm ci && \ - npm cache clean --force && \ - mv /usr/local/app/node_modules /node_modules + npm cache clean --force && \ + mv /usr/local/app/node_modules /node_modules -COPY . . +# Ensure the application files belong to the non-root user +COPY --chown=app:app . . ENV PORT=80 EXPOSE 80 ENTRYPOINT ["/usr/bin/tini", "--"] + +# Run the application without root privileges +USER app + CMD ["node", "server.js"] diff --git a/vote/.dockerignore b/vote/.dockerignore index f906d06d7f..0361bc3d76 100644 --- a/vote/.dockerignore +++ b/vote/.dockerignore @@ -12,3 +12,8 @@ venv/ .mypy_cache/ .coverage htmlcov/ +.vscode/ +.idea/ +.DS_Store +*.swp +*.tmp diff --git a/vote/Dockerfile b/vote/Dockerfile index 2681083600..75f426847c 100644 --- a/vote/Dockerfile +++ b/vote/Dockerfile @@ -1,32 +1,25 @@ -# base defines a base stage that uses the official python runtime base image + FROM python:3.11-slim AS base -# Add curl for healthcheck RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ - rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/apt/lists/* && \ + groupadd --system app && \ + useradd --system --gid app --home-dir /home/app --create-home app -# Set the application directory WORKDIR /usr/local/app -# Install our requirements.txt COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -# dev defines a stage for development, where it'll watch for filesystem changes FROM base AS dev RUN pip install watchdog ENV FLASK_ENV=development +USER app CMD ["python", "app.py"] -# final defines the stage that will bundle the application for production FROM base AS final - -# Copy our code from the current folder to the working directory inside the container -COPY . . - -# Make port 80 available for links and/or publish +COPY --chown=app:app . . EXPOSE 80 - -# Define our command to be run when launching the container +USER app CMD ["gunicorn", "app:app", "-b", "0.0.0.0:80", "--log-file", "-", "--access-logfile", "-", "--workers", "4", "--keep-alive", "0"] diff --git a/worker/.dockerignore b/worker/.dockerignore index bb036b2428..8fd38ecbda 100644 --- a/worker/.dockerignore +++ b/worker/.dockerignore @@ -10,3 +10,8 @@ obj/ *.cache *.log TestResults/ +.vscode/ +.idea/ +.DS_Store +*.swp +*.tmp diff --git a/worker/Dockerfile b/worker/Dockerfile index a3f92d7e94..b34f4ef9f6 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,28 +1,33 @@ -# because of dotnet, we always build on amd64, and target platforms in cli -# dotnet doesn't support QEMU for building or running. -# (errors common in arm/v7 32bit) https://github.com/dotnet/dotnet-docker/issues/1537 -# https://hub.docker.com/_/microsoft-dotnet -# hadolint ignore=DL3029 -# to build for a different platform than your host, use --platform= -# for example, if you were on Intel (amd64) and wanted to build for ARM, you would use: -# docker buildx build --platform "linux/arm64/v8" . - -# build compiles the program for the builder's local platform + +# Build stage FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:7.0 AS build + ARG TARGETPLATFORM ARG TARGETARCH ARG BUILDPLATFORM -RUN echo "I am running on $BUILDPLATFORM, building for $TARGETPLATFORM" + +RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" WORKDIR /source + COPY *.csproj . RUN dotnet restore -a $TARGETARCH COPY . . -RUN dotnet publish -c release -o /app -a $TARGETARCH --self-contained false --no-restore +RUN dotnet publish -c Release -o /app -a $TARGETARCH \ + --self-contained false --no-restore -# app image +# Runtime stage FROM mcr.microsoft.com/dotnet/runtime:7.0 + WORKDIR /app -COPY --from=build /app . + +# Create a dedicated non-root account +RUN groupadd --system app && \ + useradd --system --gid app --home-dir /home/app --create-home app + +COPY --from=build --chown=app:app /app . + +USER app + ENTRYPOINT ["dotnet", "Worker.dll"] From 2ad0fd28e4bee9000b86c0b91b802fac04b032ca Mon Sep 17 00:00:00 2001 From: Emah1 Date: Fri, 16 Oct 2026 11:46:10 +0100 Subject: [PATCH 15/16] ci: run Docker validation on Terraform feature branch --- .github/workflows/call-docker-build-result.yaml | 2 ++ .github/workflows/call-docker-build-vote.yaml | 2 ++ .github/workflows/call-docker-build-worker.yaml | 2 ++ .github/workflows/validate-voting-app.yml | 1 + 4 files changed, 7 insertions(+) diff --git a/.github/workflows/call-docker-build-result.yaml b/.github/workflows/call-docker-build-result.yaml index 1efc59a43c..110d931fd1 100644 --- a/.github/workflows/call-docker-build-result.yaml +++ b/.github/workflows/call-docker-build-result.yaml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'result/**' @@ -13,6 +14,7 @@ on: pull_request: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'result/**' diff --git a/.github/workflows/call-docker-build-vote.yaml b/.github/workflows/call-docker-build-vote.yaml index 7109916966..42e6a45e6d 100644 --- a/.github/workflows/call-docker-build-vote.yaml +++ b/.github/workflows/call-docker-build-vote.yaml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'vote/**' @@ -13,6 +14,7 @@ on: pull_request: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'vote/**' diff --git a/.github/workflows/call-docker-build-worker.yaml b/.github/workflows/call-docker-build-worker.yaml index 45a2af9c5e..1634421705 100644 --- a/.github/workflows/call-docker-build-worker.yaml +++ b/.github/workflows/call-docker-build-worker.yaml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'worker/**' @@ -13,6 +14,7 @@ on: pull_request: branches: - 'main' + - 'feature/terraform-setup' # only build when important files change paths: - 'worker/**' diff --git a/.github/workflows/validate-voting-app.yml b/.github/workflows/validate-voting-app.yml index 5d740c869a..5d7ead22d4 100644 --- a/.github/workflows/validate-voting-app.yml +++ b/.github/workflows/validate-voting-app.yml @@ -6,6 +6,7 @@ on: branches: - main - feature/three-voting-options + - feature/terraform-setup pull_request: branches: - main From e6d37da5cb90493bbe28a1f986ae252b24cda443 Mon Sep 17 00:00:00 2001 From: Emah1 Date: Fri, 16 Oct 2026 12:03:13 +0100 Subject: [PATCH 16/16] ci: fix Docker image tags for feature branches --- .github/workflows/call-docker-build-result.yaml | 5 ++--- .github/workflows/call-docker-build-vote.yaml | 5 ++--- .github/workflows/call-docker-build-worker.yaml | 3 ++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/call-docker-build-result.yaml b/.github/workflows/call-docker-build-result.yaml index 110d931fd1..cbd7e404bf 100644 --- a/.github/workflows/call-docker-build-result.yaml +++ b/.github/workflows/call-docker-build-result.yaml @@ -59,10 +59,9 @@ jobs: ### https://github.com/docker/metadata-action#tags-input ### defaults to: tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=branch type=ref,event=pr + type=ref,event=tag ### path to where docker should copy files into image ### defaults to root of repository (.) diff --git a/.github/workflows/call-docker-build-vote.yaml b/.github/workflows/call-docker-build-vote.yaml index 42e6a45e6d..3342be4eef 100644 --- a/.github/workflows/call-docker-build-vote.yaml +++ b/.github/workflows/call-docker-build-vote.yaml @@ -59,10 +59,9 @@ jobs: ### https://github.com/docker/metadata-action#tags-input ### defaults to: tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=branch type=ref,event=pr + type=ref,event=tag ### path to where docker should copy files into image ### defaults to root of repository (.) diff --git a/.github/workflows/call-docker-build-worker.yaml b/.github/workflows/call-docker-build-worker.yaml index 1634421705..9856f9fad5 100644 --- a/.github/workflows/call-docker-build-worker.yaml +++ b/.github/workflows/call-docker-build-worker.yaml @@ -58,8 +58,9 @@ jobs: ### https://github.com/docker/metadata-action#tags-input ### defaults to: tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=branch type=ref,event=pr + type=ref,event=tag ### path to where docker should copy files into image ### defaults to root of repository (.)