Skip to content
Merged
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
211 changes: 203 additions & 8 deletions internal/target/dokploy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dokploy

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -237,6 +238,7 @@ type appCache struct {
ComposeAppName string
DiscoveryRedeployAttempted bool
TargetWritersStopped []dockerContainer
MigratedVolumeMounts map[string]migratedVolumeMount
}

type applyContext struct {
Expand Down Expand Up @@ -317,7 +319,7 @@ func (c *Client) Apply(ctx context.Context, plan Plan) error {
err := c.applyStep(ctx, actx, step)
if err != nil {
emitProgress(plan.OnProgress, StepProgress{Index: index, Total: total, Step: step, Status: StepStatusError, Err: err})
c.bestEffortResume(ctx, actx, plan, total, pausedApps, coolifyProxyStopped)
c.bestEffortResume(ctx, actx, plan, total, pausedApps, coolifyProxyStopped, isUnsafeTargetResumeError(err))
return fmt.Errorf("dokploy step %s for %s (%s): %w", step.Kind, step.App, step.Ref, err)
}
emitProgress(plan.OnProgress, StepProgress{Index: index, Total: total, Step: step, Status: StepStatusOK})
Expand All @@ -341,6 +343,9 @@ func isPlatformAppRole(role string) bool {
}

func (c *Client) primeResumeState(ctx context.Context, actx *applyContext, completed []Step, next Step, pausedApps map[string]struct{}, coolifyProxyStopped *bool) error {
if err := actx.loadMigratedVolumeMounts(); err != nil {
return err
}
pushedApps := map[string]struct{}{}
resumedTargetApps := map[string]struct{}{}
for _, step := range completed {
Expand Down Expand Up @@ -401,11 +406,17 @@ func (c *Client) primeResumeState(ctx context.Context, actx *applyContext, compl
// original failure; operators can still re-run apply or rollback. it
// uses a fresh context so a Ctrl-C that cancelled apply can still
// clean up — without that, source containers would stay stopped.
func (c *Client) bestEffortResume(_ context.Context, actx *applyContext, plan Plan, total int, pausedApps map[string]struct{}, coolifyProxyStopped bool) {
if len(pausedApps) == 0 && !coolifyProxyStopped && !actx.hasStoppedTargetWriters() {
func (c *Client) bestEffortResume(_ context.Context, actx *applyContext, plan Plan, total int, pausedApps map[string]struct{}, coolifyProxyStopped bool, skipTargetWriters bool) {
if actx == nil {
actx = &applyContext{}
}
actx.plan = plan
if len(pausedApps) == 0 && !coolifyProxyStopped && (skipTargetWriters || !actx.hasStoppedTargetWriters()) {
return
}
c.bestEffortResumeTargetWriters(actx, plan, total)
if !skipTargetWriters {
c.bestEffortResumeTargetWriters(actx, plan, total)
}
for app := range pausedApps {
cleanupCtx, cancel := context.WithTimeout(context.Background(), dockerStartTimeout)
resumeStep := Step{Kind: StepResumeSource, App: app, Ref: app}
Expand Down Expand Up @@ -451,18 +462,16 @@ func (c *Client) bestEffortResumeTargetWriters(actx *applyContext, plan Plan, to
if actx == nil {
return
}
runner := c.dockerRunner()
for app, entry := range actx.cache {
if len(entry.TargetWritersStopped) == 0 {
continue
}
resumeStep := Step{Kind: StepResumeTarget, App: app, Ref: app}
emitProgress(plan.OnProgress, StepProgress{Index: total, Total: total, Step: resumeStep, Status: StepStatusStarted})
if err := startStoppedTargetWriters(runner, entry.TargetWritersStopped); err != nil {
if err := c.applyResumeTarget(context.Background(), actx, resumeStep); err != nil {
emitProgress(plan.OnProgress, StepProgress{Index: total, Total: total, Step: resumeStep, Status: StepStatusError, Err: err})
continue
}
entry.TargetWritersStopped = nil
emitProgress(plan.OnProgress, StepProgress{Index: total, Total: total, Step: resumeStep, Status: StepStatusOK})
}
}
Expand All @@ -474,6 +483,11 @@ func emitProgress(fn *func(StepProgress), progress StepProgress) {
(*fn)(progress)
}

func isUnsafeTargetResumeError(err error) bool {
var unsafe unsafeTargetResumeError
return errors.As(err, &unsafe)
}

func (c *Client) applyStep(ctx context.Context, actx *applyContext, step Step) error {
switch step.Kind {
case StepCreateProject:
Expand Down Expand Up @@ -605,6 +619,12 @@ func (c *Client) applyPushImage(ctx context.Context, actx *applyContext, step St
return err
}
if err := c.DeployCompose(ctx, entry.ComposeID, deployComposeTitle(actx.plan)); err != nil {
if safetyErr := c.validateMigratedVolumeMountsAfterDeploy(ctx, actx, step.App); safetyErr != nil && isUnsafeTargetResumeError(safetyErr) {
return fmt.Errorf("deploy dokploy compose: %w; post-deploy safety check: %v", err, safetyErr)
}
return err
}
if err := c.validateMigratedVolumeMountsAfterDeploy(ctx, actx, step.App); err != nil {
return err
}
return c.pauseTargetWritersForState(ctx, c.dockerRunner(), actx, step.App)
Expand Down Expand Up @@ -804,7 +824,13 @@ func (c *Client) applyActivateRoutes(ctx context.Context, actx *applyContext, st
return err
}
}
return c.DeployCompose(ctx, entry.ComposeID, deployComposeTitle(actx.plan))
if err := c.DeployCompose(ctx, entry.ComposeID, deployComposeTitle(actx.plan)); err != nil {
if safetyErr := c.validateMigratedVolumeMountsAfterDeploy(ctx, actx, step.App); safetyErr != nil && isUnsafeTargetResumeError(safetyErr) {
return fmt.Errorf("deploy dokploy compose: %w; post-deploy safety check: %v", err, safetyErr)
}
return err
}
return c.validateMigratedVolumeMountsAfterDeploy(ctx, actx, step.App)
}

func (c *Client) ensureRouteDomain(ctx context.Context, composeID string, route gateway.Route) error {
Expand Down Expand Up @@ -1119,9 +1145,56 @@ func readComposeFile(plan Plan, appName string) (string, error) {
if err != nil {
return "", fmt.Errorf("prepare compose file %s for dokploy: %w", full, err)
}
if shouldAttachDokployNetworkAliases(app) {
compose, err = attachDokployNetworkAliases(compose)
if err != nil {
return "", fmt.Errorf("prepare dokploy network aliases for %s: %w", full, err)
}
}
return compose, nil
}

func shouldAttachDokployNetworkAliases(app preparer.AppPlan) bool {
return strings.EqualFold(strings.TrimSpace(app.Role), "support")
}

func attachDokployNetworkAliases(contents string) (string, error) {
var doc yaml.Node
if err := yaml.Unmarshal([]byte(contents), &doc); err != nil {
return "", err
}
root := &doc
if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
root = doc.Content[0]
}
if root.Kind != yaml.MappingNode {
return contents, nil
}
services := mappingValue(root, "services")
if services == nil || services.Kind != yaml.MappingNode {
return contents, nil
}
changed := ensureTopLevelDokployNetwork(root)
for i := 0; i+1 < len(services.Content); i += 2 {
serviceName := strings.TrimSpace(services.Content[i].Value)
service := services.Content[i+1]
if serviceName == "" || service.Kind != yaml.MappingNode {
continue
}
if ensureServiceDokployNetworkAlias(service, serviceName) {
changed = true
}
}
if !changed {
return contents, nil
}
out, err := yaml.Marshal(&doc)
if err != nil {
return "", err
}
return string(out), nil
}

func inlineComposeEnvFiles(contents, appDir string) (string, error) {
return inlineComposeEnvFilesWithFallbacks(contents, appDir, nil)
}
Expand Down Expand Up @@ -1432,6 +1505,97 @@ func sanitizeComposeTopLevelResources(root *yaml.Node) bool {
return changed
}

func ensureTopLevelDokployNetwork(root *yaml.Node) bool {
networks := mappingValue(root, "networks")
changed := false
if networks == nil || networks.Kind != yaml.MappingNode {
networks = &yaml.Node{Kind: yaml.MappingNode}
setMappingNode(root, "networks", networks)
changed = true
}
network := mappingValue(networks, "dokploy-network")
if network == nil || network.Kind != yaml.MappingNode {
network = &yaml.Node{Kind: yaml.MappingNode}
setMappingNode(networks, "dokploy-network", network)
changed = true
}
if ensureMappingBool(network, "external", true) {
changed = true
}
return changed
}

func ensureServiceDokployNetworkAlias(service *yaml.Node, serviceName string) bool {
networks := mappingValue(service, "networks")
changed := false
if networks == nil {
networks = &yaml.Node{Kind: yaml.MappingNode}
setMappingNode(service, "networks", networks)
setMappingNode(networks, "default", &yaml.Node{Kind: yaml.MappingNode})
changed = true
} else if networks.Kind == yaml.SequenceNode {
networks = composeNetworkSequenceToMapping(networks)
setMappingNode(service, "networks", networks)
changed = true
} else if networks.Kind != yaml.MappingNode {
networks = &yaml.Node{Kind: yaml.MappingNode}
setMappingNode(service, "networks", networks)
changed = true
}
network := mappingValue(networks, "dokploy-network")
if network == nil || network.Kind != yaml.MappingNode {
network = &yaml.Node{Kind: yaml.MappingNode}
setMappingNode(networks, "dokploy-network", network)
changed = true
}
if ensureServiceNetworkAlias(network, serviceName) {
changed = true
}
return changed
}

func composeNetworkSequenceToMapping(sequence *yaml.Node) *yaml.Node {
mapping := &yaml.Node{Kind: yaml.MappingNode}
for _, item := range sequence.Content {
if item.Kind != yaml.ScalarNode || strings.TrimSpace(item.Value) == "" {
continue
}
setMappingNode(mapping, strings.TrimSpace(item.Value), &yaml.Node{Kind: yaml.MappingNode})
}
return mapping
}

func ensureServiceNetworkAlias(network *yaml.Node, alias string) bool {
alias = strings.TrimSpace(alias)
if alias == "" {
return false
}
aliases := mappingValue(network, "aliases")
if aliases == nil {
setMappingNode(network, "aliases", &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{stringNode(alias)}})
return true
}
if aliases.Kind != yaml.SequenceNode {
preserved := strings.TrimSpace(aliases.Value)
items := []*yaml.Node{}
if preserved != "" {
items = append(items, stringNode(preserved))
}
if preserved != alias {
items = append(items, stringNode(alias))
}
setMappingNode(network, "aliases", &yaml.Node{Kind: yaml.SequenceNode, Content: items})
return true
}
for _, item := range aliases.Content {
if item.Kind == yaml.ScalarNode && strings.TrimSpace(item.Value) == alias {
return false
}
}
aliases.Content = append(aliases.Content, stringNode(alias))
return true
}

func readComposeEnvFilePathListValues(appDir string, paths []string) (map[string]string, error) {
values := map[string]string{}
for _, envPath := range paths {
Expand Down Expand Up @@ -1655,6 +1819,37 @@ func setMappingScalar(node *yaml.Node, key, value string) {
)
}

func setMappingNode(node *yaml.Node, key string, value *yaml.Node) {
if node == nil || node.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value != key {
continue
}
node.Content[i+1] = value
return
}
node.Content = append(node.Content, stringNode(key), value)
}

func ensureMappingBool(node *yaml.Node, key string, value bool) bool {
want := "false"
if value {
want = "true"
}
current := mappingValue(node, key)
if current != nil && current.Kind == yaml.ScalarNode && current.Value == want {
return false
}
setMappingNode(node, key, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: want})
return true
}

func stringNode(value string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
}

func removeMappingKey(node *yaml.Node, key string) bool {
if node == nil || node.Kind != yaml.MappingNode {
return false
Expand Down
47 changes: 47 additions & 0 deletions internal/target/dokploy/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,3 +665,50 @@ func TestApplyActivateRoutesUpdatesDomainsAndRedeploys(t *testing.T) {
t.Fatalf("expected one compose deploy after domain update, got %d", deploys)
}
}

func TestApplyActivateRoutesDetectsMigratedVolumeDriftAfterDeploy(t *testing.T) {
bundleDir := t.TempDir()
appDir := filepath.Join(bundleDir, "api")
if err := os.MkdirAll(appDir, 0o700); err != nil {
t.Fatalf("mkdir app dir: %v", err)
}
if err := os.WriteFile(filepath.Join(appDir, "compose.yaml"), []byte("services:\n web:\n image: example/web\n"), 0o600); err != nil {
t.Fatalf("write compose: %v", err)
}
deploys := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/compose.update":
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api/compose.deploy":
deploys++
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
runner := &latePostDeployTargetRunner{}
client := &Client{BaseURL: server.URL, Token: "secret", HTTPClient: server.Client(), Docker: runner}
app := preparer.AppPlan{Name: "api", Directory: "api"}
app.Resources.Volumes = []preparer.VolumeResource{{Service: "web", Type: "volume", Target: "/data"}}
app.TargetResources = &preparer.TargetResources{Dokploy: &preparer.DokployResources{ComposeApp: preparer.DokployComposeApp{ComposePath: "compose.yaml"}}}
actx := &applyContext{cache: map[string]*appCache{}, plan: Plan{Prepare: preparer.Result{BundleDir: bundleDir, Apps: []preparer.AppPlan{app}}}}
entry := actx.entry("api")
entry.ComposeID = "c1"
entry.ComposeAppName = "stack-1"
entry.MigratedVolumeMounts = map[string]migratedVolumeMount{
migratedMountKey("web", "/data"): {Service: "web", Target: "/data", VolumeName: "migrated-vol"},
}

err := client.applyActivateRoutes(context.Background(), actx, Step{Kind: StepActivateRoutes, App: "api", Ref: "routes"})
if err == nil || !strings.Contains(err.Error(), "changed from migrated volume migrated-vol to fresh-vol") {
t.Fatalf("expected migrated volume drift after deploy, got %v", err)
}
if deploys != 1 {
t.Fatalf("expected route activation deploy before validation, got %d", deploys)
}
if !fakeOutputCalled(&fakeDockerRunner{outputArgs: runner.outputArgs}, "stop", "web-id") {
t.Fatalf("expected drifted target container to stop, calls=%#v", runner.outputArgs)
}
}
Loading
Loading