diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 07290ab..4c78b84 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -204,6 +204,106 @@ func main() { loadingSpinner.Stop() fmt.Println("Deployed successfully!") + case "stop": + var projectName string + + if len(os.Args) < 3 { + if _, err := os.Stat("flux.json"); err != nil { + fmt.Printf("Usage: flux delete , or run flux delete in the project directory\n") + os.Exit(1) + } + + fluxConfigFile, err := os.Open("flux.json") + if err != nil { + fmt.Printf("Failed to open flux.json: %v\n", err) + os.Exit(1) + } + defer fluxConfigFile.Close() + + var config models.ProjectConfig + if err := json.NewDecoder(fluxConfigFile).Decode(&config); err != nil { + fmt.Printf("Failed to decode flux.json: %v\n", err) + os.Exit(1) + } + + projectName = config.Name + } else { + projectName = os.Args[2] + } + + req, err := http.Post(config.DeamonURL+"/stop/"+projectName, "application/json", nil) + if err != nil { + fmt.Printf("Failed to stop app: %v\n", err) + os.Exit(1) + } + defer req.Body.Close() + + if req.StatusCode != http.StatusOK { + responseBody, err := io.ReadAll(req.Body) + if err != nil { + fmt.Printf("error reading response body: %v\n", err) + os.Exit(1) + } + + if len(responseBody) > 0 && responseBody[len(responseBody)-1] == '\n' { + responseBody = responseBody[:len(responseBody)-1] + } + + fmt.Printf("Stop failed: %s\n", responseBody) + os.Exit(1) + } + + fmt.Printf("Successfully stopped %s\n", projectName) + case "start": + var projectName string + + if len(os.Args) < 3 { + if _, err := os.Stat("flux.json"); err != nil { + fmt.Printf("Usage: flux delete , or run flux delete in the project directory\n") + os.Exit(1) + } + + fluxConfigFile, err := os.Open("flux.json") + if err != nil { + fmt.Printf("Failed to open flux.json: %v\n", err) + os.Exit(1) + } + defer fluxConfigFile.Close() + + var config models.ProjectConfig + if err := json.NewDecoder(fluxConfigFile).Decode(&config); err != nil { + fmt.Printf("Failed to decode flux.json: %v\n", err) + os.Exit(1) + } + + projectName = config.Name + } else { + projectName = os.Args[2] + } + + req, err := http.Post(config.DeamonURL+"/start/"+projectName, "application/json", nil) + if err != nil { + fmt.Printf("Failed to start app: %v\n", err) + os.Exit(1) + } + defer req.Body.Close() + + if req.StatusCode != http.StatusOK { + responseBody, err := io.ReadAll(req.Body) + if err != nil { + fmt.Printf("error reading response body: %v\n", err) + os.Exit(1) + } + + if len(responseBody) > 0 && responseBody[len(responseBody)-1] == '\n' { + responseBody = responseBody[:len(responseBody)-1] + } + + fmt.Printf("Start failed: %s\n", responseBody) + os.Exit(1) + } + + fmt.Printf("Successfully started %s\n", projectName) case "delete": var projectName string @@ -282,8 +382,13 @@ func main() { os.Exit(1) } + if len(apps) == 0 { + fmt.Println("No apps found") + os.Exit(0) + } + for _, app := range apps { - fmt.Printf("%s\n", app.Name) + fmt.Printf("%s (%s)\n", app.Name, app.DeploymentStatus) } default: fmt.Println("Unknown command:", command) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 0e298c2..a5dba20 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -12,6 +12,8 @@ func main() { http.HandleFunc("POST /deploy", fluxServer.DeployHandler) http.HandleFunc("DELETE /deploy/{name}", fluxServer.DeleteDeployHandler) + http.HandleFunc("POST /start/{name}", fluxServer.StartDeployHandler) + http.HandleFunc("POST /stop/{name}", fluxServer.StopDeployHandler) http.HandleFunc("GET /apps", fluxServer.ListAppsHandler) log.Printf("Fluxd started on http://127.0.0.1:5647\n") diff --git a/models/app.go b/models/app.go index ab66d8a..4ab4d26 100644 --- a/models/app.go +++ b/models/app.go @@ -9,11 +9,12 @@ type ProjectConfig struct { } type App struct { - ID int64 `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - ProjectPath string `json:"project_path"` - ProjectConfig ProjectConfig `json:"project_config"` - DeploymentID int64 `json:"deployment_id"` - CreatedAt string `json:"created_at"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + ProjectConfig ProjectConfig `json:"project_config,omitempty"` + DeploymentID int64 `json:"deployment_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + DeploymentStatus string `json:"deployment_status,omitempty"` } diff --git a/models/docker.go b/models/docker.go new file mode 100644 index 0000000..cff7004 --- /dev/null +++ b/models/docker.go @@ -0,0 +1,14 @@ +package models + +type Containers struct { + ID string `json:"id"` + ContainerID string `json:"container_id"` + DeploymentID int64 `json:"deployment_id"` + CreatedAt string `json:"created_at"` +} + +type Deployments struct { + ID int64 `json:"id"` + URLs string `json:"urls"` + CreatedAt string `json:"created_at"` +} diff --git a/server/docker.go b/server/container.go similarity index 76% rename from server/docker.go rename to server/container.go index 35fdc2b..0c9c2a8 100644 --- a/server/docker.go +++ b/server/container.go @@ -34,25 +34,10 @@ func NewContainerManager() *ContainerManager { } } -func (cm *ContainerManager) DeployContainer(ctx context.Context, imageName, containerPrefix, projectPath string, projectConfig models.ProjectConfig) (string, error) { +func (cm *ContainerManager) CreateContainer(ctx context.Context, imageName, projectPath string, projectConfig models.ProjectConfig) (string, error) { log.Printf("Deploying container with image %s\n", imageName) - containerName := fmt.Sprintf("%s-%s", containerPrefix, time.Now().Format("20060102-150405")) - - existingContainers, err := cm.findExistingContainers(ctx, containerPrefix) - if err != nil { - return "", fmt.Errorf("Failed to find existing containers: %v", err) - } - - // TODO: swap containers if they are running and have the same image so that we can have a constant uptime - for _, existingContainer := range existingContainers { - log.Printf("Stopping existing container: %s\n", existingContainer) - - err = cm.RemoveContainer(ctx, existingContainer) - if err != nil { - return "", err - } - } + containerName := fmt.Sprintf("%s-%s", projectConfig.Name, time.Now().Format("20060102-150405")) if projectConfig.EnvFile != "" { envBytes, err := os.Open(filepath.Join(projectPath, projectConfig.EnvFile)) @@ -74,7 +59,7 @@ func (cm *ContainerManager) DeployContainer(ctx context.Context, imageName, cont vol, err := cm.dockerClient.VolumeCreate(ctx, volume.CreateOptions{ Driver: "local", DriverOpts: map[string]string{}, - Name: fmt.Sprintf("%s-volume", containerPrefix), + Name: fmt.Sprintf("%s-volume", projectConfig.Name), }) if err != nil { return "", fmt.Errorf("Failed to create volume: %v", err) @@ -82,7 +67,7 @@ func (cm *ContainerManager) DeployContainer(ctx context.Context, imageName, cont log.Printf("Volume %s created at %s\n", vol.Name, vol.Mountpoint) - log.Printf("Creating and starting container %s...\n", containerName) + log.Printf("Creating container %s...\n", containerName) resp, err := cm.dockerClient.ContainerCreate(ctx, &container.Config{ Image: imageName, Env: projectConfig.Environment, @@ -119,14 +104,18 @@ func (cm *ContainerManager) DeployContainer(ctx context.Context, imageName, cont return "", fmt.Errorf("Failed to create container: %v", err) } - if err := cm.dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { - return "", fmt.Errorf("Failed to start container: %v", err) - } - - log.Printf("Deployed new container: %s\n", containerName) + log.Printf("Created new container: %s\n", containerName) return resp.ID, nil } +func (cm *ContainerManager) StartContainer(ctx context.Context, containerID string) error { + return cm.dockerClient.ContainerStart(ctx, containerID, container.StartOptions{}) +} + +func (cm *ContainerManager) StopContainer(ctx context.Context, containerID string) error { + return cm.dockerClient.ContainerStop(ctx, containerID, container.StopOptions{}) +} + // RemoveContainer stops and removes a container, but be warned that this will not remove the container from the database func (cm *ContainerManager) RemoveContainer(ctx context.Context, containerID string) error { if err := cm.dockerClient.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { diff --git a/server/deploy.go b/server/deploy.go index eef8784..1f5afe9 100644 --- a/server/deploy.go +++ b/server/deploy.go @@ -1,14 +1,12 @@ package server import ( - "database/sql" "encoding/json" "fmt" "log" "mime/multipart" "net/http" "os/exec" - "strings" "github.com/juls0730/fluxd/models" ) @@ -19,7 +17,7 @@ type DeployRequest struct { } type DeployResponse struct { - AppID int64 `json:"app_id"` + App models.App `json:"app"` } func (s *FluxServer) DeployHandler(w http.ResponseWriter, r *http.Request) { @@ -98,109 +96,118 @@ func (s *FluxServer) DeployHandler(w http.ResponseWriter, r *http.Request) { return } - containerID, err := s.containerManager.DeployContainer(r.Context(), imageName, projectConfig.Name, projectPath, projectConfig) - if err != nil { - log.Printf("Failed to deploy container: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + var app models.App + s.db.QueryRow("SELECT id FROM apps WHERE name = ?", projectConfig.Name).Scan(&app.ID) - deploymentResult, err := s.db.Exec("INSERT INTO deployments (urls) VALUES (?)", strings.Join(projectConfig.Urls, ",")) - if err != nil { - log.Printf("Failed to insert deployment: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - deploymentID, err := deploymentResult.LastInsertId() - if err != nil { - log.Printf("Failed to get deployment id: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, err = s.db.Exec("INSERT INTO containers (container_id, deployment_id, status) VALUES (?, ?, ?)", containerID, deploymentID, "pending") - if err != nil { - log.Printf("Failed to get container id: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - var app struct { - id int - name string - deployment_id int - } - s.db.QueryRow("SELECT id, name, deployment_id FROM apps WHERE name = ?", projectConfig.Name).Scan(&app.id, &app.name, &app.deployment_id) - configBytes, err := json.Marshal(projectConfig) - if err != nil { - log.Printf("Failed to marshal project config: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - tx, err := s.db.Begin() - if err != nil { - log.Printf("Failed to begin transaction: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - var appResult sql.Result - if app.id == 0 { - // create app in the database - appResult, err = tx.Exec("INSERT INTO apps (name, image, project_path, project_config, deployment_id) VALUES (?, ?, ?, ?, ?)", projectConfig.Name, imageName, projectPath, configBytes, deploymentID) + if app.ID == 0 { + configBytes, err := json.Marshal(projectConfig) + if err != nil { + log.Printf("Failed to marshal project config: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + containerID, err := s.containerManager.CreateContainer(r.Context(), imageName, projectPath, projectConfig) + if err != nil { + log.Printf("Failed to create container: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + deploymentID, err := s.CreateDeployment(r.Context(), projectConfig, containerID) + if err != nil { + log.Printf("Failed to create deployment: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // create app in the database + appResult, err := s.db.Exec("INSERT INTO apps (name, image, project_path, project_config, deployment_id) VALUES (?, ?, ?, ?, ?)", projectConfig.Name, imageName, projectPath, configBytes, deploymentID) if err != nil { - tx.Rollback() log.Printf("Failed to insert app: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } + + appID, err := appResult.LastInsertId() + if err != nil { + log.Printf("Failed to get app id: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.db.QueryRow("SELECT id, name, deployment_id FROM apps WHERE id = ?", appID).Scan(&app.ID, &app.Name, &app.DeploymentID) } else { - _, err = tx.Exec("DELETE FROM deployments WHERE id = ?", app.deployment_id) + err = s.UpgradeDeployment(r.Context(), app.DeploymentID, projectConfig, imageName, projectPath) if err != nil { - tx.Rollback() - log.Printf("Failed to delete old deployment: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, err = tx.Exec("DELETE FROM containers WHERE deployment_id = ?", app.deployment_id) - if err != nil { - tx.Rollback() - log.Printf("Failed to delete old containers: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // update app in the database - appResult, err = tx.Exec("UPDATE apps SET project_config = ?, deployment_id = ? WHERE name = ?", configBytes, deploymentID, projectConfig.Name) - if err != nil { - tx.Rollback() - log.Printf("Failed to update app: %v\n", err) + log.Printf("Failed to upgrade deployment: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } } - if err := tx.Commit(); err != nil { - log.Printf("Failed to commit transaction: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - appId, err := appResult.LastInsertId() + err = s.StartDeployment(r.Context(), app.DeploymentID) if err != nil { - log.Printf("Failed to get app id: %v\n", err) + log.Printf("Failed to start deployment: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } + log.Printf("App %s deployed successfully!\n", app.Name) + json.NewEncoder(w).Encode(DeployResponse{ - AppID: appId, + App: app, }) } +func (s *FluxServer) StartDeployHandler(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var app struct { + id int64 + name string + deployment_id int64 + } + s.db.QueryRow("SELECT id, name, deployment_id FROM apps WHERE name = ?", name).Scan(&app.id, &app.name, &app.deployment_id) + + if app.id == 0 { + http.Error(w, "App not found", http.StatusNotFound) + return + } + + err := s.StartDeployment(r.Context(), app.deployment_id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (s *FluxServer) StopDeployHandler(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var app struct { + id int64 + name string + deployment_id int64 + } + s.db.QueryRow("SELECT id, name, deployment_id FROM apps WHERE name = ?", name).Scan(&app.id, &app.name, &app.deployment_id) + + if app.id == 0 { + http.Error(w, "App not found", http.StatusNotFound) + return + } + + err := s.StopDeployment(r.Context(), app.deployment_id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + func (s *FluxServer) DeleteDeployHandler(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") @@ -285,7 +292,6 @@ func (s *FluxServer) DeleteDeployHandler(w http.ResponseWriter, r *http.Request) } func (s *FluxServer) ListAppsHandler(w http.ResponseWriter, r *http.Request) { - // Implement app listing logic var apps []models.App rows, err := s.db.Query("SELECT * FROM apps") if err != nil { @@ -314,6 +320,18 @@ func (s *FluxServer) ListAppsHandler(w http.ResponseWriter, r *http.Request) { apps = append(apps, app) } + // for each app, get the deployment status + for i, app := range apps { + deploymentStatus, err := s.GetDeploymentStatus(r.Context(), app.DeploymentID) + if err != nil { + log.Printf("Failed to get deployment status: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + apps[i].DeploymentStatus = deploymentStatus + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(apps) } diff --git a/server/deployment.go b/server/deployment.go new file mode 100644 index 0000000..e3738cf --- /dev/null +++ b/server/deployment.go @@ -0,0 +1,223 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/juls0730/fluxd/models" +) + +// Creates a deployment and containers in the database +func (s *FluxServer) CreateDeployment(ctx context.Context, projectConfig models.ProjectConfig, containerID string) (int64, error) { + deploymentResult, err := s.db.Exec("INSERT INTO deployments (urls) VALUES (?)", strings.Join(projectConfig.Urls, ",")) + if err != nil { + log.Printf("Failed to insert deployment: %v\n", err) + return 0, err + } + + deploymentID, err := deploymentResult.LastInsertId() + if err != nil { + log.Printf("Failed to get deployment id: %v\n", err) + return 0, err + } + + _, err = s.db.Exec("INSERT INTO containers (container_id, deployment_id) VALUES (?, ?)", containerID, deploymentID) + if err != nil { + log.Printf("Failed to get container id: %v\n", err) + return 0, err + } + + return deploymentID, nil +} + +func (s *FluxServer) UpgradeDeployment(ctx context.Context, deploymentID int64, projectConfig models.ProjectConfig, imageName string, projectPath string) error { + configBytes, err := json.Marshal(projectConfig) + if err != nil { + log.Printf("Failed to marshal project config: %v\n", err) + return err + } + + existingContainers, err := s.containerManager.findExistingContainers(ctx, projectConfig.Name) + if err != nil { + return fmt.Errorf("Failed to find existing containers: %v", err) + } + + tx, err := s.db.Begin() + if err != nil { + log.Printf("Failed to begin transaction: %v\n", err) + return err + } + // TODO: swap containers if they are running and have the same image so that we can have a constant uptime + for _, existingContainer := range existingContainers { + log.Printf("Stopping existing container: %s\n", existingContainer[0:12]) + + tx.Exec("DELETE FROM containers WHERE container_id = ?", existingContainer) + err = s.containerManager.RemoveContainer(ctx, existingContainer) + if err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + log.Printf("Failed to commit transaction: %v\n", err) + return err + } + + containerID, err := s.containerManager.CreateContainer(ctx, imageName, projectPath, projectConfig) + if err != nil { + log.Printf("Failed to create container: %v\n", err) + return err + } + + s.db.Exec("INSERT INTO containers (container_id, deployment_id) VALUES (?, ?)", containerID, deploymentID) + + // update app in the database + if _, err := s.db.Exec("UPDATE apps SET project_config = ?, deployment_id = ? WHERE name = ?", configBytes, deploymentID, projectConfig.Name); err != nil { + log.Printf("Failed to update app: %v\n", err) + return err + } + + return nil +} + +func (s *FluxServer) StartDeployment(ctx context.Context, deploymentID int64) error { + var containerIds []string + rows, err := s.db.Query("SELECT container_id FROM containers WHERE deployment_id = ?", deploymentID) + if err != nil { + log.Printf("Failed to query containers: %v\n", err) + return err + } + defer rows.Close() + + for rows.Next() { + var newContainerId string + if err := rows.Scan(&newContainerId); err != nil { + log.Printf("Failed to scan container id: %v\n", err) + return err + } + + containerIds = append(containerIds, newContainerId) + } + + for _, containerId := range containerIds { + err := s.containerManager.StartContainer(ctx, containerId) + if err != nil { + log.Printf("Failed to start container: %v\n", err) + return err + } + } + + tx, err := s.db.Begin() + if err != nil { + log.Printf("Failed to begin transaction: %v\n", err) + return err + } + + if err := tx.Commit(); err != nil { + log.Printf("Failed to commit transaction: %v\n", err) + return err + } + + return nil +} + +func (s *FluxServer) StopDeployment(ctx context.Context, deploymentID int64) error { + var containerIds []string + rows, err := s.db.Query("SELECT container_id FROM containers WHERE deployment_id = ?", deploymentID) + if err != nil { + log.Printf("Failed to query containers: %v\n", err) + return err + } + defer rows.Close() + + for rows.Next() { + var newContainerId string + if err := rows.Scan(&newContainerId); err != nil { + log.Printf("Failed to scan container id: %v\n", err) + return err + } + + containerIds = append(containerIds, newContainerId) + } + + for _, containerId := range containerIds { + err := s.containerManager.StopContainer(ctx, containerId) + if err != nil { + log.Printf("Failed to start container: %v\n", err) + return err + } + } + + tx, err := s.db.Begin() + if err != nil { + log.Printf("Failed to begin transaction: %v\n", err) + return err + } + + if err := tx.Commit(); err != nil { + log.Printf("Failed to commit transaction: %v\n", err) + return err + } + + return nil +} + +func (s *FluxServer) GetStatus(ctx context.Context, containerID string) (string, error) { + containerJSON, err := s.containerManager.dockerClient.ContainerInspect(ctx, containerID) + if err != nil { + return "", err + } + + return containerJSON.State.Status, nil +} + +func (s *FluxServer) GetDeploymentStatus(ctx context.Context, deploymentID int64) (string, error) { + var deployment models.Deployments + s.db.QueryRow("SELECT id, urls FROM deployments WHERE id = ?", deploymentID).Scan(&deployment.ID, &deployment.URLs) + + var containerIds []string + rows, err := s.db.Query("SELECT container_id FROM containers WHERE deployment_id = ?", deploymentID) + if err != nil { + log.Printf("Failed to query containers: %v\n", err) + return "", err + } + defer rows.Close() + + for rows.Next() { + var newContainerId string + if err := rows.Scan(&newContainerId); err != nil { + log.Printf("Failed to scan container id: %v\n", err) + return "", err + } + + containerIds = append(containerIds, newContainerId) + } + + var status string + for _, containerId := range containerIds { + containerStatus, err := s.GetStatus(ctx, containerId) + if err != nil { + log.Printf("Failed to get container status: %v\n", err) + return "", err + } + + // if not all containers are in the same state + if status != "" && status != containerStatus { + return "", fmt.Errorf("Malformed deployment") + } + + status = containerStatus + } + + switch status { + case "running": + return "running", nil + case "exited": + return "stopped", nil + default: + return "pending", nil + } +} diff --git a/server/schema.sql b/server/schema.sql index 26d8409..a58cd03 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -13,7 +13,6 @@ CREATE TABLE IF NOT EXISTS containers ( id INTEGER PRIMARY KEY AUTOINCREMENT, container_id TEXT NOT NULL, deployment_id INTEGER NOT NULL, - status TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(deployment_id) REFERENCES deployments(id) );