init
This commit is contained in:
0
.env/cluster.list
Normal file
0
.env/cluster.list
Normal file
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env/*.yaml
|
||||
.env/*.yml
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for cloning repositories
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o manager ./cmd/manager
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install git and docker client
|
||||
RUN apk add --no-cache git docker-cli
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/manager .
|
||||
|
||||
# Copy manifests
|
||||
COPY --from=builder /app/manifests ./manifests
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./manager"]
|
||||
252
README.md
Normal file
252
README.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Deployment Manager
|
||||
|
||||
A Kubernetes deployment manager that reads repository URLs from a database and automatically deploys them to Kubernetes clusters.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated Deployment**: Clone Git repositories, build Docker images, and deploy to Kubernetes
|
||||
- **Multi-Language Support**: Supports Node.js and Python applications
|
||||
- **Real-time Events**: Server-Sent Events (SSE) for real-time deployment status updates
|
||||
- **REST API**: Full REST API for managing repositories and deployments
|
||||
- **Worker Pool**: Concurrent deployment processing with configurable worker pool size
|
||||
- **Reconciliation Loop**: Automatic retry and status reconciliation
|
||||
- **Health Checks**: Built-in health check endpoints
|
||||
|
||||
## Architecture
|
||||
|
||||
The deployment manager consists of several components:
|
||||
|
||||
- **API Server**: HTTP server with REST endpoints and SSE support
|
||||
- **Worker Pool**: Concurrent workers that process deployment jobs
|
||||
- **Reconciler**: Background process that ensures desired state matches actual state
|
||||
- **Event Bus**: Pub/sub system for real-time event streaming
|
||||
- **Database**: SQLite database for repository metadata
|
||||
- **Executor**: Command execution and Docker operations
|
||||
- **Kubernetes Client**: Kubernetes resource management
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21+
|
||||
- Docker
|
||||
- kubectl configured with cluster access
|
||||
- Git
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build the binary
|
||||
go build ./cmd/manager
|
||||
|
||||
# Or build with Docker
|
||||
docker build -t deployment-manager .
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Run locally
|
||||
./manager
|
||||
|
||||
# Run with Docker
|
||||
docker run -p 8080:8080 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.kube:/root/.kube \
|
||||
deployment-manager
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `DB_PATH`: Path to SQLite database (default: `./manager.db`)
|
||||
- `MAX_WORKERS`: Number of concurrent workers (default: `2`)
|
||||
- `RECONCILE_TICK`: Reconciliation interval in seconds (default: `2`)
|
||||
- `HTTP_PORT`: HTTP server port (default: `8080`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Repositories
|
||||
|
||||
- `GET /api/repos` - List all repositories
|
||||
- `GET /api/repos?user_id=xxx` - List repositories by user
|
||||
- `POST /api/repos` - Create a new repository
|
||||
- `GET /api/repos/{id}` - Get repository details
|
||||
- `DELETE /api/repos/{id}` - Delete a repository
|
||||
- `POST /api/repos/{id}/stop` - Stop deployment
|
||||
- `POST /api/repos/{id}/restart` - Restart deployment
|
||||
|
||||
### Events
|
||||
|
||||
- `GET /events` - Server-Sent Events stream for real-time updates
|
||||
|
||||
### Health
|
||||
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
## API Usage Examples
|
||||
|
||||
### Create a Repository
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/repos \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"repo_url": "https://github.com/user/my-node-app.git",
|
||||
"user_id": "user123",
|
||||
"type": "nodejs"
|
||||
}'
|
||||
```
|
||||
|
||||
### List Repositories
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/repos
|
||||
```
|
||||
|
||||
### Stream Events
|
||||
|
||||
```bash
|
||||
curl -N http://localhost:8080/events
|
||||
```
|
||||
|
||||
## Repository Types
|
||||
|
||||
### Node.js Applications
|
||||
|
||||
Expected structure:
|
||||
- `package.json` with dependencies
|
||||
- Entry point defined in `package.json` (default: `index.js`)
|
||||
- Exposes port 3000
|
||||
|
||||
### Python Applications
|
||||
|
||||
Expected structure:
|
||||
- `requirements.txt` with dependencies
|
||||
- `app.py` as entry point
|
||||
- Exposes port 8000
|
||||
|
||||
## Deployment Process
|
||||
|
||||
1. **Repository Creation**: Add repository via API
|
||||
2. **Job Queuing**: Reconciler detects `need_to_deploy` status
|
||||
3. **Cloning**: Worker clones the Git repository
|
||||
4. **Dockerfile Generation**: Auto-generates appropriate Dockerfile
|
||||
5. **Image Build**: Builds and pushes Docker image
|
||||
6. **Kubernetes Deployment**: Applies Kubernetes manifests
|
||||
7. **Status Update**: Updates repository status to `deployed`
|
||||
|
||||
## Kubernetes Resources
|
||||
|
||||
The deployment manager creates the following Kubernetes resources:
|
||||
|
||||
- **Deployment**: Manages pod replicas and updates
|
||||
- **Service**: Exposes the application internally
|
||||
- **ConfigMap**: Stores repository metadata
|
||||
- **Ingress**: External access (if ingress controller is available)
|
||||
|
||||
## Event Types
|
||||
|
||||
- `repo_created`: New repository added
|
||||
- `repo_updated`: Repository status changed
|
||||
- `repo_deleted`: Repository deleted
|
||||
- `deploy_started`: Deployment process started
|
||||
- `deploy_success`: Deployment completed successfully
|
||||
- `deploy_error`: Deployment failed
|
||||
- `log`: Real-time log messages
|
||||
|
||||
## Configuration
|
||||
|
||||
### Database Schema
|
||||
|
||||
The SQLite database contains a `repos` table with the following schema:
|
||||
|
||||
```sql
|
||||
CREATE TABLE repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- nodejs | python
|
||||
image_tag TEXT,
|
||||
last_error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Status Transitions
|
||||
|
||||
| From | To | Action |
|
||||
| -------------- | ---------- | -------------------- |
|
||||
| need_to_deploy | deploying | enqueue job |
|
||||
| deploying | deployed | success |
|
||||
| deploying | err | failure |
|
||||
| deployed | stopped | scale 0 |
|
||||
| stopped | restarting | scale up |
|
||||
| any | deleted | delete k8s resources |
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
deployment-manager/
|
||||
├── cmd/manager/ # Application entry point
|
||||
├── internal/
|
||||
│ ├── api/ # HTTP server and handlers
|
||||
│ ├── db/ # Database operations
|
||||
│ ├── events/ # Event bus and types
|
||||
│ ├── executor/ # Command execution and Docker
|
||||
│ ├── k8s/ # Kubernetes client
|
||||
│ ├── model/ # Data models
|
||||
│ ├── reconciler/ # Reconciliation logic
|
||||
│ └── worker/ # Deployment workers
|
||||
├── migrations/ # Database migrations
|
||||
├── manifests/ # K8s manifest templates
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
# Build optimized binary
|
||||
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o manager ./cmd/manager
|
||||
|
||||
# Build Docker image
|
||||
docker build -t deployment-manager:latest .
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Docker socket not accessible**: Mount `/var/run/docker.sock` when running in Docker
|
||||
2. **kubectl not configured**: Ensure `~/.kube/config` is mounted and accessible
|
||||
3. **Database permissions**: Ensure the application can write to the database file
|
||||
4. **Port conflicts**: Change `HTTP_PORT` if 8080 is already in use
|
||||
|
||||
### Logs
|
||||
|
||||
Enable debug logging by setting the log level:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=debug ./manager
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Monitor the deployment manager health:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
93
cmd/manager/main.go
Normal file
93
cmd/manager/main.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"deployment-manager/internal/api"
|
||||
"deployment-manager/internal/db"
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/reconciler"
|
||||
"deployment-manager/internal/worker"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
dbPath = "./manager.db"
|
||||
maxWorkers = 2
|
||||
reconcileTick = 2 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// ---- graceful shutdown ----
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
log.Println("shutdown signal received")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// ---- DB ----
|
||||
database, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := db.Migrate(database); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// ---- channels ----
|
||||
jobQueue := make(chan int64, 100)
|
||||
|
||||
// ---- event bus ----
|
||||
eventBus := events.NewBus()
|
||||
|
||||
// Subscribe to events for workers
|
||||
eventChan := eventBus.Subscribe(ctx)
|
||||
|
||||
// ---- worker pool ----
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < maxWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
w := worker.NewWorker(id, database, eventChan, jobQueue)
|
||||
w.Run(ctx)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// ---- reconciler ----
|
||||
reconciler := reconciler.NewReconciler(database, jobQueue, reconcileTick)
|
||||
go reconciler.Run(ctx)
|
||||
|
||||
// ---- HTTP (API + SSE) ----
|
||||
httpServer := api.NewHTTPServer(database, eventBus)
|
||||
go func() {
|
||||
log.Println("HTTP server listening on :8080")
|
||||
if err := httpServer.Start(":8080"); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
|
||||
httpServer.Shutdown(context.Background())
|
||||
close(jobQueue)
|
||||
wg.Wait()
|
||||
log.Println("bye")
|
||||
}
|
||||
11
fn.md
Normal file
11
fn.md
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
## Status transitions
|
||||
|
||||
| From | To | Action |
|
||||
| -------------- | ---------- | -------------------- |
|
||||
| need_to_deploy | deploying | enqueue job |
|
||||
| deploying | deployed | success |
|
||||
| deploying | err | failure |
|
||||
| deployed | stopped | scale 0 |
|
||||
| stopped | restarting | scale up |
|
||||
| any | deleted | delete k8s resources |
|
||||
51
folder.structure
Normal file
51
folder.structure
Normal file
@@ -0,0 +1,51 @@
|
||||
manager/
|
||||
├── cmd/
|
||||
│ └── manager/
|
||||
│ └── main.go # entry point
|
||||
│
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── http.go # HTTP server setup
|
||||
│ │ ├── sse.go # SSE handlers
|
||||
│ │ └── health.go
|
||||
│ │
|
||||
│ ├── db/
|
||||
│ │ ├── sqlite.go # DB open / migrate
|
||||
│ │ └── repo_store.go # repo CRUD
|
||||
│ │
|
||||
│ ├── model/
|
||||
│ │ └── repo.go # Repo struct, enums
|
||||
│ │
|
||||
│ ├── reconciler/
|
||||
│ │ └── reconciler.go # desired → actual loop
|
||||
│ │
|
||||
│ ├── worker/
|
||||
│ │ ├── pool.go # worker pool
|
||||
│ │ └── deploy.go # deploy logic
|
||||
│ │
|
||||
│ ├── executor/
|
||||
│ │ ├── exec.go # runCmd, stream stdout
|
||||
│ │ └── docker.go # docker build/push helpers
|
||||
│ │
|
||||
│ ├── k8s/
|
||||
│ │ ├── kubectl.go # kubectl wrapper
|
||||
│ │ └── manifests.go # render templates
|
||||
│ │
|
||||
│ ├── events/
|
||||
│ │ ├── bus.go # pub/sub
|
||||
│ │ └── event.go # Event struct
|
||||
│ │
|
||||
│ └── config/
|
||||
│ └── config.go # paths, limits, env
|
||||
│
|
||||
├── migrations/
|
||||
│ └── 001_init.sql
|
||||
│
|
||||
├── manifests/
|
||||
│ ├── nodejs.yaml
|
||||
│ └── python.yaml
|
||||
│
|
||||
├── Dockerfile
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── README.md
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module deployment-manager
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.17
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
12
internal/api/health.go
Normal file
12
internal/api/health.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *HTTPServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
253
internal/api/http.go
Normal file
253
internal/api/http.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"deployment-manager/internal/db"
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
repoStore *db.RepoStore
|
||||
eventBus *events.Bus
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewHTTPServer(database *sql.DB, eventBus *events.Bus) *HTTPServer {
|
||||
return &HTTPServer{
|
||||
repoStore: db.NewRepoStore(database),
|
||||
eventBus: eventBus,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Start(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/repos", s.handleRepos)
|
||||
mux.HandleFunc("/api/repos/", s.handleRepo)
|
||||
mux.HandleFunc("/api/repos/", s.handleRepoActions)
|
||||
|
||||
// SSE endpoint
|
||||
sseHandler := NewSSEHandler(s.eventBus)
|
||||
mux.Handle("/events", sseHandler)
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
log.Printf("HTTP server starting on %s", addr)
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Shutdown(ctx context.Context) error {
|
||||
if s.server != nil {
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleRepos(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.getRepos(w, r)
|
||||
case http.MethodPost:
|
||||
s.createRepo(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleRepo(w http.ResponseWriter, r *http.Request) {
|
||||
repoID, err := extractRepoID(r.URL.Path)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid repo ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.getRepo(w, r, repoID)
|
||||
case http.MethodDelete:
|
||||
s.deleteRepo(w, r, repoID)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleRepoActions(w http.ResponseWriter, r *http.Request) {
|
||||
repoID, err := extractRepoID(r.URL.Path)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid repo ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
action := extractAction(r.URL.Path)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
switch action {
|
||||
case "stop":
|
||||
s.stopRepo(w, r, repoID)
|
||||
case "restart":
|
||||
s.restartRepo(w, r, repoID)
|
||||
default:
|
||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) getRepos(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
var repos []*model.Repo
|
||||
var err error
|
||||
|
||||
if userID != "" {
|
||||
repos, err = s.repoStore.ListByUser(userID)
|
||||
} else {
|
||||
repos, err = s.repoStore.ListByStatus(model.StatusDeployed)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(repos)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) getRepo(w http.ResponseWriter, r *http.Request, repoID int64) {
|
||||
repo, err := s.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(repo)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) createRepo(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RepoURL string `json:"repo_url"`
|
||||
UserID string `json:"user_id"`
|
||||
Type model.RepoType `json:"type"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
repo := &model.Repo{
|
||||
RepoURL: req.RepoURL,
|
||||
Status: model.StatusNeedToDeploy,
|
||||
UserID: req.UserID,
|
||||
Type: req.Type,
|
||||
}
|
||||
|
||||
if err := s.repoStore.Create(repo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish event
|
||||
s.eventBus.Publish(events.NewRepoEvent(repo, events.EventTypeRepoCreated))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(repo)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) deleteRepo(w http.ResponseWriter, r *http.Request, repoID int64) {
|
||||
repo, err := s.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update status to deleted for cleanup
|
||||
repo.Status = model.StatusDeleted
|
||||
if err := s.repoStore.Update(repo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish event
|
||||
s.eventBus.Publish(events.NewRepoEvent(repo, events.EventTypeRepoDeleted))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) stopRepo(w http.ResponseWriter, r *http.Request, repoID int64) {
|
||||
repo, err := s.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
repo.Status = model.StatusStopped
|
||||
if err := s.repoStore.Update(repo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish event
|
||||
s.eventBus.Publish(events.NewRepoEvent(repo, events.EventTypeRepoUpdated))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(repo)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) restartRepo(w http.ResponseWriter, r *http.Request, repoID int64) {
|
||||
repo, err := s.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
repo.Status = model.StatusRestarting
|
||||
if err := s.repoStore.Update(repo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish event
|
||||
s.eventBus.Publish(events.NewRepoEvent(repo, events.EventTypeRepoUpdated))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(repo)
|
||||
}
|
||||
|
||||
func extractRepoID(path string) (int64, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 4 {
|
||||
return 0, fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
return strconv.ParseInt(parts[3], 10, 64)
|
||||
}
|
||||
|
||||
func extractAction(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) >= 5 {
|
||||
return parts[4]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
78
internal/api/sse.go
Normal file
78
internal/api/sse.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"deployment-manager/internal/events"
|
||||
)
|
||||
|
||||
type SSEHandler struct {
|
||||
eventBus *events.Bus
|
||||
}
|
||||
|
||||
func NewSSEHandler(eventBus *events.Bus) *SSEHandler {
|
||||
return &SSEHandler{eventBus: eventBus}
|
||||
}
|
||||
|
||||
func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Set SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Create a context for this connection
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
// Subscribe to events
|
||||
eventChan := h.eventBus.Subscribe(ctx)
|
||||
|
||||
// Send initial connection event
|
||||
h.sendEvent(w, "connected", map[string]interface{}{
|
||||
"message": "SSE connection established",
|
||||
})
|
||||
|
||||
// Handle events
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("SSE client disconnected")
|
||||
return
|
||||
|
||||
case event := <-eventChan:
|
||||
if err := h.sendEvent(w, string(event.Type), event.Data); err != nil {
|
||||
log.Printf("Failed to send SSE event: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHandler) sendEvent(w http.ResponseWriter, eventType string, data interface{}) error {
|
||||
// Marshal data to JSON
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format as SSE event
|
||||
event := fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
|
||||
// Write to response
|
||||
_, err = w.Write([]byte(event))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Flush to ensure immediate delivery
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
204
internal/db/repo_store.go
Normal file
204
internal/db/repo_store.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type RepoStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepoStore(db *sql.DB) *RepoStore {
|
||||
return &RepoStore{db: db}
|
||||
}
|
||||
|
||||
func (rs *RepoStore) Create(repo *model.Repo) error {
|
||||
query := `
|
||||
INSERT INTO repos (repo_url, status, user_id, type, image_tag, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
result, err := rs.db.Exec(query, repo.RepoURL, repo.Status, repo.UserID, repo.Type, repo.ImageTag, repo.LastError, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repo: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||
}
|
||||
|
||||
repo.ID = id
|
||||
repo.CreatedAt = now
|
||||
repo.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RepoStore) Get(id int64) (*model.Repo, error) {
|
||||
query := `
|
||||
SELECT id, repo_url, status, user_id, type, image_tag, last_error, created_at, updated_at
|
||||
FROM repos
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
repo := &model.Repo{}
|
||||
var imageTag, lastError sql.NullString
|
||||
|
||||
err := rs.db.QueryRow(query, id).Scan(
|
||||
&repo.ID, &repo.RepoURL, &repo.Status, &repo.UserID, &repo.Type,
|
||||
&imageTag, &lastError, &repo.CreatedAt, &repo.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repo: %w", err)
|
||||
}
|
||||
|
||||
if imageTag.Valid {
|
||||
repo.ImageTag = &imageTag.String
|
||||
}
|
||||
if lastError.Valid {
|
||||
repo.LastError = &lastError.String
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (rs *RepoStore) Update(repo *model.Repo) error {
|
||||
query := `
|
||||
UPDATE repos
|
||||
SET repo_url = ?, status = ?, user_id = ?, type = ?, image_tag = ?, last_error = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
repo.UpdatedAt = time.Now()
|
||||
result, err := rs.db.Exec(query, repo.RepoURL, repo.Status, repo.UserID, repo.Type, repo.ImageTag, repo.LastError, repo.UpdatedAt, repo.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update repo: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("no rows affected, repo not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RepoStore) Delete(id int64) error {
|
||||
query := `DELETE FROM repos WHERE id = ?`
|
||||
|
||||
result, err := rs.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete repo: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("no rows affected, repo not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RepoStore) ListByStatus(status model.RepoStatus) ([]*model.Repo, error) {
|
||||
query := `
|
||||
SELECT id, repo_url, status, user_id, type, image_tag, last_error, created_at, updated_at
|
||||
FROM repos
|
||||
WHERE status = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := rs.db.Query(query, status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repos by status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var repos []*model.Repo
|
||||
for rows.Next() {
|
||||
repo := &model.Repo{}
|
||||
var imageTag, lastError sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&repo.ID, &repo.RepoURL, &repo.Status, &repo.UserID, &repo.Type,
|
||||
&imageTag, &lastError, &repo.CreatedAt, &repo.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan repo row: %w", err)
|
||||
}
|
||||
|
||||
if imageTag.Valid {
|
||||
repo.ImageTag = &imageTag.String
|
||||
}
|
||||
if lastError.Valid {
|
||||
repo.LastError = &lastError.String
|
||||
}
|
||||
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating repo rows: %w", err)
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (rs *RepoStore) ListByUser(userID string) ([]*model.Repo, error) {
|
||||
query := `
|
||||
SELECT id, repo_url, status, user_id, type, image_tag, last_error, created_at, updated_at
|
||||
FROM repos
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := rs.db.Query(query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repos by user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var repos []*model.Repo
|
||||
for rows.Next() {
|
||||
repo := &model.Repo{}
|
||||
var imageTag, lastError sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&repo.ID, &repo.RepoURL, &repo.Status, &repo.UserID, &repo.Type,
|
||||
&imageTag, &lastError, &repo.CreatedAt, &repo.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan repo row: %w", err)
|
||||
}
|
||||
|
||||
if imageTag.Valid {
|
||||
repo.ImageTag = &imageTag.String
|
||||
}
|
||||
if lastError.Valid {
|
||||
repo.LastError = &lastError.String
|
||||
}
|
||||
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating repo rows: %w", err)
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
45
internal/db/sqlite.go
Normal file
45
internal/db/sqlite.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func Migrate(db *sql.DB) error {
|
||||
migrationDir := "./migrations"
|
||||
|
||||
files, err := ioutil.ReadDir(migrationDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
|
||||
migrationPath := filepath.Join(migrationDir, file.Name())
|
||||
log.Printf("Running migration: %s", file.Name())
|
||||
|
||||
if err := runMigration(db, migrationPath); err != nil {
|
||||
return fmt.Errorf("failed to run migration %s: %w", file.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMigration(db *sql.DB, path string) error {
|
||||
content, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(string(content))
|
||||
return err
|
||||
}
|
||||
43
internal/events/bus.go
Normal file
43
internal/events/bus.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Bus struct {
|
||||
subscribers map[chan *Event]struct{}
|
||||
}
|
||||
|
||||
func NewBus() *Bus {
|
||||
return &Bus{
|
||||
subscribers: make(map[chan *Event]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) Subscribe(ctx context.Context) chan *Event {
|
||||
ch := make(chan *Event, 100)
|
||||
b.subscribers[ch] = struct{}{}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
b.unsubscribe(ch)
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *Bus) Publish(event *Event) {
|
||||
for ch := range b.subscribers {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
log.Printf("Event channel full, dropping event: %s", event.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) unsubscribe(ch chan *Event) {
|
||||
delete(b.subscribers, ch)
|
||||
}
|
||||
67
internal/events/event.go
Normal file
67
internal/events/event.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeRepoCreated EventType = "repo_created"
|
||||
EventTypeRepoUpdated EventType = "repo_updated"
|
||||
EventTypeRepoDeleted EventType = "repo_deleted"
|
||||
EventTypeDeployStarted EventType = "deploy_started"
|
||||
EventTypeDeploySuccess EventType = "deploy_success"
|
||||
EventTypeDeployError EventType = "deploy_error"
|
||||
EventTypeLog EventType = "log"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type EventType `json:"type"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func NewEvent(eventType EventType, repoID int64, data map[string]interface{}) *Event {
|
||||
return &Event{
|
||||
ID: generateEventID(),
|
||||
Type: eventType,
|
||||
RepoID: repoID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRepoEvent(repo *model.Repo, eventType EventType) *Event {
|
||||
return NewEvent(eventType, repo.ID, map[string]interface{}{
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
func NewLogEvent(repoID int64, message string) *Event {
|
||||
return NewEvent(EventTypeLog, repoID, map[string]interface{}{
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Event) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
func generateEventID() string {
|
||||
return time.Now().Format("20060102150405") + "-" + randomString(8)
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[i%len(letters)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
101
internal/executor/docker.go
Normal file
101
internal/executor/docker.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
func BuildAndPushImage(ctx context.Context, eventChan chan<- *events.Event, repo *model.Repo, workDir string) (string, error) {
|
||||
imageName := generateImageName(repo)
|
||||
|
||||
// Build command
|
||||
buildCmd := "docker"
|
||||
buildArgs := []string{
|
||||
"build",
|
||||
"-t", imageName,
|
||||
workDir,
|
||||
}
|
||||
|
||||
if err := RunCmd(ctx, eventChan, repo.ID, buildCmd, buildArgs...); err != nil {
|
||||
return "", fmt.Errorf("docker build failed: %w", err)
|
||||
}
|
||||
|
||||
// Push command
|
||||
pushCmd := "docker"
|
||||
pushArgs := []string{
|
||||
"push",
|
||||
imageName,
|
||||
}
|
||||
|
||||
if err := RunCmd(ctx, eventChan, repo.ID, pushCmd, pushArgs...); err != nil {
|
||||
return "", fmt.Errorf("docker push failed: %w", err)
|
||||
}
|
||||
|
||||
return imageName, nil
|
||||
}
|
||||
|
||||
func generateImageName(repo *model.Repo) string {
|
||||
// Extract repo name from URL for cleaner image names
|
||||
parts := strings.Split(strings.TrimSuffix(repo.RepoURL, ".git"), "/")
|
||||
repoName := parts[len(parts)-1]
|
||||
|
||||
return fmt.Sprintf("deployment-manager/%s-%d:latest", repoName, repo.ID)
|
||||
}
|
||||
|
||||
func GetDockerfileContent(repoType model.RepoType) string {
|
||||
switch repoType {
|
||||
case model.TypeNodeJS:
|
||||
return `FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]`
|
||||
|
||||
case model.TypePython:
|
||||
return `FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "app.py"]`
|
||||
|
||||
default:
|
||||
return `FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["./app"]`
|
||||
}
|
||||
}
|
||||
|
||||
func CreateDockerfile(workDir string, repoType model.RepoType) error {
|
||||
dockerfilePath := filepath.Join(workDir, "Dockerfile")
|
||||
content := GetDockerfileContent(repoType)
|
||||
|
||||
return writeFile(dockerfilePath, content)
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
34
internal/executor/exec.go
Normal file
34
internal/executor/exec.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os/exec"
|
||||
|
||||
"deployment-manager/internal/events"
|
||||
)
|
||||
|
||||
func RunCmd(ctx context.Context, eventChan chan<- *events.Event, repoID int64, cmd string, args ...string) error {
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
|
||||
stdout, _ := c.StdoutPipe()
|
||||
stderr, _ := c.StderrPipe()
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go stream(stdout, eventChan, repoID, "stdout")
|
||||
go stream(stderr, eventChan, repoID, "stderr")
|
||||
|
||||
return c.Wait()
|
||||
}
|
||||
|
||||
func stream(r interface{ Read([]byte) (int, error) }, eventChan chan<- *events.Event, repoID int64, streamType string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
event := events.NewLogEvent(repoID, scanner.Text())
|
||||
event.Data["stream"] = streamType
|
||||
eventChan <- event
|
||||
}
|
||||
}
|
||||
128
internal/k8s/kubectl.go
Normal file
128
internal/k8s/kubectl.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type KubectlClient struct {
|
||||
namespace string
|
||||
}
|
||||
|
||||
func NewKubectlClient(namespace string) *KubectlClient {
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
return &KubectlClient{namespace: namespace}
|
||||
}
|
||||
|
||||
func (k *KubectlClient) ApplyManifest(ctx context.Context, eventChan chan<- *events.Event, repoID int64, manifest string) error {
|
||||
cmd := "kubectl"
|
||||
args := []string{
|
||||
"apply",
|
||||
"-f", "-",
|
||||
"--namespace", k.namespace,
|
||||
}
|
||||
|
||||
// Use kubectl with stdin for the manifest
|
||||
kubectlCmd := exec.CommandContext(ctx, cmd, args...)
|
||||
kubectlCmd.Stdin = strings.NewReader(manifest)
|
||||
|
||||
stdout, _ := kubectlCmd.StdoutPipe()
|
||||
stderr, _ := kubectlCmd.StderrPipe()
|
||||
|
||||
if err := kubectlCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start kubectl apply: %w", err)
|
||||
}
|
||||
|
||||
// Stream output
|
||||
go streamOutput(stdout, eventChan, repoID, "kubectl")
|
||||
go streamOutput(stderr, eventChan, repoID, "kubectl")
|
||||
|
||||
return kubectlCmd.Wait()
|
||||
}
|
||||
|
||||
func (k *KubectlClient) DeleteResources(ctx context.Context, eventChan chan<- *events.Event, repoID int64, appName string) error {
|
||||
commands := [][]string{
|
||||
{"delete", "deployment", appName, "--namespace", k.namespace},
|
||||
{"delete", "service", appName, "--namespace", k.namespace},
|
||||
{"delete", "configmap", appName, "--namespace", k.namespace},
|
||||
}
|
||||
|
||||
for _, args := range commands {
|
||||
cmd := "kubectl"
|
||||
if err := runKubectlCommand(ctx, eventChan, repoID, cmd, args...); err != nil {
|
||||
// Don't fail if resources don't exist
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
return fmt.Errorf("failed to delete resource with args %v: %w", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubectlClient) ScaleDeployment(ctx context.Context, eventChan chan<- *events.Event, repoID int64, appName string, replicas int) error {
|
||||
cmd := "kubectl"
|
||||
args := []string{
|
||||
"scale",
|
||||
"deployment", appName,
|
||||
"--replicas", fmt.Sprintf("%d", replicas),
|
||||
"--namespace", k.namespace,
|
||||
}
|
||||
|
||||
return runKubectlCommand(ctx, eventChan, repoID, cmd, args...)
|
||||
}
|
||||
|
||||
func (k *KubectlClient) GetDeploymentStatus(ctx context.Context, appName string) (string, error) {
|
||||
cmd := "kubectl"
|
||||
args := []string{
|
||||
"get", "deployment", appName,
|
||||
"--namespace", k.namespace,
|
||||
"-o", "jsonpath='{.status.readyReplicas}'",
|
||||
}
|
||||
|
||||
output, err := exec.CommandContext(ctx, cmd, args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get deployment status: %w", err)
|
||||
}
|
||||
|
||||
return strings.Trim(string(output), "'"), nil
|
||||
}
|
||||
|
||||
func runKubectlCommand(ctx context.Context, eventChan chan<- *events.Event, repoID int64, cmd string, args ...string) error {
|
||||
kubectlCmd := exec.CommandContext(ctx, cmd, args...)
|
||||
stdout, _ := kubectlCmd.StdoutPipe()
|
||||
stderr, _ := kubectlCmd.StderrPipe()
|
||||
|
||||
if err := kubectlCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start kubectl command: %w", err)
|
||||
}
|
||||
|
||||
go streamOutput(stdout, eventChan, repoID, "kubectl")
|
||||
go streamOutput(stderr, eventChan, repoID, "kubectl")
|
||||
|
||||
return kubectlCmd.Wait()
|
||||
}
|
||||
|
||||
func streamOutput(r interface{ Read([]byte) (int, error) }, eventChan chan<- *events.Event, repoID int64, source string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
event := events.NewLogEvent(repoID, scanner.Text())
|
||||
event.Data["source"] = source
|
||||
eventChan <- event
|
||||
}
|
||||
}
|
||||
|
||||
func GetAppName(repo *model.Repo) string {
|
||||
// Generate a consistent app name based on repo
|
||||
parts := strings.Split(strings.TrimSuffix(repo.RepoURL, ".git"), "/")
|
||||
repoName := parts[len(parts)-1]
|
||||
return fmt.Sprintf("repo-%d-%s", repo.ID, strings.ToLower(repoName))
|
||||
}
|
||||
132
internal/k8s/manifests.go
Normal file
132
internal/k8s/manifests.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
func GenerateDeploymentManifest(repo *model.Repo, imageName string) string {
|
||||
appName := GetAppName(repo)
|
||||
port := getAppPort(repo.Type)
|
||||
|
||||
return fmt.Sprintf(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: %s
|
||||
labels:
|
||||
app: %s
|
||||
repo-id: "%d"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: %s
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: %s
|
||||
repo-id: "%d"
|
||||
spec:
|
||||
containers:
|
||||
- name: %s
|
||||
image: %s
|
||||
ports:
|
||||
- containerPort: %d
|
||||
env:
|
||||
- name: PORT
|
||||
value: "%d"
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: %s
|
||||
labels:
|
||||
app: %s
|
||||
repo-id: "%d"
|
||||
spec:
|
||||
selector:
|
||||
app: %s
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: %d
|
||||
protocol: TCP
|
||||
type: ClusterIP
|
||||
`, appName, appName, repo.ID, appName, appName, repo.ID, appName, imageName, port, port, appName, appName, repo.ID, appName, port)
|
||||
}
|
||||
|
||||
func GenerateConfigMapManifest(repo *model.Repo) string {
|
||||
appName := GetAppName(repo)
|
||||
|
||||
return fmt.Sprintf(`apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: %s
|
||||
labels:
|
||||
app: %s
|
||||
repo-id: "%d"
|
||||
data:
|
||||
repo-url: "%s"
|
||||
repo-type: "%s"
|
||||
user-id: "%s"
|
||||
`, appName, appName, repo.ID, repo.RepoURL, repo.Type, repo.UserID)
|
||||
}
|
||||
|
||||
func GenerateIngressManifest(repo *model.Repo) string {
|
||||
appName := GetAppName(repo)
|
||||
host := fmt.Sprintf("repo-%d.example.com", repo.ID)
|
||||
|
||||
return fmt.Sprintf(`apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: %s
|
||||
labels:
|
||||
app: %s
|
||||
repo-id: "%d"
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
rules:
|
||||
- host: %s
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: %s
|
||||
port:
|
||||
number: 80
|
||||
`, appName, appName, repo.ID, host, appName)
|
||||
}
|
||||
|
||||
func getAppPort(repoType model.RepoType) int {
|
||||
switch repoType {
|
||||
case model.TypeNodeJS:
|
||||
return 3000
|
||||
case model.TypePython:
|
||||
return 8000
|
||||
default:
|
||||
return 8080
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateFullManifest(repo *model.Repo, imageName string) string {
|
||||
var manifest strings.Builder
|
||||
|
||||
manifest.WriteString(GenerateDeploymentManifest(repo, imageName))
|
||||
manifest.WriteString("\n---\n")
|
||||
manifest.WriteString(GenerateConfigMapManifest(repo))
|
||||
manifest.WriteString("\n---\n")
|
||||
manifest.WriteString(GenerateIngressManifest(repo))
|
||||
|
||||
return manifest.String()
|
||||
}
|
||||
33
internal/model/repo.go
Normal file
33
internal/model/repo.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type RepoStatus string
|
||||
type RepoType string
|
||||
|
||||
const (
|
||||
StatusNeedToDeploy RepoStatus = "need_to_deploy"
|
||||
StatusDeploying RepoStatus = "deploying"
|
||||
StatusDeployed RepoStatus = "deployed"
|
||||
StatusError RepoStatus = "err"
|
||||
StatusStopped RepoStatus = "stopped"
|
||||
StatusRestarting RepoStatus = "restarting"
|
||||
StatusDeleted RepoStatus = "deleted"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeNodeJS RepoType = "nodejs"
|
||||
TypePython RepoType = "python"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
Status RepoStatus `json:"status"`
|
||||
UserID string `json:"user_id"`
|
||||
Type RepoType `json:"type"`
|
||||
ImageTag *string `json:"image_tag"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
90
internal/reconciler/reconciler.go
Normal file
90
internal/reconciler/reconciler.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"deployment-manager/internal/db"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type Reconciler struct {
|
||||
repoStore *db.RepoStore
|
||||
jobChan chan<- int64
|
||||
ticker *time.Ticker
|
||||
}
|
||||
|
||||
func NewReconciler(database *sql.DB, jobChan chan<- int64, tickInterval time.Duration) *Reconciler {
|
||||
return &Reconciler{
|
||||
repoStore: db.NewRepoStore(database),
|
||||
jobChan: jobChan,
|
||||
ticker: time.NewTicker(tickInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) Run(ctx context.Context) {
|
||||
log.Println("Reconciler started")
|
||||
|
||||
defer r.ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Reconciler shutting down")
|
||||
return
|
||||
|
||||
case <-r.ticker.C:
|
||||
if err := r.reconcile(ctx); err != nil {
|
||||
log.Printf("Reconciliation error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcile(ctx context.Context) error {
|
||||
// Find repos that need to be deployed
|
||||
repos, err := r.repoStore.ListByStatus(model.StatusNeedToDeploy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Enqueue jobs for repos that need deployment
|
||||
for _, repo := range repos {
|
||||
select {
|
||||
case r.jobChan <- repo.ID:
|
||||
log.Printf("Enqueued deployment job for repo %d", repo.ID)
|
||||
default:
|
||||
log.Printf("Job queue full, skipping repo %d", repo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find repos that are in error state and might need retry
|
||||
errorRepos, err := r.repoStore.ListByStatus(model.StatusError)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Simple retry logic: retry errors older than 5 minutes
|
||||
for _, repo := range errorRepos {
|
||||
if time.Since(repo.UpdatedAt) > 5*time.Minute {
|
||||
// Reset to need_to_deploy for retry
|
||||
repo.Status = model.StatusNeedToDeploy
|
||||
repo.LastError = nil
|
||||
if err := r.repoStore.Update(repo); err != nil {
|
||||
log.Printf("Failed to reset repo %d for retry: %v", repo.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case r.jobChan <- repo.ID:
|
||||
log.Printf("Enqueued retry job for repo %d", repo.ID)
|
||||
default:
|
||||
log.Printf("Job queue full, skipping retry for repo %d", repo.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
106
internal/worker/deploy.go
Normal file
106
internal/worker/deploy.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"deployment-manager/internal/db"
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/k8s"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type DeploymentManager struct {
|
||||
repoStore *db.RepoStore
|
||||
kubectl *k8s.KubectlClient
|
||||
eventChan chan<- *events.Event
|
||||
}
|
||||
|
||||
func NewDeploymentManager(database *sql.DB, eventChan chan<- *events.Event) *DeploymentManager {
|
||||
return &DeploymentManager{
|
||||
repoStore: db.NewRepoStore(database),
|
||||
kubectl: k8s.NewKubectlClient("default"),
|
||||
eventChan: eventChan,
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeploymentManager) StopDeployment(ctx context.Context, repoID int64) error {
|
||||
repo, err := dm.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appName := k8s.GetAppName(repo)
|
||||
|
||||
// Scale deployment to 0
|
||||
if err := dm.kubectl.ScaleDeployment(ctx, dm.eventChan, repoID, appName, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update status
|
||||
repo.Status = model.StatusStopped
|
||||
if err := dm.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send event
|
||||
dm.eventChan <- events.NewRepoEvent(repo, events.EventTypeRepoUpdated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dm *DeploymentManager) RestartDeployment(ctx context.Context, repoID int64) error {
|
||||
repo, err := dm.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appName := k8s.GetAppName(repo)
|
||||
|
||||
// Update status to restarting
|
||||
repo.Status = model.StatusRestarting
|
||||
if err := dm.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Scale deployment to 1
|
||||
if err := dm.kubectl.ScaleDeployment(ctx, dm.eventChan, repoID, appName, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update status to deployed
|
||||
repo.Status = model.StatusDeployed
|
||||
if err := dm.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send event
|
||||
dm.eventChan <- events.NewRepoEvent(repo, events.EventTypeRepoUpdated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dm *DeploymentManager) DeleteDeployment(ctx context.Context, repoID int64) error {
|
||||
repo, err := dm.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appName := k8s.GetAppName(repo)
|
||||
|
||||
// Delete Kubernetes resources
|
||||
if err := dm.kubectl.DeleteResources(ctx, dm.eventChan, repoID, appName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update status to deleted
|
||||
repo.Status = model.StatusDeleted
|
||||
if err := dm.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send event
|
||||
dm.eventChan <- events.NewRepoEvent(repo, events.EventTypeRepoDeleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
151
internal/worker/pool.go
Normal file
151
internal/worker/pool.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"database/sql"
|
||||
|
||||
"deployment-manager/internal/db"
|
||||
"deployment-manager/internal/events"
|
||||
"deployment-manager/internal/executor"
|
||||
"deployment-manager/internal/k8s"
|
||||
"deployment-manager/internal/model"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
id int
|
||||
db *sql.DB
|
||||
repoStore *db.RepoStore
|
||||
eventChan chan<- *events.Event
|
||||
jobChan <-chan int64
|
||||
}
|
||||
|
||||
func NewWorker(id int, database *sql.DB, eventChan chan<- *events.Event, jobChan <-chan int64) *Worker {
|
||||
return &Worker{
|
||||
id: id,
|
||||
db: database,
|
||||
repoStore: db.NewRepoStore(database),
|
||||
eventChan: eventChan,
|
||||
jobChan: jobChan,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
log.Printf("Worker %d started", w.id)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("Worker %d shutting down", w.id)
|
||||
return
|
||||
|
||||
case repoID := <-w.jobChan:
|
||||
if err := w.processJob(ctx, repoID); err != nil {
|
||||
log.Printf("Worker %d failed to process job %d: %v", w.id, repoID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) processJob(ctx context.Context, repoID int64) error {
|
||||
log.Printf("Worker %d processing job for repo %d", w.id, repoID)
|
||||
|
||||
// Get repo from database
|
||||
repo, err := w.repoStore.Get(repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update status to deploying
|
||||
repo.Status = model.StatusDeploying
|
||||
if err := w.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send deployment started event
|
||||
w.eventChan <- events.NewRepoEvent(repo, events.EventTypeDeployStarted)
|
||||
|
||||
// Perform deployment
|
||||
if err := w.deploy(ctx, repo); err != nil {
|
||||
// Update status to error
|
||||
repo.Status = model.StatusError
|
||||
repo.LastError = &[]string{err.Error()}[0]
|
||||
if updateErr := w.repoStore.Update(repo); updateErr != nil {
|
||||
log.Printf("Failed to update repo error status: %v", updateErr)
|
||||
}
|
||||
|
||||
// Send error event
|
||||
w.eventChan <- events.NewRepoEvent(repo, events.EventTypeDeployError)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update status to deployed
|
||||
repo.Status = model.StatusDeployed
|
||||
repo.LastError = nil
|
||||
if err := w.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send success event
|
||||
w.eventChan <- events.NewRepoEvent(repo, events.EventTypeDeploySuccess)
|
||||
|
||||
log.Printf("Worker %d completed deployment for repo %d", w.id, repoID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) deploy(ctx context.Context, repo *model.Repo) error {
|
||||
// Clone repository
|
||||
workDir, err := w.cloneRepo(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create Dockerfile
|
||||
if err := executor.CreateDockerfile(workDir, repo.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build and push Docker image
|
||||
imageName, err := executor.BuildAndPushImage(ctx, w.eventChan, repo, workDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update repo with image tag
|
||||
repo.ImageTag = &imageName
|
||||
if err := w.repoStore.Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Deploy to Kubernetes
|
||||
if err := w.deployToK8s(ctx, repo, imageName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) cloneRepo(ctx context.Context, repo *model.Repo) (string, error) {
|
||||
workDir := "/tmp/repo-" + string(rune(repo.ID))
|
||||
|
||||
// Clone the repository
|
||||
if err := executor.RunCmd(ctx, w.eventChan, repo.ID, "git", "clone", repo.RepoURL, workDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return workDir, nil
|
||||
}
|
||||
|
||||
func (w *Worker) deployToK8s(ctx context.Context, repo *model.Repo, imageName string) error {
|
||||
kubectl := k8s.NewKubectlClient("default")
|
||||
|
||||
// Generate manifest
|
||||
manifest := k8s.GenerateFullManifest(repo, imageName)
|
||||
|
||||
// Apply manifest
|
||||
if err := kubectl.ApplyManifest(ctx, w.eventChan, repo.ID, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
51
manifests/nodejs.yaml
Normal file
51
manifests/nodejs.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{.AppName}}
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{.AppName}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
containers:
|
||||
- name: {{.AppName}}
|
||||
image: {{.ImageName}}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{.AppName}}
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
selector:
|
||||
app: {{.AppName}}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
type: ClusterIP
|
||||
51
manifests/python.yaml
Normal file
51
manifests/python.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{.AppName}}
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{.AppName}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
containers:
|
||||
- name: {{.AppName}}
|
||||
image: {{.ImageName}}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8000"
|
||||
- name: PYTHONUNBUFFERED
|
||||
value: "1"
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{.AppName}}
|
||||
labels:
|
||||
app: {{.AppName}}
|
||||
repo-id: "{{.RepoID}}"
|
||||
spec:
|
||||
selector:
|
||||
app: {{.AppName}}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
type: ClusterIP
|
||||
13
migrations/001_init.sql
Normal file
13
migrations/001_init.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- nodejs | python
|
||||
image_tag TEXT,
|
||||
last_error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_repos_status ON repos(status);
|
||||
1
readme.txt
Normal file
1
readme.txt
Normal file
@@ -0,0 +1 @@
|
||||
deployment manager it will read the repo url from the database and deploy it to k8s
|
||||
13
sqlite.schema
Normal file
13
sqlite.schema
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- nodejs | python
|
||||
image_tag TEXT,
|
||||
last_error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_repos_status ON repos(status);
|
||||
Reference in New Issue
Block a user