Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: docker swarm support #645

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type DockerAgentDeployment struct {
AgentDeployment
ComposeFile []byte `json:"composeFile"`
EnvFile []byte `json:"envFile"`
types.DockerType
}

type KubernetesAgentResource struct {
Expand Down
17 changes: 9 additions & 8 deletions cmd/agent/docker/agent_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
"path"

"github.com/glasskube/distr/api"
"github.com/glasskube/distr/internal/types"
"github.com/google/uuid"
)

type AgentDeployment struct {
ID uuid.UUID `json:"id"`
RevisionID uuid.UUID `json:"revisionId"`
ProjectName string `json:"projectName"`
ID uuid.UUID `json:"id"`
RevisionID uuid.UUID `json:"revisionId"`
ProjectName string `json:"projectName"`
DockerType types.DockerType `json:"docker_type"`
}

func (d *AgentDeployment) FileName() string {
Expand All @@ -29,7 +31,7 @@ func NewAgentDeployment(deployment api.DockerAgentDeployment) (*AgentDeployment,
if name, err := getProjectName(deployment.ComposeFile); err != nil {
return nil, err
} else {
return &AgentDeployment{ID: deployment.ID, RevisionID: deployment.RevisionID, ProjectName: name}, nil
return &AgentDeployment{ID: deployment.ID, RevisionID: deployment.RevisionID, ProjectName: name, DockerType: deployment.DockerType}, nil
}
}

Expand All @@ -42,8 +44,7 @@ func getProjectName(data []byte) (string, error) {
return name, nil
}
}

func GetExistingDeployments() ([]AgentDeployment, error) {
func GetExistingDeployments() (map[uuid.UUID]AgentDeployment, error) {
if entries, err := os.ReadDir(agentDeploymentDir()); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
Expand All @@ -62,13 +63,13 @@ func GetExistingDeployments() ([]AgentDeployment, error) {
return &d, nil
}
}
result := make([]AgentDeployment, 0, len(entries))
result := make(map[uuid.UUID]AgentDeployment, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
if d, err := fn(entry.Name()); err != nil {
return nil, err
} else {
result = append(result, *d)
result[d.RevisionID] = *d
}
}
}
Expand Down
215 changes: 213 additions & 2 deletions cmd/agent/docker/docker_actions.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
package main

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"

"github.com/glasskube/distr/api"
"github.com/glasskube/distr/internal/agentauth"
"github.com/glasskube/distr/internal/types"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)

