feat: initial tictactoe web app with CI/CD pipeline
Some checks failed
homelab-k8s-services/tictactoe/pipeline/head There was a failure building this commit
Some checks failed
homelab-k8s-services/tictactoe/pipeline/head There was a failure building this commit
- Express app serving vanilla JS 2-player TicTacToe game - Dockerfile (multi-stage node:18-slim) - Jenkinsfile (K8s pod: test → Harbor push → Helm bump → Gitea push) - Helm chart v1.0.0 with HTTPRoute for tictactoe.fireflylab.local Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:18-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/index.js ./
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "index.js"]
|
||||||
126
Jenkinsfile
vendored
Normal file
126
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
kubernetes {
|
||||||
|
yaml """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: node
|
||||||
|
image: node:18-slim
|
||||||
|
command:
|
||||||
|
- sleep
|
||||||
|
args:
|
||||||
|
- infinity
|
||||||
|
- name: docker
|
||||||
|
image: docker:dind
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
env:
|
||||||
|
- name: DOCKER_TLS_CERTDIR
|
||||||
|
value: ""
|
||||||
|
- name: tools
|
||||||
|
image: alpine/git
|
||||||
|
command:
|
||||||
|
- sleep
|
||||||
|
args:
|
||||||
|
- infinity
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
APP_NAME = 'tictactoe'
|
||||||
|
HARBOR_REGISTRY = 'harbor.fireflylab.local'
|
||||||
|
HARBOR_PROJECT = 'library'
|
||||||
|
IMAGE = "${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${APP_NAME}"
|
||||||
|
DOCKER_HOST = 'tcp://localhost:2375'
|
||||||
|
CHART_FILE = 'manifest/helm/Chart.yaml'
|
||||||
|
VALUES_FILE = 'manifest/helm/values.yaml'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Install & Test') {
|
||||||
|
steps {
|
||||||
|
container('node') {
|
||||||
|
sh 'npm install'
|
||||||
|
sh 'npm test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Image') {
|
||||||
|
steps {
|
||||||
|
container('docker') {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'harbor-credentials',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASS'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
docker login ${HARBOR_REGISTRY} -u \${HARBOR_USER} -p \${HARBOR_PASS}
|
||||||
|
docker build -t ${IMAGE}:${BUILD_NUMBER} .
|
||||||
|
docker push ${IMAGE}:${BUILD_NUMBER}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Bump Helm Chart') {
|
||||||
|
steps {
|
||||||
|
container('tools') {
|
||||||
|
script {
|
||||||
|
def content = readFile(CHART_FILE)
|
||||||
|
def matcher = content =~ /version:\s+(\d+)\.(\d+)\.(\d+)/
|
||||||
|
def newVersion = "${matcher[0][1]}.${matcher[0][2]}.${matcher[0][3].toInteger() + 1}"
|
||||||
|
sh "sed -i 's/^version: .*/version: ${newVersion}/' ${CHART_FILE}"
|
||||||
|
sh "sed -i 's/^appVersion: .*/appVersion: \"${BUILD_NUMBER}\"/' ${CHART_FILE}"
|
||||||
|
sh "sed -i 's/^ tag: .*/ tag: ${BUILD_NUMBER}/' ${VALUES_FILE}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Commit & Push') {
|
||||||
|
steps {
|
||||||
|
container('tools') {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'gitea-credentials',
|
||||||
|
usernameVariable: 'GIT_USER',
|
||||||
|
passwordVariable: 'GIT_PASS'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
git config user.email "jenkins@fireflylab.local"
|
||||||
|
git config user.name "Jenkins"
|
||||||
|
git add ${CHART_FILE} ${VALUES_FILE}
|
||||||
|
git commit -m "ci: bump tictactoe chart to build ${BUILD_NUMBER}"
|
||||||
|
REMOTE_URL=\$(git remote get-url origin)
|
||||||
|
AUTH_URL=\$(echo \$REMOTE_URL | sed "s|https://|https://\${GIT_USER}:\${GIT_PASS}@|")
|
||||||
|
BRANCH=\$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
git push \$AUTH_URL HEAD:\$BRANCH
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
cleanWs()
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "Build ${BUILD_NUMBER} deployed. Image: ${IMAGE}:${BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "Build ${BUILD_NUMBER} failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
index.js
Normal file
18
index.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).send('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`TicTacToe running on port ${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
6
manifest/helm/Chart.yaml
Normal file
6
manifest/helm/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: tictactoe
|
||||||
|
description: TicTacToe web game
|
||||||
|
type: application
|
||||||
|
version: 1.0.0
|
||||||
|
appVersion: "latest"
|
||||||
35
manifest/helm/templates/deployment.yaml
Normal file
35
manifest/helm/templates/deployment.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: {{ .Values.service.targetPort }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: {{ .Values.service.targetPort }}
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: {{ .Values.service.targetPort }}
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
19
manifest/helm/templates/httproute.yaml
Normal file
19
manifest/helm/templates/httproute.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: HTTPRoute
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- name: envoy-gateway
|
||||||
|
namespace: envoy-gateway-system
|
||||||
|
hostnames:
|
||||||
|
- "tictactoe.fireflylab.local"
|
||||||
|
rules:
|
||||||
|
- matches:
|
||||||
|
- path:
|
||||||
|
type: PathPrefix
|
||||||
|
value: /
|
||||||
|
backendRefs:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
14
manifest/helm/templates/service.yaml
Normal file
14
manifest/helm/templates/service.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ .Chart.Name }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: {{ .Chart.Name }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: {{ .Values.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
10
manifest/helm/values.yaml
Normal file
10
manifest/helm/values.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
image:
|
||||||
|
repository: harbor.fireflylab.local/library/tictactoe
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
service:
|
||||||
|
port: 80
|
||||||
|
targetPort: 3000
|
||||||
4737
package-lock.json
generated
Normal file
4737
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "tictactoe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "TicTacToe web game",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
public/game.js
Normal file
61
public/game.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
(function () {
|
||||||
|
const WINS = [
|
||||||
|
[0,1,2],[3,4,5],[6,7,8],
|
||||||
|
[0,3,6],[1,4,7],[2,5,8],
|
||||||
|
[0,4,8],[2,4,6]
|
||||||
|
];
|
||||||
|
|
||||||
|
let board, current, over;
|
||||||
|
const cells = document.querySelectorAll('.cell');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const resetBtn = document.getElementById('reset');
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
board = Array(9).fill(null);
|
||||||
|
current = 'X';
|
||||||
|
over = false;
|
||||||
|
cells.forEach(c => {
|
||||||
|
c.textContent = '';
|
||||||
|
c.className = 'cell';
|
||||||
|
});
|
||||||
|
status.textContent = "Player X's turn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWinner() {
|
||||||
|
for (const [a, b, c] of WINS) {
|
||||||
|
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
|
||||||
|
return { winner: board[a], line: [a, b, c] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return board.every(Boolean) ? { winner: null, draw: true } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
const idx = parseInt(e.target.dataset.index);
|
||||||
|
if (over || board[idx]) return;
|
||||||
|
|
||||||
|
board[idx] = current;
|
||||||
|
const cell = cells[idx];
|
||||||
|
cell.textContent = current;
|
||||||
|
cell.classList.add('taken', current.toLowerCase());
|
||||||
|
|
||||||
|
const result = checkWinner();
|
||||||
|
if (result) {
|
||||||
|
over = true;
|
||||||
|
if (result.draw) {
|
||||||
|
status.textContent = "It's a draw!";
|
||||||
|
} else {
|
||||||
|
result.line.forEach(i => cells[i].classList.add('winner'));
|
||||||
|
status.textContent = `Player ${result.winner} wins!`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current === 'X' ? 'O' : 'X';
|
||||||
|
status.textContent = `Player ${current}'s turn`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.forEach(c => c.addEventListener('click', handleClick));
|
||||||
|
resetBtn.addEventListener('click', init);
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
28
public/index.html
Normal file
28
public/index.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TicTacToe</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Tic Tac Toe</h1>
|
||||||
|
<div id="status" class="status">Player X's turn</div>
|
||||||
|
<div id="board" class="board">
|
||||||
|
<div class="cell" data-index="0"></div>
|
||||||
|
<div class="cell" data-index="1"></div>
|
||||||
|
<div class="cell" data-index="2"></div>
|
||||||
|
<div class="cell" data-index="3"></div>
|
||||||
|
<div class="cell" data-index="4"></div>
|
||||||
|
<div class="cell" data-index="5"></div>
|
||||||
|
<div class="cell" data-index="6"></div>
|
||||||
|
<div class="cell" data-index="7"></div>
|
||||||
|
<div class="cell" data-index="8"></div>
|
||||||
|
</div>
|
||||||
|
<button id="reset" class="reset-btn">New Game</button>
|
||||||
|
</div>
|
||||||
|
<script src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
91
public/style.css
Normal file
91
public/style.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
min-height: 1.5em;
|
||||||
|
color: #a8dadc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 120px);
|
||||||
|
grid-template-rows: repeat(3, 120px);
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
background: #16213e;
|
||||||
|
border: 2px solid #0f3460;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover:not(.taken) {
|
||||||
|
background: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.taken {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.x {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.o {
|
||||||
|
color: #a8dadc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.winner {
|
||||||
|
background: #0f3460;
|
||||||
|
border-color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #e94560;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover {
|
||||||
|
background: #c73652;
|
||||||
|
}
|
||||||
16
test/app.test.js
Normal file
16
test/app.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../index');
|
||||||
|
|
||||||
|
describe('TicTacToe App', () => {
|
||||||
|
it('GET / returns HTML', async () => {
|
||||||
|
const res = await request(app).get('/');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toMatch(/html/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health returns OK', async () => {
|
||||||
|
const res = await request(app).get('/health');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.text).toBe('OK');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user