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