func ApplyComposeFile(ctx context.Context, deployment api.DockerAgentDeployment) (*AgentDeployment, string, error) {
func DockerEngineApply(
ctx context.Context,
deployment api.DockerAgentDeployment,
) (*AgentDeployment, string, error) {

if deployment.DockerType == types.DockerTypeSwarm {

fmt.Println(deployment.RevisionID)

// Step 1 Ensure Docker Swarm is initialized
initCmd := exec.CommandContext(ctx, "docker", "info", "--format", "{{.Swarm.LocalNodeState}}")
initOutput, err := initCmd.CombinedOutput()
if err != nil {
logger.Error("Failed to check Docker Swarm state", zap.Error(err))
return nil, "", fmt.Errorf("failed to check Docker Swarm state: %w", err)
}

if !strings.Contains(strings.TrimSpace(string(initOutput)), "active") {
logger.Error("Docker Swarm not initialized", zap.String("output", string(initOutput)))
return nil, "", fmt.Errorf("docker Swarm not initialized: %s", string(initOutput))
}
// Step 2: Pull images before deployment
_, err = PullSwarmMode(ctx, deployment)
if err != nil {
logger.Error("Failed to Pull", zap.Error(err))
return nil, "", err
}
return ApplyComposeFileSwarm(ctx, deployment)

}
return ApplyComposeFile(ctx, deployment)

}
func DockerEngineUninstall(
ctx context.Context, deployment AgentDeployment,
) error {
if deployment.DockerType == types.DockerTypeSwarm {
return UninstallDockerSwarm(ctx, deployment)
}
return UninstallDockerCompose(ctx, deployment)
}
func ApplyComposeFile(
ctx context.Context,
deployment api.DockerAgentDeployment,
) (*AgentDeployment, string, error) {

agentDeploymet, err := NewAgentDeployment(deployment)
if err != nil {
return nil, "", err
Expand Down Expand Up @@ -60,11 +109,173 @@ func ApplyComposeFile(ctx context.Context, deployment api.DockerAgentDeployment)
}
}

func UninstallDockerCompose(ctx context.Context, deployment AgentDeployment) error {
func ApplyComposeFileSwarm(
ctx context.Context,
deployment api.DockerAgentDeployment,
) (*AgentDeployment, string, error) {

agentDeployment, err := NewAgentDeployment(deployment)
if err != nil {
return nil, "", err
}

// Read the Compose file without replacing environment variables
cleanedCompose := cleanComposeFile(deployment.ComposeFile)

// Construct environment variables
envVars := os.Environ()
envVars = append(envVars, agentauth.DockerConfigEnv(deployment.AgentDeployment)...)

// // If an env file is provided, load its values
if deployment.EnvFile != nil {
parsedEnv, err := parseEnvFile(deployment.EnvFile)
if err != nil {
logger.Error("Failed to parse env file", zap.Error(err))
return nil, "", fmt.Errorf("failed to parse env file: %w", err)
}
for key, value := range parsedEnv {
envVars = append(envVars, fmt.Sprintf("%s=%s", key, value))
}
}

// Deploy the stack
composeArgs := []string{
"stack", "deploy",
"--compose-file", "-",
"--with-registry-auth",
"--detach=true",
agentDeployment.ProjectName,
}
cmd := exec.CommandContext(ctx, "docker", composeArgs...)
cmd.Stdin = bytes.NewReader(cleanedCompose)
cmd.Env = envVars // Ensure the same env variables are used

// Execute the command and capture output
cmdOut, err := cmd.CombinedOutput()
statusStr := string(cmdOut)

if err != nil {
logger.Error("Docker stack deploy failed", zap.String("output", statusStr))
return nil, "", errors.New(statusStr)
}

return agentDeployment, statusStr, nil
}

func UninstallDockerCompose(
ctx context.Context, deployment AgentDeployment,
) error {
cmd := exec.CommandContext(ctx, "docker", "compose", "--project-name", deployment.ProjectName, "down", "--volumes")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %v", err, string(out))
}
return nil
}

func UninstallDockerSwarm(
ctx context.Context, deployment AgentDeployment,
) error {

cmd := exec.CommandContext(ctx, "docker", "stack", "rm", deployment.ProjectName)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to remove Docker Swarm stack: %w: %v", err, string(out))
}

// Optional: Prune unused networks created by Swarm
pruneCmd := exec.CommandContext(ctx, "docker", "network", "prune", "-f")
pruneOut, pruneErr := pruneCmd.CombinedOutput()
if pruneErr != nil {
logger.Warn("Failed to prune networks", zap.String("output", string(pruneOut)), zap.Error(pruneErr))
}

return nil
}
func cleanComposeFile(composeData []byte) []byte {
lines := strings.Split(string(composeData), "\n")
cleanedLines := make([]string, 0, 50)

for _, line := range lines {
// Skip lines that define `name:`
if strings.HasPrefix(strings.TrimSpace(line), "name:") {
continue
}
cleanedLines = append(cleanedLines, line)
}
return []byte(strings.Join(cleanedLines, "\n"))
}
func parseEnvFile(envData []byte) (map[string]string, error) {
envVars := make(map[string]string)
scanner := bufio.NewScanner(bytes.NewReader(envData))
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
continue // Skip empty lines and comments
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid environment variable: %s", line)
}
envVars[parts[0]] = parts[1]
}
return envVars, scanner.Err()
}

type ComposeService struct {
Image string `yaml:"image"`
}

// ComposeFile represents the structure of docker-compose.yml
type ComposeFile struct {
Services map[string]ComposeService `yaml:"services"`
}

