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

- 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:
2026-04-20 13:27:31 +07:00
commit b8783f8ee6
15 changed files with 5196 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

17
Dockerfile Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: tictactoe
description: TicTacToe web game
type: application
version: 1.0.0
appVersion: "latest"

View 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

View 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 }}

View 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
View 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

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View 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
View 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
View 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
View 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
View 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');
});
});