feat: storage attachments, docker-args triggers, install-time migration

storage:mount and storage:unmount now accept the named-entry form (--container-dir, --phase, --process-type, --volume-subpath, --volume-readonly, --volume-chown), persisting attachments via the property list. The legacy host:container colon form is preserved for back-compat. Storage now emits its own docker-args-deploy and docker-args-run triggers so docker-local apps continue to receive bind mounts even after their data moves out of docker-options. The install trigger walks every app once per upgrade and converts legacy -v lines into legacy-<hash> entries plus attachments, draining the originals from docker-options last so a partial run leaves the old behavior intact. Lifecycle triggers post-delete, post-app-clone-setup, and post-app-rename-setup keep the attachment store in sync with app state.
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-29 14:43:51 -04:00
parent 8a46d89181
commit e1b6bdb1d4
6 changed files with 453 additions and 30 deletions

View File

@@ -125,38 +125,102 @@ func isUserNamespacesEnabled() (bool, error) {
return strings.TrimSpace(result.StdoutContents()) == "true", nil
}
// CommandMount creates a new bind mount for an app
func CommandMount(appName string, mountPath string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
if err := VerifyPaths(mountPath); err != nil {
return err
}
if CheckIfPathExists(appName, mountPath, MountPhases) {
return errors.New("Mount path already exists.")
}
return dockeroptions.AddDockerOptionToPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath))
// CommandMountInput captures the optional flags accepted by storage:mount
// when the second argument is a named entry rather than a colon-form path.
type CommandMountInput struct {
AppName string
NameOrPath string
ContainerDir string
Phases []string
ProcessType string
Subpath string
Readonly bool
VolumeOptions string
VolumeChown string
}
// CommandUnmount removes an existing bind mount from an app
func CommandUnmount(appName string, mountPath string) error {
if err := common.VerifyAppName(appName); err != nil {
// CommandMount creates a new bind mount for an app. The second positional
// argument may be either a legacy host:container[:options] string (kept
// for back-compat on docker-local) or a registered storage entry name.
func CommandMount(input CommandMountInput) error {
if err := common.VerifyAppName(input.AppName); err != nil {
return err
}
if err := VerifyPaths(mountPath); err != nil {
// Legacy colon form: keep writing into docker-options so existing
// users see no behavior change.
if strings.Contains(input.NameOrPath, ":") {
if err := VerifyPaths(input.NameOrPath); err != nil {
return err
}
if CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) {
return errors.New("Mount path already exists.")
}
return dockeroptions.AddDockerOptionToPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", input.NameOrPath))
}
// Named-entry form: persist as an attachment.
if !EntryExists(input.NameOrPath) {
return fmt.Errorf("storage entry %q does not exist; create it first with `dokku storage:create`", input.NameOrPath)
}
if input.ContainerDir == "" {
return errors.New("--container-dir is required when mounting a named storage entry")
}
entry, err := LoadEntry(input.NameOrPath)
if err != nil {
return err
}
if !CheckIfPathExists(appName, mountPath, MountPhases) {
return errors.New("Mount path does not exist.")
phases := input.Phases
if len(phases) == 0 {
phases = []string{PhaseDeploy, PhaseRun}
}
return dockeroptions.RemoveDockerOptionFromPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath))
processType := input.ProcessType
if processType == "" {
processType = DefaultProcessType
}
attachment := &Attachment{
EntryName: entry.Name,
ContainerPath: input.ContainerDir,
Phases: phases,
ProcessType: processType,
Subpath: input.Subpath,
Readonly: input.Readonly,
VolumeOptions: input.VolumeOptions,
VolumeChown: input.VolumeChown,
}
return AddAttachment(input.AppName, attachment)
}
// CommandUnmountInput captures the flags accepted by storage:unmount when
// the second argument is a named entry.
type CommandUnmountInput struct {
AppName string
NameOrPath string
ContainerDir string
}
// CommandUnmount removes an existing bind mount from an app.
func CommandUnmount(input CommandUnmountInput) error {
if err := common.VerifyAppName(input.AppName); err != nil {
return err
}
if strings.Contains(input.NameOrPath, ":") {
if err := VerifyPaths(input.NameOrPath); err != nil {
return err
}
if !CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) {
return errors.New("Mount path does not exist.")
}
return dockeroptions.RemoveDockerOptionFromPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", input.NameOrPath))
}
return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir)
}
// CommandList lists all bind mounts for an app