func PullSwarmMode(
ctx context.Context, deployment api.DockerAgentDeployment,
) (string, error) {

// Parse the compose YAML file
var compose ComposeFile
err := yaml.Unmarshal(deployment.ComposeFile, &compose)
if err != nil {
return "", fmt.Errorf("failed to parse docker-compose.yml: %w", err)
}

// Extract image names
var images []string
for _, service := range compose.Services {
if service.Image != "" {
images = append(images, service.Image)
}
}

if len(images) == 0 {
return "", fmt.Errorf("no images found in the compose file")
}

// Pull images using Docker CLI
var pullLogs bytes.Buffer
for _, image := range images {
fmt.Println("Pulling image:", image)
logger.Info("Pulling image:", zap.String("id", image))
// Run `docker pull IMAGE_NAME`
cmd := exec.CommandContext(ctx, "docker", "pull", image)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out

err := cmd.Run()
if err != nil {
logger.Error("failed to pull image", zap.Error(err))
return "", fmt.Errorf("failed to pull image %s: %w\nOutput: %s", image, err, out.String())
}

// Append logs
pullLogs.WriteString(out.String() + "\n")
fmt.Println(out.String())
}

fmt.Println("Image pulling complete.")
return pullLogs.String(), nil
}
17 changes: 13 additions & 4 deletions cmd/agent/docker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ loop:
if resource, err := client.DockerResource(ctx); err != nil {
logger.Error("failed to get resource", zap.Error(err))
} else {

if agentVersionID != "" {
if agentVersionID != resource.Version.ID.String() {
logger.Info("agent version has changed. starting self-update")
Expand All @@ -71,13 +72,14 @@ loop:
}
}

if deployments, err := GetExistingDeployments(); err != nil {
deployments, err := GetExistingDeployments()
if err != nil {
logger.Error("could not get existing deployments", zap.Error(err))
} else {
for _, deployment := range deployments {
if resource.Deployment == nil || resource.Deployment.ID != deployment.ID {
logger.Info("uninstalling old deployment", zap.String("id", deployment.ID.String()))
if err := UninstallDockerCompose(ctx, deployment); err != nil {
if err := DockerEngineUninstall(ctx, deployment); err != nil {
logger.Error("could not uninstall deployment", zap.Error(err))
} else if err := DeleteDeployment(deployment); err != nil {
logger.Error("could not delete deployment", zap.Error(err))
Expand All @@ -96,8 +98,15 @@ loop:
_, err = agentauth.EnsureAuth(ctx, resource.Deployment.AgentDeployment)
if err != nil {
logger.Error("docker auth error", zap.Error(err))
} else if agentDeployment, status, err = ApplyComposeFile(ctx, *resource.Deployment); err == nil {
multierr.AppendInto(&err, SaveDeployment(*agentDeployment))
}
if _, exists := deployments[resource.Deployment.RevisionID]; !exists {

agentDeployment, status, err = DockerEngineApply(ctx, *resource.Deployment)

if err == nil {
multierr.AppendInto(&err, SaveDeployment(*agentDeployment))
}

}

if statusErr := client.Status(ctx, resource.Deployment.RevisionID, status, err); statusErr != nil {
Expand Down
18 changes: 18 additions & 0 deletions frontend/ui/src/app/applications/application-detail.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,24 @@ <h3 class="block mb-2 text-lg font-medium text-gray-900 dark:text-white">New Ver
</div>
</div>

<div class="w-full">
<label for="dockerTypeSelect" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Docker Type *</label
>
<select
[formControl]="newVersionForm.controls.docker.controls.dockerType"
id="dockerTypeSelect"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<option value="compose">compose</option>
<option value="swarm">swarm</option>
</select>
@if (
newVersionForm.controls.docker.controls.dockerType.invalid &&
newVersionForm.controls.docker.controls.dockerType.touched
) {
<p class="mt-1 text-sm text-red-600 dark:text-red-500">Field is required.</p>
}
</div>
@if (application.type === 'kubernetes') {
<div class="space-y-4 mt-4">
<div class="grid grid-cols-2 md:grid-cols-2 space-y-4 sm:flex sm:space-x-4 sm:space-y-0">
Expand Down
Loading
Loading