Loading environment variables...
;
}
@@ -234,13 +251,29 @@ export const EnvironmentVariables = ({ appId }: EnvironmentVariablesProps) => {
@@ -349,12 +382,28 @@ export const EnvironmentVariables = ({ appId }: EnvironmentVariablesProps) => {
@@ -370,10 +419,24 @@ export const EnvironmentVariables = ({ appId }: EnvironmentVariablesProps) => {
) : (
-
+
{env.key}
- =
- ••••••••
+ =
+
+ {visibleVarIds.has(env.id) ? env.value : "••••••••"}
+
+ toggleVisibility(env.id)}
+ >
+ {visibleVarIds.has(env.id) ? (
+
+ ) : (
+
+ )}
+
{
const [isRepoLoading, setIsRepoLoading] = useState(true)
const [isBranchLoading, setIsBranchLoading] = useState(false)
+ // Public Git state
+ const [publicGitUrl, setPublicGitUrl] = useState(app.gitCloneUrl || "")
+ const [publicGitBranch, setPublicGitBranch] = useState(app.gitBranch || "main")
+ const [isSavingPublicGit, setIsSavingPublicGit] = useState(false)
+
// this is for github app fetching
// FIX: name of this function should be changed
const fetchApp = async () => {
@@ -149,6 +155,38 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => {
}
}
+ const savePublicGitConfig = async () => {
+ if (!publicGitUrl.trim()) {
+ toast.error("Please enter a Git URL")
+ return
+ }
+
+ try {
+ setIsSavingPublicGit(true)
+ const res = await fetch("/api/apps/update", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ appId: app.id,
+ gitProviderId: null,
+ gitRepository: null,
+ gitBranch: publicGitBranch || "main",
+ gitCloneUrl: publicGitUrl.trim(),
+ }),
+ })
+
+ const data = await res.json()
+ if (!data.success) throw new Error(data.error)
+
+ toast.success("Public Git configuration saved")
+ } catch {
+ toast.error("Failed to save configuration")
+ } finally {
+ setIsSavingPublicGit(false)
+ }
+ }
+
return (
@@ -160,6 +198,11 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => {
GitHub
+
+
+ Public Git
+
+
{/* */}
{/* */}
{/* GitLab */}
@@ -298,6 +341,52 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => {
)}
+ {/* ✅ PUBLIC GIT TAB CONTENT */}
+
+
+
+ Public Git Repository
+
+ Deploy from any public Git repository by providing the URL and branch.
+
+
+
+
+
+
+
Git URL
+
setPublicGitUrl(e.target.value)}
+ />
+
+ Enter the full clone URL of your public repository
+
+
+
+
+
Branch
+
setPublicGitBranch(e.target.value)}
+ />
+
+ The branch to deploy from (default: main)
+
+
+
+
+
+ {isSavingPublicGit ? "Saving..." : "Save Configuration"}
+
+
+
+
+
{/* ✅ OTHER PROVIDERS (Disabled) */}
diff --git a/dash/src/features/applications/AppPage.tsx b/dash/src/features/applications/AppPage.tsx
index 3e53a9c..6e08e3e 100644
--- a/dash/src/features/applications/AppPage.tsx
+++ b/dash/src/features/applications/AppPage.tsx
@@ -7,6 +7,7 @@ import { useApplication } from "@/hooks";
import { TabsList, Tabs, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { AppInfo, GitProviderTab, EnvironmentVariables, Domains, AppSettings, LiveLogsViewer, AppStats, Volumes, ContainerStats } from "@/components/applications";
import { DeploymentsTab } from "@/components/deployments";
+import { ComposeAppPage } from "./ComposeAppPage";
export const AppPage = () => {
@@ -67,6 +68,10 @@ export const AppPage = () => {
if (!app) return null;
+ if (app.appType === 'compose') {
+ return ;
+ }
+
return (
{/* Header */}
@@ -91,19 +96,19 @@ export const AppPage = () => {
- Info
- {app.appType !== 'database' && Git }
- Environment
- {app.appType === 'web' && Domains }
- Deployments
- Stats
- Logs
- Settings
-
-
+ Info
+ {app.appType !== 'database' && Sources }
+ Environment
+ {app.appType === 'web' && Domains }
+ Deployments
+ Stats
+ Logs
+ Settings
+
+
{/* ✅ INFO TAB */}
-
+ < TabsContent value="info" className="space-y-6" >
@@ -112,13 +117,15 @@ export const AppPage = () => {
-
+
- {app.appType !== 'database' && (
-
-
-
- )}
+ {
+ app.appType !== 'database' && (
+
+
+
+ )
+ }
{/* ✅ ENVIRONMENT TAB */}
@@ -126,11 +133,13 @@ export const AppPage = () => {
{/* ✅ DOMAINS TAB */}
- {app.appType === 'web' && (
-
-
-
- )}
+ {
+ app.appType === 'web' && (
+
+
+
+ )
+ }
{/* ✅ DEPLOYMENTS TAB */}
@@ -150,20 +159,21 @@ export const AppPage = () => {
-
-
+
+
{/* Edit Modal */}
- setIsModalOpen(false)}
title="Edit App"
- fields={[
- { label: "App Name", name: "name", type: "text", defaultValue: app.name },
- { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" },
- ]}
+ fields={
+ [
+ { label: "App Name", name: "name", type: "text", defaultValue: app.name },
+ { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" },
+ ]}
onSubmit={(data) => handleUpdateApp(data as { name: string; description: string })}
/>
-
+
);
};
diff --git a/dash/src/features/applications/ComposeAppPage.tsx b/dash/src/features/applications/ComposeAppPage.tsx
new file mode 100644
index 0000000..17c2cf8
--- /dev/null
+++ b/dash/src/features/applications/ComposeAppPage.tsx
@@ -0,0 +1,158 @@
+import { FormModal } from "@/components/common/form-modal";
+import { FullScreenLoading } from "@/components/common";
+import { Button } from "@/components/ui/button";
+import { useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useApplication } from "@/hooks";
+import { TabsList, Tabs, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { AppInfo, GitProviderTab, EnvironmentVariables, LiveLogsViewer, Volumes } from "@/components/applications";
+import { ComposeStatus } from "@/components/applications/compose-status";
+import { ComposeAppSettings } from "@/components/applications/compose-app-settings";
+import { DeploymentsTab } from "@/components/deployments";
+
+
+export const ComposeAppPage = () => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState("info");
+
+ const params = useParams();
+ const navigate = useNavigate();
+
+ const appId = useMemo(() => Number(params.appId), [params.appId]);
+ const projectId = useMemo(() => Number(params.projectId), [params.projectId])
+
+ const {
+ app,
+ loading,
+ error,
+ latestCommit,
+ updateApp,
+ deleteApp,
+ refreshApp,
+ } = useApplication({
+ appId,
+ autoFetch: true,
+ projectId
+ });
+
+ const deleteAppHandler = async () => {
+ const success = await deleteApp();
+ if (success) {
+ navigate(-1);
+ }
+ };
+
+ const handleUpdateApp = async (appData: {
+ name: string;
+ description: string;
+ }) => {
+ const result = await updateApp(appData);
+ if (result) {
+ setIsModalOpen(false);
+ }
+ };
+
+ if (loading) return
;
+
+ if (error)
+ return (
+
+ );
+
+ if (!app) return null;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* App Info */}
+
+
+
+
+ Info
+ Git
+ Environment
+ Deployments
+ Logs
+ Settings
+
+
+
+ {/* ✅ INFO TAB */}
+
+
+
+
+
+
+
+
+ {/* ✅ ENVIRONMENT TAB */}
+
+
+
+
+ {/* ✅ DEPLOYMENTS TAB */}
+
+
+
+
+ {/* Stats tab removed as requested ("keep the stats if its possible to get the compose stats" -> we put compose stats in info/status card for now as overall stats. Detailed container stats are hard for multiple containers yet. User said 'keep only the necessary things') */}
+ {/* Actually user said "keep the stats if its possible to get the compose stats". But our backend currently doesn't provide resource stats for compose. */}
+ {/* I will hide the stats tab for now. */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Edit Modal */}
+
setIsModalOpen(false)}
+ title="Edit App"
+ fields={[
+ { label: "App Name", name: "name", type: "text", defaultValue: app.name },
+ { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" },
+ ]}
+ onSubmit={(data) => handleUpdateApp(data as { name: string; description: string })}
+ />
+
+ );
+};
diff --git a/dash/src/features/projects/components/AppTypeSelection.tsx b/dash/src/features/projects/components/AppTypeSelection.tsx
index 6a75f52..3cedd32 100644
--- a/dash/src/features/projects/components/AppTypeSelection.tsx
+++ b/dash/src/features/projects/components/AppTypeSelection.tsx
@@ -1,4 +1,4 @@
-import { Globe, Cog, Database } from "lucide-react";
+import { Globe, Cog, Database, Container } from "lucide-react";
import { Card } from "@/components/ui/card";
import type { AppType } from "@/types/app";
@@ -29,6 +29,13 @@ export function AppTypeSelection({ onSelect }: AppTypeSelectionProps) {
description: "Pre-configured databases and services deployed from official Docker images",
examples: "PostgreSQL, Redis, MySQL, MongoDB, RabbitMQ",
},
+ {
+ type: "compose" as AppType,
+ icon: Container,
+ title: "Docker Compose",
+ description: "Deploy complex multi-container applications using docker-compose",
+ examples: "Full stack apps, microservices",
+ },
];
return (
diff --git a/dash/src/features/projects/components/ComposeAppForm.tsx b/dash/src/features/projects/components/ComposeAppForm.tsx
new file mode 100644
index 0000000..7c38db8
--- /dev/null
+++ b/dash/src/features/projects/components/ComposeAppForm.tsx
@@ -0,0 +1,75 @@
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import type { CreateAppRequest } from "@/types/app";
+
+interface ComposeAppFormProps {
+ projectId: number;
+ onSubmit: (data: CreateAppRequest) => void;
+ onBack: () => void;
+}
+
+export function ComposeAppForm({ projectId, onSubmit, onBack }: ComposeAppFormProps) {
+ const [formData, setFormData] = useState({
+ name: "",
+ description: "",
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit({
+ projectId,
+ appType: "compose",
+ name: formData.name,
+ description: formData.description || undefined,
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/dash/src/features/projects/components/CreateAppModal.tsx b/dash/src/features/projects/components/CreateAppModal.tsx
index e02c7b1..4992567 100644
--- a/dash/src/features/projects/components/CreateAppModal.tsx
+++ b/dash/src/features/projects/components/CreateAppModal.tsx
@@ -9,6 +9,7 @@ import { AppTypeSelection } from "./AppTypeSelection";
import { WebAppForm } from "./WebAppForm";
import { ServiceForm } from "./ServiceForm";
import { DatabaseForm } from "./DatabaseForm";
+import { ComposeAppForm } from "./ComposeAppForm";
import type { AppType, CreateAppRequest } from "@/types/app";
interface CreateAppModalProps {
@@ -86,6 +87,14 @@ export function CreateAppModal({
onBack={handleBack}
/>
)}
+
+ {step === "configure" && selectedType === "compose" && (
+
+ )}
);
diff --git a/dash/src/types/app.ts b/dash/src/types/app.ts
index 9356591..a963bdf 100644
--- a/dash/src/types/app.ts
+++ b/dash/src/types/app.ts
@@ -1,4 +1,4 @@
-export type AppType = 'web' | 'service' | 'database';
+export type AppType = 'web' | 'service' | 'database' | 'compose';
export type RestartPolicy = 'no' | 'always' | 'on-failure' | 'unless-stopped';
diff --git a/server/api/handlers/applications/containerControl.go b/server/api/handlers/applications/containerControl.go
index c08212c..ec63ec1 100644
--- a/server/api/handlers/applications/containerControl.go
+++ b/server/api/handlers/applications/containerControl.go
@@ -1,11 +1,13 @@
package applications
import (
+ "fmt"
"net/http"
"strconv"
"github.com/corecollectives/mist/api/handlers"
"github.com/corecollectives/mist/api/middleware"
+ "github.com/corecollectives/mist/compose"
"github.com/corecollectives/mist/docker"
"github.com/corecollectives/mist/models"
)
@@ -44,10 +46,18 @@ func StopContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
- containerName := docker.GetContainerName(app.Name, appId)
+ // containerName declaration moved after check
+
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ err = compose.ComposeDown(path)
+ } else {
+ containerName := docker.GetContainerName(app.Name, appId)
+ err = docker.StopContainer(containerName)
+ }
- if err := docker.StopContainer(containerName); err != nil {
- handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to stop container", err.Error())
+ if err != nil {
+ handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to stop application", err.Error())
return
}
@@ -57,14 +67,20 @@ func StopContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ containerName := ""
+ if app.AppType != models.AppTypeCompose {
+ containerName = docker.GetContainerName(app.Name, appId)
+ }
+
models.LogUserAudit(userInfo.ID, "stop", "container", &appId, map[string]interface{}{
"app_name": app.Name,
"container_name": containerName,
+ "app_type": app.AppType,
})
handlers.SendResponse(w, http.StatusOK, true, map[string]any{
- "message": "Container stopped successfully",
- }, "Container stopped successfully", "")
+ "message": "Application stopped successfully",
+ }, "Application stopped successfully", "")
}
func StartContainerHandler(w http.ResponseWriter, r *http.Request) {
@@ -101,10 +117,22 @@ func StartContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
- containerName := docker.GetContainerName(app.Name, appId)
+ var startErr error
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ envVars, _ := models.GetEnvVariablesByAppID(appId)
+ envMap := make(map[string]string)
+ for _, e := range envVars {
+ envMap[e.Key] = e.Value
+ }
+ startErr = compose.ComposeUp(path, envMap, nil)
+ } else {
+ containerName := docker.GetContainerName(app.Name, appId)
+ startErr = docker.StartContainer(containerName)
+ }
- if err := docker.StartContainer(containerName); err != nil {
- handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to start container", err.Error())
+ if startErr != nil {
+ handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to start application", startErr.Error())
return
}
@@ -114,14 +142,20 @@ func StartContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ containerName := ""
+ if app.AppType != models.AppTypeCompose {
+ containerName = docker.GetContainerName(app.Name, appId)
+ }
+
models.LogUserAudit(userInfo.ID, "start", "container", &appId, map[string]interface{}{
"app_name": app.Name,
"container_name": containerName,
+ "app_type": app.AppType,
})
handlers.SendResponse(w, http.StatusOK, true, map[string]any{
- "message": "Container started successfully",
- }, "Container started successfully", "")
+ "message": "Application started successfully",
+ }, "Application started successfully", "")
}
func RestartContainerHandler(w http.ResponseWriter, r *http.Request) {
@@ -158,10 +192,17 @@ func RestartContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
- containerName := docker.GetContainerName(app.Name, appId)
+ var restartErr error
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ restartErr = compose.ComposeRestart(path)
+ } else {
+ containerName := docker.GetContainerName(app.Name, appId)
+ restartErr = docker.RestartContainer(containerName)
+ }
- if err := docker.RestartContainer(containerName); err != nil {
- handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to restart container", err.Error())
+ if restartErr != nil {
+ handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to restart application", restartErr.Error())
return
}
@@ -171,14 +212,20 @@ func RestartContainerHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ containerName := ""
+ if app.AppType != models.AppTypeCompose {
+ containerName = docker.GetContainerName(app.Name, appId)
+ }
+
models.LogUserAudit(userInfo.ID, "restart", "container", &appId, map[string]interface{}{
"app_name": app.Name,
"container_name": containerName,
+ "app_type": app.AppType,
})
handlers.SendResponse(w, http.StatusOK, true, map[string]any{
- "message": "Container restarted successfully",
- }, "Container restarted successfully", "")
+ "message": "Application restarted successfully",
+ }, "Application restarted successfully", "")
}
func GetContainerStatusHandler(w http.ResponseWriter, r *http.Request) {
@@ -215,8 +262,26 @@ func GetContainerStatusHandler(w http.ResponseWriter, r *http.Request) {
return
}
- containerName := docker.GetContainerName(app.Name, appId)
+ // containerName declaration moved
+
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ status, err := compose.GetComposeStatus(path, app.Name)
+ if err != nil {
+ // If error, maybe it's just not running or folder doesn't exist
+ handlers.SendResponse(w, http.StatusOK, true, map[string]interface{}{
+ "name": app.Name,
+ "status": "Unknown",
+ "state": "stopped",
+ "error": err.Error(),
+ }, "Compose status retrieval failed (app might be stopped)", "")
+ return
+ }
+ handlers.SendResponse(w, http.StatusOK, true, status, "Compose status retrieved successfully", "")
+ return
+ }
+ containerName := docker.GetContainerName(app.Name, appId)
status, err := docker.GetContainerStatus(containerName)
if err != nil {
handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to get container status", err.Error())
@@ -268,6 +333,19 @@ func GetContainerLogsHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ logs, err := compose.GetComposeLogs(path, tail)
+ if err != nil {
+ handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to get compose logs", err.Error())
+ return
+ }
+ handlers.SendResponse(w, http.StatusOK, true, map[string]any{
+ "logs": logs,
+ }, "Compose logs retrieved successfully", "")
+ return
+ }
+
containerName := docker.GetContainerName(app.Name, appId)
logs, err := docker.GetContainerLogs(containerName, tail)
diff --git a/server/api/handlers/applications/create.go b/server/api/handlers/applications/create.go
index 32dff2f..a92e87d 100644
--- a/server/api/handlers/applications/create.go
+++ b/server/api/handlers/applications/create.go
@@ -44,8 +44,8 @@ func CreateApplication(w http.ResponseWriter, r *http.Request) {
req.AppType = "web"
}
- if req.AppType != "web" && req.AppType != "service" && req.AppType != "database" {
- handlers.SendResponse(w, http.StatusBadRequest, false, nil, "Invalid app type", "Must be 'web', 'service', or 'database'")
+ if req.AppType != "web" && req.AppType != "service" && req.AppType != "database" && req.AppType != "compose" {
+ handlers.SendResponse(w, http.StatusBadRequest, false, nil, "Invalid app type", "Must be 'web', 'service', 'database', or 'compose'")
return
}
@@ -116,17 +116,13 @@ func CreateApplication(w http.ResponseWriter, r *http.Request) {
return
}
- // Auto-generate domain if wildcard domain is configured (only for web apps)
if req.AppType == "web" {
project, err := models.GetProjectByID(req.ProjectID)
if err == nil {
autoDomain, err := models.GenerateAutoDomain(project.Name, app.Name)
if err == nil && autoDomain != "" {
- // Create the auto-generated domain
_, err = models.CreateDomain(app.ID, autoDomain)
if err != nil {
- // Log the error but don't fail the app creation
- // The user can manually add domains later
}
}
}
diff --git a/server/api/handlers/applications/getLatestCommit.go b/server/api/handlers/applications/getLatestCommit.go
index 057300b..5ee2c22 100644
--- a/server/api/handlers/applications/getLatestCommit.go
+++ b/server/api/handlers/applications/getLatestCommit.go
@@ -6,7 +6,7 @@ import (
"github.com/corecollectives/mist/api/handlers"
"github.com/corecollectives/mist/api/middleware"
- "github.com/corecollectives/mist/github"
+ "github.com/corecollectives/mist/git"
"github.com/corecollectives/mist/models"
)
@@ -56,7 +56,7 @@ func GetLatestCommit(w http.ResponseWriter, r *http.Request) {
return
}
- commit, err := github.GetLatestCommit(req.AppID, userInfo.ID)
+ commit, err := git.GetLatestCommit(req.AppID, userInfo.ID)
if err != nil {
handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to get latest commit", err.Error())
return
diff --git a/server/api/handlers/deployments/AddDeployHandler.go b/server/api/handlers/deployments/AddDeployHandler.go
index 315881f..ef6ae33 100644
--- a/server/api/handlers/deployments/AddDeployHandler.go
+++ b/server/api/handlers/deployments/AddDeployHandler.go
@@ -6,7 +6,7 @@ import (
"github.com/corecollectives/mist/api/handlers"
"github.com/corecollectives/mist/api/middleware"
- "github.com/corecollectives/mist/github"
+ "github.com/corecollectives/mist/git"
"github.com/corecollectives/mist/models"
"github.com/corecollectives/mist/queue"
"github.com/rs/zerolog/log"
@@ -39,7 +39,7 @@ func AddDeployHandler(w http.ResponseWriter, r *http.Request) {
if app.AppType != models.AppTypeDatabase {
userId := int64(user.ID)
- commit, err := github.GetLatestCommit(int64(req.AppId), userId)
+ commit, err := git.GetLatestCommit(int64(req.AppId), userId)
if err != nil {
log.Error().Err(err).Int("app_id", req.AppId).Msg("Error getting latest commit")
handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "failed to get latest commit", err.Error())
diff --git a/server/compose/compose_deployer.go b/server/compose/compose_deployer.go
new file mode 100644
index 0000000..a1cafc7
--- /dev/null
+++ b/server/compose/compose_deployer.go
@@ -0,0 +1,87 @@
+package compose
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/corecollectives/mist/docker"
+ "github.com/corecollectives/mist/models"
+ "github.com/corecollectives/mist/utils"
+ "gorm.io/gorm"
+)
+
+func DeployComposeApp(ctx context.Context, dep *models.Deployment, app *models.App, appContextPath string, db *gorm.DB, logfile *os.File, logger *utils.DeploymentLogger) error {
+ logger.Info("Starting compose deployment process")
+
+ logger.Info("Getting port, domains, and environment variables")
+ port, domains, envVars, err := docker.GetDeploymentConfig(dep.ID, app, db)
+ if err != nil {
+ logger.Error(err, "Failed to get deployment configuration")
+ dep.Status = models.DeploymentStatusFailed
+ dep.Stage = "failed"
+ dep.Progress = 0
+ errMsg := fmt.Sprintf("Failed to get deployment config: %v", err)
+ dep.ErrorMessage = &errMsg
+ docker.UpdateDeployment(dep, db)
+ models.UpdateDeploymentStatus(dep.ID, "failed", "failed", 0, &errMsg)
+ return fmt.Errorf("get deployment config failed: %w", err)
+ }
+
+ logger.InfoWithFields("Configuration loaded", map[string]interface{}{
+ "domains": domains,
+ "port": port,
+ "envVars": len(envVars),
+ "appType": app.AppType,
+ })
+
+ dep.Status = models.DeploymentStatusDeploying
+ dep.Stage = "deploying"
+ dep.Progress = 50
+ docker.UpdateDeployment(dep, db)
+ models.UpdateDeploymentStatus(dep.ID, "deploying", "deploying", 50, nil)
+
+ logger.Info("Running docker compose up")
+
+ err = ComposeUp(appContextPath, envVars, logfile)
+ if err != nil {
+ if ctx.Err() == context.Canceled {
+ logger.Info("Compose deployment canceled")
+ return ctx.Err()
+ }
+ logger.Error(err, "Docker compose up failed")
+ dep.Status = models.DeploymentStatusFailed
+ dep.Stage = "failed"
+ dep.Progress = 0
+ errMsg := fmt.Sprintf("Compose up failed: %v", err)
+ dep.ErrorMessage = &errMsg
+ docker.UpdateDeployment(dep, db)
+ models.UpdateDeploymentStatus(dep.ID, "failed", "failed", 0, &errMsg)
+ docker.UpdateAppStatus(app.ID, "error", db)
+ return fmt.Errorf("compose up failed: %w", err)
+ }
+
+ logger.Info("Docker compose up completed successfully")
+
+ dep.Status = models.DeploymentStatusSuccess
+ dep.Stage = "success"
+ dep.Progress = 100
+ now := time.Now()
+ dep.FinishedAt = &now
+ docker.UpdateDeployment(dep, db)
+ models.UpdateDeploymentStatus(dep.ID, "success", "success", 100, nil)
+
+ logger.Info("Updating app status to running")
+ err = docker.UpdateAppStatus(app.ID, "running", db)
+ if err != nil {
+ logger.Error(err, "Failed to update app status (non-fatal)")
+ }
+
+ logger.InfoWithFields("Deployment succeeded", map[string]interface{}{
+ "deployment_id": dep.ID,
+ "app_status": "running",
+ })
+
+ return nil
+}
diff --git a/server/compose/down.go b/server/compose/down.go
new file mode 100644
index 0000000..00617c4
--- /dev/null
+++ b/server/compose/down.go
@@ -0,0 +1,14 @@
+package compose
+
+import (
+ "os"
+ "os/exec"
+)
+
+func ComposeDown(appContextPath string) error {
+ cmd := exec.Command("docker", "compose", "down")
+ cmd.Dir = appContextPath
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
diff --git a/server/compose/logs.go b/server/compose/logs.go
new file mode 100644
index 0000000..b971172
--- /dev/null
+++ b/server/compose/logs.go
@@ -0,0 +1,19 @@
+package compose
+
+import (
+ "fmt"
+ "os/exec"
+)
+
+func GetComposeLogs(appContextPath string, tail int) (string, error) {
+ tailStr := fmt.Sprintf("%d", tail)
+ cmd := exec.Command("docker", "compose", "logs", "--tail", tailStr)
+ cmd.Dir = appContextPath
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("failed to get compose logs: %w", err)
+ }
+
+ return string(output), nil
+}
diff --git a/server/compose/restart.go b/server/compose/restart.go
new file mode 100644
index 0000000..f10ca5c
--- /dev/null
+++ b/server/compose/restart.go
@@ -0,0 +1,14 @@
+package compose
+
+import (
+ "os"
+ "os/exec"
+)
+
+func ComposeRestart(appContextPath string) error {
+ cmd := exec.Command("docker", "compose", "restart")
+ cmd.Dir = appContextPath
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
diff --git a/server/compose/status.go b/server/compose/status.go
new file mode 100644
index 0000000..132d66b
--- /dev/null
+++ b/server/compose/status.go
@@ -0,0 +1,106 @@
+package compose
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+type ComposeStatus struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ State string `json:"state"`
+ Uptime string `json:"uptime"`
+ Services []ComposeService `json:"services"`
+}
+
+type ComposeService struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+ Status string `json:"status"`
+}
+
+type DockerComposePSOutput struct {
+ Name string `json:"Name"`
+ Service string `json:"Service"`
+ State string `json:"State"`
+ Status string `json:"Status"`
+ Created int64 `json:"Created"`
+}
+
+func GetComposeStatus(appContextPath string, appName string) (*ComposeStatus, error) {
+ cmd := exec.Command("docker", "compose", "ps", "--format", "json")
+ cmd.Dir = appContextPath
+ cmd.Stderr = os.Stderr
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to run docker compose ps: %w", err)
+ }
+
+ var services []DockerComposePSOutput
+
+ if len(output) == 0 {
+ return &ComposeStatus{
+ Name: appName,
+ Status: "Not Created",
+ State: "stopped",
+ Uptime: "N/A",
+ Services: []ComposeService{},
+ }, nil
+ }
+
+ if err := json.Unmarshal(output, &services); err != nil {
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ var svc DockerComposePSOutput
+ if err := json.Unmarshal([]byte(line), &svc); err == nil {
+ services = append(services, svc)
+ }
+ }
+ }
+
+ if len(services) == 0 {
+ return &ComposeStatus{
+ Name: appName,
+ Status: "Stopped",
+ State: "stopped",
+ Uptime: "N/A",
+ Services: []ComposeService{},
+ }, nil
+ }
+
+ runningCount := 0
+ formattedServices := []ComposeService{}
+
+ for _, s := range services {
+ formattedServices = append(formattedServices, ComposeService{
+ Name: s.Service, // Or s.Name
+ State: s.State,
+ Status: s.Status,
+ })
+ if s.State == "running" {
+ runningCount++
+ }
+ }
+
+ overallState := "stopped"
+ if runningCount == len(services) {
+ overallState = "running"
+ } else if runningCount > 0 {
+ overallState = "partial"
+ }
+
+ return &ComposeStatus{
+ Name: appName,
+ Status: fmt.Sprintf("%d/%d Running", runningCount, len(services)),
+ State: overallState,
+ Uptime: time.Now().Format(time.RFC3339),
+ Services: formattedServices,
+ }, nil
+}
diff --git a/server/compose/up.go b/server/compose/up.go
new file mode 100644
index 0000000..8864c85
--- /dev/null
+++ b/server/compose/up.go
@@ -0,0 +1,20 @@
+package compose
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+func ComposeUp(appContextPath string, env map[string]string, logFile *os.File) error {
+ cmd := exec.Command("docker", "compose", "up", "-d")
+ var envArray []string
+ for k, v := range env {
+ envArray = append(envArray, fmt.Sprintf("%s=%s", k, v))
+ }
+ cmd.Env = envArray
+ cmd.Dir = appContextPath
+ cmd.Stdout = logFile
+ cmd.Stderr = logFile
+ return cmd.Run()
+}
diff --git a/server/db/migrations.go b/server/db/migrations.go
index 89557aa..88385c2 100644
--- a/server/db/migrations.go
+++ b/server/db/migrations.go
@@ -144,12 +144,108 @@ func migrateDbInternal(dbInstance *gorm.DB) error {
}
var Version = models.SystemSettingEntry{
Key: "version",
- Value: "1.0.4",
+ Value: "1.0.5",
}
+ templates := []models.ServiceTemplate{
+ {
+ Name: "postgres",
+ DisplayName: "PostgreSQL 16",
+ Category: "database",
+ Description: ptr("PostgreSQL is a powerful, open source object-relational database system"),
+ DockerImage: "postgres:16-alpine",
+ DefaultPort: 5432,
+ DefaultEnvVars: ptr(`{"POSTGRES_PASSWORD":"GENERATE","POSTGRES_DB":"myapp","POSTGRES_USER":"postgres"}`),
+ DefaultVolumePath: ptr("/var/lib/postgresql/data"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ {
+ Name: "redis",
+ DisplayName: "Redis 7",
+ Category: "cache",
+ Description: ptr("Redis is an in-memory data structure store, used as a database, cache, and message broker"),
+ DockerImage: "redis:7-alpine",
+ DefaultPort: 6379,
+ DefaultEnvVars: ptr(`{}`),
+ DefaultVolumePath: ptr("/data"),
+ RecommendedMemory: ptrInt(256),
+ MinMemory: ptrInt(128),
+ },
+ {
+ Name: "mysql",
+ DisplayName: "MySQL 8",
+ Category: "database",
+ Description: ptr("MySQL is the world's most popular open source database"),
+ DockerImage: "mysql:8",
+ DefaultPort: 3306,
+ DefaultEnvVars: ptr(`{"MYSQL_ROOT_PASSWORD":"GENERATE","MYSQL_DATABASE":"myapp"}`),
+ DefaultVolumePath: ptr("/var/lib/mysql"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ {
+ Name: "mariadb",
+ DisplayName: "MariaDB 11",
+ Category: "database",
+ Description: ptr("MariaDB is a community-developed fork of MySQL"),
+ DockerImage: "mariadb:11",
+ DefaultPort: 3306,
+ DefaultEnvVars: ptr(`{"MARIADB_ROOT_PASSWORD":"GENERATE","MARIADB_DATABASE":"myapp"}`),
+ DefaultVolumePath: ptr("/var/lib/mysql"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ {
+ Name: "mongodb",
+ DisplayName: "MongoDB 7",
+ Category: "database",
+ Description: ptr("MongoDB is a source-available cross-platform document-oriented database"),
+ DockerImage: "mongo:7",
+ DefaultPort: 27017,
+ DefaultEnvVars: ptr(`{"MONGO_INITDB_ROOT_USERNAME":"admin","MONGO_INITDB_ROOT_PASSWORD":"GENERATE"}`),
+ DefaultVolumePath: ptr("/data/db"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ {
+ Name: "rabbitmq",
+ DisplayName: "RabbitMQ 3",
+ Category: "queue",
+ Description: ptr("RabbitMQ is a reliable and mature messaging and streaming broker"),
+ DockerImage: "rabbitmq:3-management",
+ DefaultPort: 5672,
+ DefaultEnvVars: ptr(`{"RABBITMQ_DEFAULT_USER":"admin","RABBITMQ_DEFAULT_PASS":"GENERATE"}`),
+ DefaultVolumePath: ptr("/var/lib/rabbitmq"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ {
+ Name: "minio",
+ DisplayName: "MinIO",
+ Category: "storage",
+ Description: ptr("MinIO is a high-performance, S3 compatible object store"),
+ DockerImage: "minio/minio",
+ DefaultPort: 9000,
+ DefaultEnvVars: ptr(`{"MINIO_ROOT_USER":"admin","MINIO_ROOT_PASSWORD":"GENERATE"}`),
+ DefaultVolumePath: ptr("/data"),
+ RecommendedMemory: ptrInt(512),
+ MinMemory: ptrInt(256),
+ },
+ }
+
+ dbInstance.Create(&templates)
dbInstance.Clauses(clause.Insert{Modifier: "OR IGNORE"}).Create(&wildCardDomain)
dbInstance.Clauses(clause.Insert{Modifier: "OR IGNORE"}).Create(&MistAppName)
dbInstance.Clauses(clause.Insert{Modifier: "OR REPLACE"}).Create(&Version)
return nil
}
+
+func ptr(s string) *string {
+ return &s
+}
+
+func ptrInt(i int) *int {
+ return &i
+}
diff --git a/server/docker/deployer.go b/server/docker/deployer.go
index d892c8d..1242d4e 100644
--- a/server/docker/deployer.go
+++ b/server/docker/deployer.go
@@ -245,14 +245,15 @@ func GetDeploymentConfig(deploymentID int64, app *models.App, db *gorm.DB) (int,
return 0, nil, nil, fmt.Errorf("get app ID failed: %w", err)
}
- var port int
+ var port *int
err = db.Model(&models.App{}).Select("port").Where("id = ?", appID).Scan(&port).Error
if err != nil {
return 0, nil, nil, fmt.Errorf("get port failed: %w", err)
}
- if port == 0 {
- port = 3000
+ finalPort := 3000
+ if port != nil {
+ finalPort = *port
}
domains, err := models.GetDomainsByAppID(appID)
@@ -288,7 +289,7 @@ func GetDeploymentConfig(deploymentID int64, app *models.App, db *gorm.DB) (int,
envMap[env.Key] = env.Value
}
- return port, domainStrings, envMap, nil
+ return finalPort, domainStrings, envMap, nil
}
// package docker
diff --git a/server/docker/deployerMain.go b/server/docker/deployerMain.go
index 2a854bb..7e0434b 100644
--- a/server/docker/deployerMain.go
+++ b/server/docker/deployerMain.go
@@ -41,7 +41,7 @@ func DeployerMain(ctx context.Context, Id int64, db *gorm.DB, logFile *os.File,
"app_type": app.AppType,
})
- appContextPath := filepath.Join(constants.Constants["RootPath"].(string), fmt.Sprintf("projects/%d/apps/%s", app.ProjectID, app.Name))
+ appContextPath := filepath.Join(constants.Constants["RootPath"].(string), fmt.Sprintf("projects/%d/apps/%s/%s", app.ProjectID, app.Name, app.RootDirectory))
imageTag := dep.CommitHash
containerName := fmt.Sprintf("app-%d", app.ID)
diff --git a/server/git/clone.go b/server/git/clone.go
index 1f9865f..41d3f3f 100644
--- a/server/git/clone.go
+++ b/server/git/clone.go
@@ -4,13 +4,16 @@ import (
"context"
"fmt"
"os"
+ "time"
+ "github.com/corecollectives/mist/github"
+ "github.com/corecollectives/mist/models"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/rs/zerolog/log"
)
-func CloneRepo(ctx context.Context, url string, branch string, logFile *os.File, path string) error {
+func CloneGitRepo(ctx context.Context, url string, branch string, logFile *os.File, path string) error {
_, err := fmt.Fprintf(logFile, "[GIT]: Cloning into %s\n", path)
if err != nil {
log.Warn().Msg("error logging into log file")
@@ -30,3 +33,92 @@ func CloneRepo(ctx context.Context, url string, branch string, logFile *os.File,
return nil
}
+
+func CloneRepo(ctx context.Context, appId int64, logFile *os.File) error {
+ log.Info().Int64("app_id", appId).Msg("Starting repository clone")
+
+ userId, err := models.GetUserIDByAppID(appId)
+ if err != nil {
+ return fmt.Errorf("failed to get user id by app id: %w", err)
+ }
+
+ cloneURL, accessToken, shouldMigrate, err := models.GetAppCloneURL(appId, *userId)
+ if err != nil {
+ return fmt.Errorf("failed to get clone URL: %w", err)
+ }
+
+ _, _, branch, _, projectId, name, err := models.GetAppGitInfo(appId)
+ if err != nil {
+ return fmt.Errorf("failed to fetch app: %w", err)
+ }
+
+ if shouldMigrate {
+ log.Info().Int64("app_id", appId).Msg("Migrating legacy app to new git format")
+ // for legacy GitHub apps, we don't have a git_provider_id
+ // we just update the git_clone_url
+ err = models.UpdateAppGitCloneURL(appId, cloneURL, nil)
+ if err != nil {
+ log.Warn().Err(err).Int64("app_id", appId).Msg("Failed to migrate app git info, continuing anyway")
+ }
+ }
+
+ // construct authenticated clone URL if we have an access token
+ repoURL := cloneURL
+ if accessToken != "" {
+ // insert token into the URL
+ // for GitHub: https://x-access-token:TOKEN@github.com/user/repo.git
+ // for GitLab: https://oauth2:TOKEN@gitlab.com/user/repo.git
+ // for Bitbucket: https://x-token-auth:TOKEN@bitbucket.org/user/repo.git
+ // for Gitea: https://TOKEN@gitea.com/user/repo.git
+
+ // simple approach: insert token after https://
+ // if len(cloneURL) > 8 && cloneURL[:8] == "https://" {
+ // repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, cloneURL[8:])
+ // }
+ repoURL = github.CreateCloneUrl(accessToken, repoURL)
+ }
+
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", projectId, name)
+
+ if _, err := os.Stat(path + "/.git"); err == nil {
+ log.Info().Str("path", path).Msg("Repository already exists, removing directory")
+
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("failed to remove existing repository: %w", err)
+
+ }
+ }
+
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ log.Info().Str("clone_url", cloneURL).Str("branch", branch).Str("path", path).Msg("Cloning repository")
+
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
+ defer cancel()
+
+ // old command implementation
+ //
+ //
+ // cmd := exec.CommandContext(ctx, "git", "clone", "--branch", branch, repoURL, path)
+ // output, err := cmd.CombinedOutput()
+ // lines := strings.Split(string(output), "\n")
+ // for _, line := range lines {
+ // if len(line) > 0 {
+ // fmt.Fprintf(logFile, "[GIT] %s\n", line)
+ // }
+ // }
+
+ // new git sdk implementation
+ err = CloneGitRepo(ctx, repoURL, branch, logFile, path)
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ return fmt.Errorf("git clone timed out after 10 minutes")
+ }
+ return fmt.Errorf("error cloning repository: %v\n", err)
+ }
+
+ log.Info().Int64("app_id", appId).Str("path", path).Msg("Repository cloned successfully")
+ return nil
+}
diff --git a/server/git/commit.go b/server/git/commit.go
new file mode 100644
index 0000000..efd977e
--- /dev/null
+++ b/server/git/commit.go
@@ -0,0 +1,101 @@
+package git
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/corecollectives/mist/constants"
+ "github.com/corecollectives/mist/github"
+ "github.com/corecollectives/mist/models"
+)
+
+func GetLatestCommit(appID int64, userID int64) (*models.LatestCommit, error) {
+ gitProvider, err := models.GetGitProviderNameByAppID(appID)
+ if err != nil {
+ fmt.Printf("error getting provider name by app id", err.Error())
+ return nil, err
+ }
+
+ gitCloneUrl, err := models.GetCloneUrlfromAppID(appID)
+ if err != nil {
+ return nil, err
+ }
+ if gitProvider == nil && gitCloneUrl != nil {
+ fmt.Printf("no provider found")
+ return latestRemoteCommit(*gitCloneUrl)
+ } else if gitProvider == nil && gitCloneUrl == nil {
+ return nil, fmt.Errorf("git url or provider not given")
+ }
+
+ if *gitProvider == models.GitProviderGitHub {
+ fmt.Printf("github provider found")
+ return github.GetLatestCommit(appID, userID)
+ }
+ return nil, fmt.Errorf("failed to get latest commit")
+
+}
+
+func latestRemoteCommit(repoURL string) (*models.LatestCommit, error) {
+
+ tmpPath := filepath.Join(constants.Constants["RootPath"].(string), "git-meta")
+ if err := os.MkdirAll(tmpPath, 0o755); err != nil {
+ return nil, err
+ }
+ tmpDir, err := os.MkdirTemp(tmpPath, "repo-")
+ if err != nil {
+ return nil, err
+ }
+ defer os.RemoveAll(tmpDir)
+ cmd := exec.Command("git", "init")
+ cmd.Dir = tmpDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return nil, fmt.Errorf("git init failed: %w: %s", err, out)
+ }
+
+ cmd = exec.Command("git", "remote", "add", "origin", repoURL)
+ cmd.Dir = tmpDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return nil, fmt.Errorf("git remote add failed: %w: %s", err, out)
+ }
+ cmd = exec.Command(
+ "git",
+ "fetch",
+ "--depth=1",
+ "--filter=blob:none",
+ "origin",
+ "HEAD",
+ )
+ cmd.Dir = tmpDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return nil, fmt.Errorf("git fetch failed: %w: %s", err, out)
+ }
+
+ cmd = exec.Command(
+ "git",
+ "show",
+ "-s",
+ "--format=%H%n%an%n%s",
+ "FETCH_HEAD",
+ )
+ cmd.Dir = tmpDir
+
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git show failed: %w", err)
+ }
+
+ parts := strings.SplitN(strings.TrimSpace(string(out)), "\n", 3)
+ if len(parts) < 3 {
+ return nil, fmt.Errorf("unexpected git show output")
+ }
+
+ return &models.LatestCommit{
+ SHA: parts[0],
+ Author: parts[1],
+ Message: parts[2],
+ URL: "",
+ }, nil
+}
diff --git a/server/git/types.go b/server/git/types.go
new file mode 100644
index 0000000..73a3b68
--- /dev/null
+++ b/server/git/types.go
@@ -0,0 +1,8 @@
+package git
+
+type LatestCommit struct {
+ SHA string `json:"sha"`
+ Message string `json:"message"`
+ URL string `json:"html_url"`
+ Author string `json:"author"`
+}
diff --git a/server/github/cloneRepo.go b/server/github/cloneRepo.go
deleted file mode 100644
index 19f0019..0000000
--- a/server/github/cloneRepo.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package github
-
-import (
- "context"
- "fmt"
- "os"
- "time"
-
- "github.com/corecollectives/mist/git"
- "github.com/corecollectives/mist/models"
- "github.com/rs/zerolog/log"
-)
-
-func CloneRepo(ctx context.Context, appId int64, logFile *os.File) error {
- log.Info().Int64("app_id", appId).Msg("Starting repository clone")
-
- userId, err := models.GetUserIDByAppID(appId)
- if err != nil {
- return fmt.Errorf("failed to get user id by app id: %w", err)
- }
-
- cloneURL, accessToken, shouldMigrate, err := models.GetAppCloneURL(appId, *userId)
- if err != nil {
- return fmt.Errorf("failed to get clone URL: %w", err)
- }
-
- _, _, branch, _, projectId, name, err := models.GetAppGitInfo(appId)
- if err != nil {
- return fmt.Errorf("failed to fetch app: %w", err)
- }
-
- if shouldMigrate {
- log.Info().Int64("app_id", appId).Msg("Migrating legacy app to new git format")
- // for legacy GitHub apps, we don't have a git_provider_id
- // we just update the git_clone_url
- err = models.UpdateAppGitCloneURL(appId, cloneURL, nil)
- if err != nil {
- log.Warn().Err(err).Int64("app_id", appId).Msg("Failed to migrate app git info, continuing anyway")
- }
- }
-
- // construct authenticated clone URL if we have an access token
- repoURL := cloneURL
- if accessToken != "" {
- // insert token into the URL
- // for GitHub: https://x-access-token:TOKEN@github.com/user/repo.git
- // for GitLab: https://oauth2:TOKEN@gitlab.com/user/repo.git
- // for Bitbucket: https://x-token-auth:TOKEN@bitbucket.org/user/repo.git
- // for Gitea: https://TOKEN@gitea.com/user/repo.git
-
- // simple approach: insert token after https://
- if len(cloneURL) > 8 && cloneURL[:8] == "https://" {
- repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, cloneURL[8:])
- }
- }
-
- path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", projectId, name)
-
- if _, err := os.Stat(path + "/.git"); err == nil {
- log.Info().Str("path", path).Msg("Repository already exists, removing directory")
-
- if err := os.RemoveAll(path); err != nil {
- return fmt.Errorf("failed to remove existing repository: %w", err)
-
- }
- }
-
- if err := os.MkdirAll(path, 0o755); err != nil {
- return fmt.Errorf("failed to create directory: %w", err)
- }
-
- log.Info().Str("clone_url", cloneURL).Str("branch", branch).Str("path", path).Msg("Cloning repository")
-
- ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
- defer cancel()
-
- // old command implementation
- //
- //
- // cmd := exec.CommandContext(ctx, "git", "clone", "--branch", branch, repoURL, path)
- // output, err := cmd.CombinedOutput()
- // lines := strings.Split(string(output), "\n")
- // for _, line := range lines {
- // if len(line) > 0 {
- // fmt.Fprintf(logFile, "[GIT] %s\n", line)
- // }
- // }
-
- // new git sdk implementation
- err = git.CloneRepo(ctx, repoURL, branch, logFile, path)
- if err != nil {
- if ctx.Err() == context.DeadlineExceeded {
- return fmt.Errorf("git clone timed out after 10 minutes")
- }
- return fmt.Errorf("error cloning repository: %v\n", err)
- }
-
- log.Info().Int64("app_id", appId).Str("path", path).Msg("Repository cloned successfully")
- return nil
-}
diff --git a/server/github/getLatestCommit.go b/server/github/getLatestCommit.go
index ccd631d..65f2b66 100644
--- a/server/github/getLatestCommit.go
+++ b/server/github/getLatestCommit.go
@@ -17,7 +17,7 @@ import (
"github.com/corecollectives/mist/models"
)
-func GetLatestCommit(appID, userID int64) (*LatestCommit, error) {
+func GetLatestCommit(appID, userID int64) (*models.LatestCommit, error) {
repoName, branch, err := models.GetAppRepoAndBranch(appID)
if err != nil {
return nil, fmt.Errorf("failed to fetch app repo: %w", err)
@@ -74,7 +74,7 @@ func GetLatestCommit(appID, userID int64) (*LatestCommit, error) {
return nil, fmt.Errorf("failed to decode GitHub response: %w", err)
}
- return &LatestCommit{
+ return &models.LatestCommit{
SHA: data.SHA,
Message: data.Commit.Message,
URL: data.HTMLURL,
diff --git a/server/github/repo.go b/server/github/repo.go
new file mode 100644
index 0000000..45c91cf
--- /dev/null
+++ b/server/github/repo.go
@@ -0,0 +1,10 @@
+package github
+
+import "fmt"
+
+func CreateCloneUrl(accessToken string, repoURL string) string {
+ if len(repoURL) > 8 && repoURL[:8] == "https://" {
+ repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, repoURL[8:])
+ }
+ return repoURL
+}
diff --git a/server/models/app.go b/server/models/app.go
index 1b55a21..8e9038a 100644
--- a/server/models/app.go
+++ b/server/models/app.go
@@ -25,6 +25,7 @@ const (
AppTypeWeb AppType = "web"
AppTypeService AppType = "service"
AppTypeDatabase AppType = "database"
+ AppTypeCompose AppType = "compose"
RestartPolicyNo RestartPolicy = "no"
RestartPolicyAlways RestartPolicy = "always"
@@ -277,3 +278,47 @@ func GetAppCloneURL(appID int64, userID int64) (string, string, bool, error) {
return "", "", false, fmt.Errorf("app has no git repository or clone URL configured")
}
+
+func GetCloneUrlfromAppID(appID int64) (*string, error) {
+ var URL string
+ if err := db.Model(&App{}).Select("git_clone_url").Where("id = ?", appID).Take(&URL).Error; err != nil {
+ fmt.Printf(err.Error(), "eror getting clone git_clone_url")
+ return nil, err
+ }
+ return &URL, nil
+}
+
+func GetGitProviderNameByAppID(
+ appID int64,
+) (*GitProviderType, error) {
+
+ var app struct {
+ GitProviderID *int64
+ }
+
+ if err := db.
+ Model(&App{}).
+ Select("git_provider_id").
+ Where("id = ?", appID).
+ Take(&app).Error; err != nil {
+ return nil, err
+ }
+
+ if app.GitProviderID == nil {
+ return nil, nil
+ }
+
+ var result struct {
+ Provider GitProviderType `gorm:"column:provider"`
+ }
+
+ if err := db.
+ Model(&GitProvider{}).
+ Select("provider").
+ Where("id = ?", *app.GitProviderID).
+ Take(&result).Error; err != nil {
+ return nil, err
+ }
+
+ return &result.Provider, nil
+}
diff --git a/server/models/auditLog.go b/server/models/auditLog.go
index 8850a80..0fec4c5 100644
--- a/server/models/auditLog.go
+++ b/server/models/auditLog.go
@@ -10,8 +10,8 @@ import (
type AuditLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID *int64 `gorm:"index" json:"user_id"`
- Username *string `gorm:"-" json:"username"`
- Email *string `gorm:"-" json:"email"`
+ Username *string `json:"username"`
+ Email *string `json:"email"`
Action string `gorm:"not null" json:"action"`
ResourceType string `gorm:"not null" json:"resourceType"`
ResourceID *int64 `json:"resourceId"`
diff --git a/server/models/misc.go b/server/models/misc.go
new file mode 100644
index 0000000..725a0c8
--- /dev/null
+++ b/server/models/misc.go
@@ -0,0 +1,8 @@
+package models
+
+type LatestCommit struct {
+ SHA string `json:"sha"`
+ Message string `json:"message"`
+ URL string `json:"html_url"`
+ Author string `json:"author"`
+}
diff --git a/server/models/services.go b/server/models/services.go
new file mode 100644
index 0000000..2640e7f
--- /dev/null
+++ b/server/models/services.go
@@ -0,0 +1 @@
+package models
diff --git a/server/queue/handleWork.go b/server/queue/handleWork.go
index 76ff2ab..e9b1940 100644
--- a/server/queue/handleWork.go
+++ b/server/queue/handleWork.go
@@ -5,9 +5,10 @@ import (
"fmt"
"sync"
+ "github.com/corecollectives/mist/compose"
"github.com/corecollectives/mist/docker"
"github.com/corecollectives/mist/fs"
- "github.com/corecollectives/mist/github"
+ "github.com/corecollectives/mist/git"
"github.com/corecollectives/mist/models"
"github.com/corecollectives/mist/utils"
"gorm.io/gorm"
@@ -85,7 +86,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) {
logger.Info("Cloning repository")
models.UpdateDeploymentStatus(id, "cloning", "cloning", 20, nil)
- err = github.CloneRepo(ctx, appId, logFile)
+ err = git.CloneRepo(ctx, appId, logFile)
if err != nil {
if ctx.Err() == context.Canceled {
logger.Info("Deployment cancelled by user")
@@ -96,6 +97,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) {
logger.Error(err, "Failed to clone repository")
errMsg := fmt.Sprintf("Failed to clone repository: %v", err)
models.UpdateDeploymentStatus(id, "failed", "failed", 0, &errMsg)
+ fmt.Fprint(logFile, "error cloning repository: ", err.Error())
return
}
@@ -104,7 +106,12 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) {
logger.Info("Skipping git clone for database app")
}
- _, err = docker.DeployerMain(ctx, id, db, logFile, logger)
+ if app.AppType == models.AppTypeCompose {
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ err = compose.DeployComposeApp(ctx, dep, app, path, db, logFile, logger)
+ } else {
+ _, err = docker.DeployerMain(ctx, id, db, logFile, logger)
+ }
if err != nil {
if ctx.Err() == context.Canceled {
logger.Info("Deployment cancelled by user")
@@ -115,6 +122,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) {
logger.Error(err, "Deployment failed")
errMsg := fmt.Sprintf("Deployment failed: %v", err)
models.UpdateDeploymentStatus(id, "failed", "failed", 0, &errMsg)
+ fmt.Fprint(logFile, "error deploying docker: ", err.Error())
return
}
diff --git a/server/websockets/containerLogs.go b/server/websockets/containerLogs.go
index cace404..ec8dbc2 100644
--- a/server/websockets/containerLogs.go
+++ b/server/websockets/containerLogs.go
@@ -1,11 +1,13 @@
package websockets
import (
+ "bufio"
"context"
"encoding/binary"
"fmt"
"io"
"net/http"
+ "os/exec"
"strconv"
"time"
@@ -58,48 +60,60 @@ func ContainerLogsHandler(w http.ResponseWriter, r *http.Request) {
containerName := docker.GetContainerName(app.Name, appID)
- if !docker.ContainerExists(containerName) {
- conn.WriteJSON(ContainerLogsEvent{
- Type: "error",
- Timestamp: time.Now().Format(time.RFC3339),
- Data: map[string]interface{}{
- "message": "Container not found",
- },
- })
- return
- }
+ if app.AppType != models.AppTypeCompose {
+ if !docker.ContainerExists(containerName) {
+ conn.WriteJSON(ContainerLogsEvent{
+ Type: "error",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: map[string]interface{}{
+ "message": "Container not found",
+ },
+ })
+ return
+ }
+
+ status, err := docker.GetContainerStatus(containerName)
+ if err != nil {
+ conn.WriteJSON(ContainerLogsEvent{
+ Type: "error",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: map[string]interface{}{
+ "message": fmt.Sprintf("Failed to get container status: %v", err),
+ },
+ })
+ return
+ }
- status, err := docker.GetContainerStatus(containerName)
- if err != nil {
conn.WriteJSON(ContainerLogsEvent{
- Type: "error",
+ Type: "status",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
- "message": fmt.Sprintf("Failed to get container status: %v", err),
+ "container": containerName,
+ "state": status.State,
+ "status": status.Status,
},
})
- return
- }
- conn.WriteJSON(ContainerLogsEvent{
- Type: "status",
- Timestamp: time.Now().Format(time.RFC3339),
- Data: map[string]interface{}{
- "container": containerName,
- "state": status.State,
- "status": status.Status,
- },
- })
-
- if status.State != "running" {
+ if status.State != "running" {
+ conn.WriteJSON(ContainerLogsEvent{
+ Type: "error",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: map[string]interface{}{
+ "message": fmt.Sprintf("Container is not running (state: %s)", status.State),
+ },
+ })
+ return
+ }
+ } else {
conn.WriteJSON(ContainerLogsEvent{
- Type: "error",
+ Type: "status",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
- "message": fmt.Sprintf("Container is not running (state: %s)", status.State),
+ "container": app.Name,
+ "state": "running",
+ "status": "Compose Stack",
},
})
- return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -116,6 +130,52 @@ func ContainerLogsHandler(w http.ResponseWriter, r *http.Request) {
go func() {
defer close(logChan)
+ if app.AppType == models.AppTypeCompose {
+ // Streaming for Compose Apps
+ path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", app.ProjectID, app.Name)
+ cmd := exec.CommandContext(ctx, "docker", "compose", "logs", "--follow", "--tail", "100")
+ cmd.Dir = path
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ errChan <- fmt.Errorf("failed to get stdout pipe: %w", err)
+ return
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ errChan <- fmt.Errorf("failed to get stderr pipe: %w", err)
+ return
+ }
+
+ if err := cmd.Start(); err != nil {
+ errChan <- fmt.Errorf("failed to start compose logs: %w", err)
+ return
+ }
+
+ readStream := func(r io.Reader, streamType string) {
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ select {
+ case <-ctx.Done():
+ return
+ case logChan <- logMessage{line: scanner.Text(), streamType: streamType}:
+ }
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF {
+ }
+ }
+
+ go readStream(stdout, "stdout")
+ go readStream(stderr, "stderr")
+
+ if err := cmd.Wait(); err != nil {
+ if ctx.Err() == nil {
+ }
+ }
+ return
+ }
+
+ // Standard Docker Container Streaming
cli, err := client.New(client.FromEnv)
if err != nil {
errChan <- fmt.Errorf("failed to create docker client: %w", err)
diff --git a/www b/www
index 651f471..42a51ee 160000
--- a/www
+++ b/www
@@ -1 +1 @@
-Subproject commit 651f471d42a6799574a828e6bed18f14612e9b4e
+Subproject commit 42a51ee3d4f849cb9ff575356fee14637ff1719d