diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml new file mode 100644 index 0000000..0e09a4e --- /dev/null +++ b/.github/workflows/build-check.yml @@ -0,0 +1,24 @@ +name: Build Check + +on: + pull_request: + branches: [ develop, main ] # Adjust if necessary + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 # Adjust Node.js version as needed + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index b3e5be1..64c6b11 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,7 +5,12 @@ - + + + + + + diff --git a/workspaces/backend/src/api/index.ts b/workspaces/backend/src/api/index.ts index 05cbee1..79dc6b8 100644 --- a/workspaces/backend/src/api/index.ts +++ b/workspaces/backend/src/api/index.ts @@ -1,7 +1,12 @@ import express from "express"; import {parseEnvVars} from "../lib/utils.js"; -import {KubeConfig, CoreV1Api, NetworkingV1Api} from '@kubernetes/client-node'; -import {createPodSpec, createServiceSpec, createIngressSpec} from '../lib/k8s/index.js'; +import {KubeConfig, CoreV1Api, NetworkingV1Api, AppsV1Api} from '@kubernetes/client-node'; +import { + createServiceSpec, + createIngressSpec, + createNamespaceSpec, + createDeploymentSpec +} from '../lib/k8s/index.js'; const router = express.Router(); @@ -12,96 +17,136 @@ const kc = new KubeConfig(); // (1) ローカル開発で~/.kube/configを使う場合 kc.loadFromDefault() +const k8sApps = kc.makeApiClient(AppsV1Api); const k8sCore = kc.makeApiClient(CoreV1Api); const k8sNetApi = kc.makeApiClient(NetworkingV1Api); // "/api" router.get("/", (req, res) => { - res.send("Hello from API"); + res.send("Hello from API"); }); router.post("/deploy", async (req, res) => { - const {repoUrl} = req.body; - const {envVars} = req.body; - const {host} = req.body; - - const parsedEnv = parseEnvVars(envVars || ''); - - try { - // Pod用マニフェストを作成 - const podManifest = createPodSpec(repoUrl, parsedEnv); - const serviceManifest = createServiceSpec(podManifest.metadata.name, 3000); - const ingressManifest = createIngressSpec(podManifest.metadata.name, host) - - const response = await k8sCore.createNamespacedPod({namespace: 'default', body: podManifest}); - const serviceResponse = await k8sCore.createNamespacedService({namespace: 'default', body: serviceManifest}); - const ingressResponse = await k8sNetApi.createNamespacedIngress({namespace: 'default', body: ingressManifest}) - const createdPodName = response.metadata?.name; - res.json({ - message: `Pod ${createdPodName} created successfully`, - podName: createdPodName, - }) - } catch (error) { - console.error('Error creating Pod:', error); - const errorMessage = (error as Error).message; - res.status(500).json({error: errorMessage}); - } + const {namespace} = req.body; + const {repoUrl} = req.body; + const {envVars} = req.body; + const {host} = req.body; + + const parsedEnv = parseEnvVars(envVars || ''); + + try { + const k8sNamespace = createNamespaceSpec(namespace, { + repositoryUrl: repoUrl.replace(/https?:\/\//, '').replace(/\//g, '_'), + deployEnvVars: envVars, + deployUser: "admin" + }) + await k8sCore.createNamespace({body: k8sNamespace}) + + // Pod用マニフェストを作成 + const deployManifest = createDeploymentSpec(repoUrl, parsedEnv, 1, namespace); + const serviceManifest = createServiceSpec(deployManifest.metadata.name, 3000, namespace); + const ingressManifest = createIngressSpec(deployManifest.metadata.name, host, namespace) + + + const deployment = await k8sApps.createNamespacedDeployment({namespace: namespace, body: deployManifest}); + const serviceResponse = await k8sCore.createNamespacedService({namespace: namespace, body: serviceManifest}); + const ingressResponse = await k8sNetApi.createNamespacedIngress({namespace: namespace, body: ingressManifest}) + const createdPodName = deployment.metadata?.name; + res.json({ + message: `Deployment ${createdPodName} created successfully`, + podName: createdPodName, + }) + } catch (error) { + console.error('Error creating Pod:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } }) - -router.get('/pods', async (req, res) => { - try { - const podsResponse = await k8sCore.listNamespacedPod({namespace: 'default'}); - const serviceResponse = await k8sCore.listNamespacedService({namespace: "default"}) - const ingressResponse = await k8sNetApi.listNamespacedIngress({namespace: "default"}) - - const pods = podsResponse.items; - - res.json({ - pods: pods.map(pod => ({ - name: pod.metadata?.name, - status: pod.status?.phase, - service: serviceResponse.items.find(service => service.metadata?.name === pod.metadata?.name), - ingress: ingressResponse.items.find(ingress => ingress.metadata?.name === pod.metadata?.name) - })) - }) - } catch (error) { - console.error('Error listing pods:', error); - const errorMessage = (error as Error).message; - res.status(500).json({error: errorMessage}); - } +router.get('/namespaces', async (req, res) => { + try { + const namespacesResponse = await k8sCore.listNamespace({labelSelector: 'openKokopiManaged=true'}); + res.json(namespacesResponse.items) + } catch (error) { + console.error('Error listing pods:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } }); -router.get('/pods/:name/logs', async (req, res) => { - const podName = req.params.name; - try { - // initContainerは完了後に終了するので、ログを見たいのはメインコンテナ "node-bot" の想定 - const logsResponse = await k8sCore.readNamespacedPodLog({ - namespace: 'default', - name: podName, - container: 'node-bot' - }); - res.json({log:logsResponse}); - } catch (error) { - console.error('Error fetching logs:', error); - const errorMessage = (error as Error).message; - res.status(500).json({error: errorMessage}); - } -}); +router.get("/namespace/:namespace/pods", async (req, res) => { + const namespace = req.params.namespace; + try { + const podsResponse = await k8sCore.listNamespacedPod({namespace: namespace}); + res.json(podsResponse.items); + } catch (error) { + console.error('Error listing pods:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) -router.get('/pods/:name/delete', async (req, res) => { - const podName = req.params.name; - try { - await k8sCore.deleteNamespacedPod({namespace: 'default', name: podName}); - await k8sCore.deleteNamespacedService({namespace: "default", name: podName}) - await k8sNetApi.deleteNamespacedIngress({namespace: "default", name: podName}) - res.json({message: `Pod ${podName} deleted successfully`}); - } catch (error) { - console.error('Error deleting pod:', error); - const errorMessage = (error as Error).message; - res.status(500).json({error: errorMessage}); - } -}); +router.get("/namespace/:namespace/pod/:podname" , async (req, res) => { + const namespace = req.params.namespace; + const podName = req.params.podname; + try { + const podResponse = await k8sCore.readNamespacedPod({namespace: namespace, name: podName}); + res.json(podResponse); + } catch (error) { + console.error('Error fetching pod:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) + +router.get("/namespace/:namespace/pod/:podname/log", async (req, res) => { + const namespace = req.params.namespace; + const podName = req.params.podname; + try { + const logsResponse = await k8sCore.readNamespacedPodLog({namespace: namespace, name: podName}); + res.json({log: logsResponse}); + } catch (error) { + console.error('Error fetching logs:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) + +router.get("/namespace/:namespace/services", async (req, res) => { + const namespace = req.params.namespace; + try { + const servicesResponse = await k8sCore.listNamespacedService({namespace: namespace}); + res.json(servicesResponse.items); + } catch (error) { + console.error('Error listing services:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) + +router.get("/namespace/:namespace/ingresses", async (req, res) => { + const namespace = req.params.namespace; + try { + const ingressesResponse = await k8sNetApi.listNamespacedIngress({namespace: namespace}); + res.json(ingressesResponse.items); + } catch (error) { + console.error('Error listing ingresses:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) + +router.post("/namespace/:namespace/delete", async (req, res) => { + const namespace = req.params.namespace; + try { + await k8sCore.deleteNamespace({name: namespace}); + res.json({message: `Namespace ${namespace} deleted successfully`}); + } catch (error) { + console.error('Error deleting namespace:', error); + const errorMessage = (error as Error).message; + res.status(500).json({error: errorMessage}); + } +}) export default router; diff --git a/workspaces/backend/src/lib/k8s/index.ts b/workspaces/backend/src/lib/k8s/index.ts index e362b7b..6730559 100644 --- a/workspaces/backend/src/lib/k8s/index.ts +++ b/workspaces/backend/src/lib/k8s/index.ts @@ -1,126 +1,233 @@ -export const createPodSpec = (repoUrl: string, envVars = {}) => { - const repositoryName = repoUrl.split('/').pop()?.split('.').shift()?.toLowerCase() - const podName = `${repositoryName}-${Date.now()}`; +// export const createPodSpec = (repoUrl: string, envVars = {}) => { +// const repositoryName = repoUrl.split('/').pop()?.split('.').shift()?.toLowerCase() +// const podName = `${repositoryName}-${Date.now()}`; +// +// // 受け取った envVars オブジェクトを k8s 用の配列形式に変換 +// const envArray = Object.entries(envVars).map(([key, value]) => ({ +// name: key, +// value: String(value) +// })); +// +// return { +// apiVersion: 'v1', +// kind: 'Pod', +// metadata: { +// name: podName, +// labels: { +// app: podName +// } +// }, +// spec: { +// restartPolicy: 'Always', +// volumes: [ +// { +// name: 'app-volume', +// emptyDir: {} +// } +// ], +// initContainers: [ +// { +// name: 'git-clone', +// image: 'alpine/git:latest', +// command: ['sh', '-c'], +// args: [ +// `[ -d "/app/.git" ] && (echo "Repository already exists. Pulling latest changes..." && git -C /app pull) || (echo "Repository not found. Cloning..." && git clone ${repoUrl} /app)` +// ], +// volumeMounts: [ +// { +// name: 'app-volume', +// mountPath: '/app' +// } +// ] +// } +// ], +// containers: [ +// { +// name: 'node-bot', +// image: 'node:18', +// workingDir: '/app', +// command: ['bash', '-c'], +// args: [ +// 'npm install && npm run build && npm run start' +// ], +// volumeMounts: [ +// { +// name: 'app-volume', +// mountPath: '/app' +// } +// ], +// env: envArray, +// ports: [ +// {containerPort: 3000} +// ] +// } +// ] +// } +// }; +// } - // 受け取った envVars オブジェクトを k8s 用の配列形式に変換 - const envArray = Object.entries(envVars).map(([key, value]) => ({ - name: key, - value: String(value) - })); +export const createServiceSpec = (podName: string, port: number, namespace: string) => { + return { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: podName, + namespace: namespace, + labels: { + app: podName + } + }, + spec: { + type: 'ClusterIP', + selector: { + app: podName + }, + ports: [ + {port: 80, targetPort: port} + ] + } + }; +} - return { - apiVersion: 'v1', - kind: 'Pod', - metadata: { - name: podName, - labels: { - app: podName - } - }, - spec: { - restartPolicy: 'Always', - volumes: [ - { - name: 'app-volume', - emptyDir: {} - } - ], - initContainers: [ - { - name: 'git-clone', - image: 'alpine/git:latest', - command: ['sh', '-c'], - args: [ - `[ -d "/app/.git" ] && (echo "Repository already exists. Pulling latest changes..." && git -C /app pull) || (echo "Repository not found. Cloning..." && git clone ${repoUrl} /app)` - ], - volumeMounts: [ - { - name: 'app-volume', - mountPath: '/app' - } - ] - } - ], - containers: [ - { - name: 'node-bot', - image: 'node:18', - workingDir: '/app', - command: ['bash', '-c'], - args: [ - 'npm install && npm run build && npm run start' - ], - volumeMounts: [ - { - name: 'app-volume', - mountPath: '/app' - } - ], - env: envArray, - ports: [ - {containerPort: 3000} - ] +export const createIngressSpec = (serviceName: string, host: string, namespace: string) => { + + return { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: serviceName, + namespace: namespace, + annotations: { + "kubernetes.io/ingress.class": "nginx" + } + }, + spec: { + rules: [ + { + host: host, + "http": { + "paths": [ + { + "path": "/", + "pathType": "Prefix", + "backend": { + "service": { + "name": serviceName, + "port": { + "number": 80 + } + } } + } ] + } } - }; + ] + } + }; } -export const createServiceSpec = (podName: string, port: number) => { - return { - apiVersion: 'v1', - kind: 'Service', - metadata: { - name: podName, - labels: { - app: podName - } - }, - spec: { - type: 'ClusterIP', - selector: { - app: podName - }, - ports: [ - {port: 80, targetPort: port} - ] - } - }; -} +export const createDeploymentSpec = (repoUrl: string, envVars = {}, replicas: number = 1, namespace: string) => { + const repositoryName = repoUrl.split('/').pop()?.split('.').shift()?.toLowerCase(); + const deploymentName = `${repositoryName}-${Date.now()}`; -export const createIngressSpec = (serviceName: string, host: string) => { + // Convert envVars object to Kubernetes env array format + const envArray = Object.entries(envVars).map(([key, value]) => ({ + name: key, + value: String(value) + })); - return { - apiVersion: 'networking.k8s.io/v1', - kind: 'Ingress', + return { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: deploymentName, + namespace: namespace, + labels: { + app: deploymentName + } + }, + spec: { + replicas, + selector: { + matchLabels: { + app: deploymentName + } + }, + template: { metadata: { - name: serviceName, - annotations: { - "kubernetes.io/ingress.class": "nginx" - } + labels: { + app: deploymentName + } }, spec: { - rules: [ + volumes: [ + { + name: 'app-volume', + emptyDir: {} + } + ], + initContainers: [ + { + name: 'git-clone', + image: 'alpine/git:latest', + command: ['sh', '-c'], + args: [ + `[ -d "/app/.git" ] && (echo "Repository already exists. Pulling latest changes..." && git -C /app pull) || (echo "Repository not found. Cloning..." && git clone ${repoUrl} /app)` + ], + volumeMounts: [ { - host: host, // unko.unchi.app - "http": { - "paths": [ - { - "path": "/", - "pathType": "Prefix", - "backend": { - "service": { - "name": serviceName, - "port": { - "number": 80 - } - } - } - } - ] - } + name: 'app-volume', + mountPath: '/app' } - ] + ] + } + ], + containers: [ + { + name: 'node-bot', + image: 'node:18', + workingDir: '/app', + command: ['bash', '-c'], + args: [ + 'npm install && npm run build && npm run start' + ], + volumeMounts: [ + { + name: 'app-volume', + mountPath: '/app' + } + ], + env: envArray, + ports: [ + {containerPort: 3000} + ] + } + ] } - }; + } + } + }; +}; + + +type NamespaceMetadata = { + repositoryUrl: string; + deployEnvVars: string; + deployUser: string; } + +export const createNamespaceSpec = (namespace: string, metadata: NamespaceMetadata) => { + return { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: namespace, + labels: { + name: namespace, + openKokopiManaged: 'true', + ...metadata + } + } + }; +}; diff --git a/workspaces/panel/Components/namespaceCard.tsx b/workspaces/panel/Components/namespaceCard.tsx new file mode 100644 index 0000000..3944910 --- /dev/null +++ b/workspaces/panel/Components/namespaceCard.tsx @@ -0,0 +1,68 @@ +import {Button, Card, Tag, Typography} from "antd"; +import React, {useEffect,} from "react"; +import {Ingress, Pod} from "@/lib/type"; + +type NamespaceCardProps = { + namespace: string; +} + + +const NamespaceCard = ({ namespace } : NamespaceCardProps) => { + + // podとingressをとってくる + const [pods, setPods] = React.useState([]); + const [ingresses, setIngresses] = React.useState([]); + + useEffect(() => { + fetch(`/api/namespace/${namespace}/pods`) + .then(res => res.json()) + .then(data => { + console.log(data) + setPods(data); + }) + + fetch(`/api/namespace/${namespace}/ingresses`) + .then(res => res.json()) + .then(data => { + setIngresses(data); + }) + }, [namespace]); + + + if(pods.length === 0) { + return <> + } + + return ( + <> + + +
+
+ {namespace} +
+ { + ingresses.map((ingress) => ( + + {ingress.spec.rules[0].host} + + )) + } +
+ {pods[0].status.phase} +
+ + + +
+
+ + ) +} + +export default NamespaceCard; diff --git a/workspaces/panel/lib/type.ts b/workspaces/panel/lib/type.ts new file mode 100644 index 0000000..40a24d5 --- /dev/null +++ b/workspaces/panel/lib/type.ts @@ -0,0 +1,25 @@ +export type Namespace = { + metadata: { + name: string; + } +} + +export type Pod = { + status: { + phase: string; + }, + metadata: { + name: string; + } +} + +export type Ingress = { + metadata: { + name: string; + }, + spec: { + rules: { + host: string; + }[] + } +} diff --git a/workspaces/panel/pages/app/[namespace].tsx b/workspaces/panel/pages/app/[namespace].tsx new file mode 100644 index 0000000..3fcdd93 --- /dev/null +++ b/workspaces/panel/pages/app/[namespace].tsx @@ -0,0 +1,150 @@ +import {useRouter} from "next/router"; +import {Breadcrumb, Button, Layout, Modal, Typography} from "antd"; +import React, {useEffect, useState} from "react"; +import {Ingress, Pod} from "@/lib/type"; +import {HomeOutlined} from '@ant-design/icons'; + +const {Header} = Layout; + +const NamespacePage = () => { + const {query} = useRouter(); + const {namespace} = query; + + const [pods, setPods] = useState([]); + const [ingresses, setIngresses] = useState([]); + const [services, setServices] = useState([]); + + const [logText, setLogText] = useState(""); + const [isLogModalOpen, setIsLogModalOpen] = useState(false); + + useEffect(() => { + fetch(`/api/namespace/${namespace}/pods`) + .then(res => res.json()) + .then(data => { + setPods(data); + }) + + fetch(`/api/namespace/${namespace}/ingresses`) + .then(res => res.json()) + .then(data => { + setIngresses(data); + }) + + fetch(`/api/namespace/${namespace}/services`) + .then(res => res.json()) + .then(data => { + setServices(data); + }) + }, [namespace]); + + + return ( +
+ { + setIsLogModalOpen(false) + }} + footer={(<>)} + > +
{logText}
+
+ +
+
+ OpenKokopi Panel +
+
+ +
+ + , + }, + { + title: ( + <> + Apps + + ), + }, + { + title: namespace, + }, + ]} + /> + + Apps + +
+ {namespace} + + Pods +
+ {pods.map((pod) => ( +
+ {pod.metadata.name} + + + +
+ ))} +
+ + Ingresses +
+ {ingresses.map((ingress) => ( +
+ {ingress.spec.rules[0].host} +
+ ))} +
+ + Services +
+ {services.map((service) => ( +
+ {service.metadata.name} +
+ ))} +
+
+
+
+
+
+ ) +} + +export default NamespacePage; diff --git a/workspaces/panel/pages/index.tsx b/workspaces/panel/pages/index.tsx index 9aa79dc..6b812c8 100644 --- a/workspaces/panel/pages/index.tsx +++ b/workspaces/panel/pages/index.tsx @@ -1,26 +1,21 @@ import React, {useEffect, useState} from 'react'; -import {Button, Card, Flex, Input, Layout, Modal, Tag, Typography} from 'antd'; +import {Breadcrumb, Button, Flex, Input, Layout, Modal, Typography} from 'antd'; +import NamespaceCard from "@/Components/namespaceCard"; +import {Namespace} from "@/lib/type"; +import {HomeOutlined} from "@ant-design/icons"; const { TextArea } = Input; const {Header} = Layout; -const API_LIST_URL = "/api/pods" +const API_LIST_URL = "/api/namespaces" -type Pod = { - name: string; - status: string; - service: string; - ingress: unknown; -} const Index = () => { - const [pods, setPods] = useState([]); - - const [isLogModalOpen, setIsLogModalOpen] = useState(false); - const [logText, setLogText] = useState(""); + const [namespaces, setNameSpaces] = useState([]); const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); + const [namespace, setNamespace] = useState(""); const [deployRepoUrl, setDeployRepoUrl] = useState(""); const [deployEnvVars, setDeployEnvVars] = useState(""); const [deployHost, setDeployHost] = useState(""); @@ -29,24 +24,18 @@ const Index = () => { fetch(API_LIST_URL) .then(res => res.json()) .then(data => { - setPods(data.pods); + setNameSpaces(data); }) }, []); return ( <> - { - setIsLogModalOpen(false) - }} - footer={(<>)} - > -
{logText}
-
{ setIsDeployModalOpen(false) }} footer={(<>)}> + setNamespace(e.target.value)}/> setDeployRepoUrl(e.target.value)}/>