feat: add the ability to log into a registry on a per-app basis

Closes #5324
This commit is contained in:
Jose Diaz-Gonzalez
2026-01-09 00:12:16 -05:00
parent 2093db6e74
commit 6a83fd053c
23 changed files with 398 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
SUBCOMMANDS = subcommands/login subcommands/report subcommands/set
TRIGGERS = triggers/deployed-app-image-repo triggers/deployed-app-image-tag triggers/deployed-app-repository triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/post-release-builder triggers/report
SUBCOMMANDS = subcommands/login subcommands/logout subcommands/report subcommands/set
TRIGGERS = triggers/deployed-app-image-repo triggers/deployed-app-image-tag triggers/deployed-app-repository triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/post-release-builder triggers/report
BUILD = commands subcommands triggers
PLUGIN_NAME = registry

View File

@@ -2,7 +2,10 @@ package registry
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
@@ -10,6 +13,47 @@ import (
"github.com/dokku/dokku/plugins/common"
)
const registryConfigDir = "/var/lib/dokku/config/registry"
// GetAppRegistryConfigDir returns the per-app registry config directory
func GetAppRegistryConfigDir(appName string) string {
return filepath.Join(registryConfigDir, appName)
}
// GetAppRegistryConfigPath returns the path to per-app docker config.json
func GetAppRegistryConfigPath(appName string) string {
return filepath.Join(GetAppRegistryConfigDir(appName), "config.json")
}
// HasAppRegistryAuth checks if an app has registry credentials configured
func HasAppRegistryAuth(appName string) bool {
configPath := GetAppRegistryConfigPath(appName)
if !common.FileExists(configPath) {
return false
}
content, err := os.ReadFile(configPath)
if err != nil {
return false
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return false
}
auths, ok := config["auths"].(map[string]interface{})
return ok && len(auths) > 0
}
// GetDockerConfigArgs returns docker --config arguments if per-app config exists
func GetDockerConfigArgs(appName string) []string {
if appName == "" || !HasAppRegistryAuth(appName) {
return []string{}
}
return []string{"--config", GetAppRegistryConfigDir(appName)}
}
func getImageRepoFromTemplate(appName string) (string, error) {
imageRepoTemplate := common.PropertyGet("registry", "--global", "image-repo-template")
if imageRepoTemplate == "" {
@@ -119,14 +163,14 @@ func pushToRegistry(appName string, tag int, imageID string, imageRepo string) e
}
}()
common.LogVerboseQuiet(fmt.Sprintf("Pushing %s", extraTagImage))
if err := dockerPush(extraTagImage); err != nil {
if err := dockerPush(appName, extraTagImage); err != nil {
return fmt.Errorf("unable to push image with %s tag: %w", extraTag, err)
}
}
}
common.LogVerboseQuiet(fmt.Sprintf("Pushing %s", fullImage))
if err := dockerPush(fullImage); err != nil {
if err := dockerPush(appName, fullImage); err != nil {
return fmt.Errorf("unable to push image %s: %w", fullImage, err)
}
@@ -159,17 +203,19 @@ func dockerTag(imageID string, imageTag string) error {
return nil
}
func dockerPush(imageTag string) error {
func dockerPush(appName string, imageTag string) error {
args := GetDockerConfigArgs(appName)
args = append(args, "image", "push", imageTag)
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: []string{"image", "push", imageTag},
Args: args,
StreamStdio: true,
})
if err != nil {
return fmt.Errorf("docker push command failed: %w", err)
return fmt.Errorf("docker image push command failed: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("docker push command exited with code %d: %s", result.ExitCode, result.Stderr)
return fmt.Errorf("docker image push command exited with code %d: %s", result.ExitCode, result.Stderr)
}
return nil
}

View File

@@ -18,9 +18,10 @@ Manage registry settings for an app
Additional commands:`
helpContent = `
registry:login [--password-stdin] <server> <username> [<password>], Login to a docker registry
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>], Login to a docker registry
registry:logout [--global] [<app>] <server>, Logout from a docker registry
registry:report [<app>] [<flag>], Displays a registry report for one or more apps
registry:set <app> <property> (<value>), Set or clear a registry property for an app`
registry:set <app>|--global <property> (<value>), Set or clear a registry property for an app`
)
func main() {

View File

@@ -21,11 +21,66 @@ func main() {
case "login":
args := flag.NewFlagSet("registry:login", flag.ExitOnError)
passwordStdin := args.Bool("password-stdin", false, "--password-stdin: read password from stdin")
global := args.Bool("global", false, "--global: login globally instead of per-app")
args.Parse(os.Args[2:])
server := args.Arg(0)
username := args.Arg(1)
password := args.Arg(2)
err = registry.CommandLogin(server, username, password, *passwordStdin)
argCount := args.NArg()
var appName, server, username, password string
// When --password-stdin is used, password is not in args
// Global login: 2 args (server, username) with --password-stdin, 3 args without
// Per-app login: 3 args (app, server, username) with --password-stdin, 4 args without
globalArgCount := 3
perAppArgCount := 4
if *passwordStdin {
globalArgCount = 2
perAppArgCount = 3
}
if *global {
// --global: server, username, [password]
server = args.Arg(0)
username = args.Arg(1)
if !*passwordStdin {
password = args.Arg(2)
}
} else if argCount == globalArgCount {
// global login without --global flag: warn and treat as global
common.LogWarn("Deprecated: please use --global flag for global registry login")
server = args.Arg(0)
username = args.Arg(1)
if !*passwordStdin {
password = args.Arg(2)
}
} else if argCount >= perAppArgCount {
// per-app login: app, server, username, [password]
appName = args.Arg(0)
server = args.Arg(1)
username = args.Arg(2)
if !*passwordStdin {
password = args.Arg(3)
}
}
err = registry.CommandLogin(appName, server, username, password, *passwordStdin)
case "logout":
args := flag.NewFlagSet("registry:logout", flag.ExitOnError)
global := args.Bool("global", false, "--global: logout globally instead of per-app")
args.Parse(os.Args[2:])
var appName, server string
if *global {
server = args.Arg(0)
} else if args.NArg() == 1 {
// 1 arg: global logout (backwards compatible)
server = args.Arg(0)
} else {
// 2 args: app, server
appName = args.Arg(0)
server = args.Arg(1)
}
err = registry.CommandLogout(appName, server)
case "report":
args := flag.NewFlagSet("registry:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")

View File

@@ -29,6 +29,9 @@ func main() {
err = registry.TriggerDeployedAppRepository(appName)
case "install":
err = registry.TriggerInstall()
case "post-create":
appName := flag.Arg(0)
err = registry.TriggerPostCreate(appName)
case "post-app-clone-setup":
oldAppName := flag.Arg(0)
newAppName := flag.Arg(1)

View File

@@ -12,7 +12,7 @@ import (
)
// CommandLogin logs a user into the specified server
func CommandLogin(server string, username string, password string, passwordStdin bool) error {
func CommandLogin(appName string, server string, username string, password string, passwordStdin bool) error {
if passwordStdin {
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
@@ -39,9 +39,22 @@ func CommandLogin(server string, username string, password string, passwordStdin
buffer := bytes.Buffer{}
buffer.Write([]byte(password + "\n"))
env := map[string]string{}
if appName != "" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
configDir := GetAppRegistryConfigDir(appName)
if err := os.MkdirAll(configDir, 0700); err != nil {
return fmt.Errorf("Unable to create registry config directory: %w", err)
}
env["DOCKER_CONFIG"] = GetAppRegistryConfigPath(appName)
}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: []string{"login", "--username", username, "--password-stdin", server},
Env: env,
Stdin: &buffer,
StreamStdio: true,
})
@@ -52,6 +65,7 @@ func CommandLogin(server string, username string, password string, passwordStdin
return fmt.Errorf("Unable to run docker login: %s", result.StderrContents())
}
// todo: change the signature of the trigger to include the app name
_, err = common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "post-registry-login",
Args: []string{server, username},
@@ -67,6 +81,44 @@ func CommandLogin(server string, username string, password string, passwordStdin
return nil
}
// CommandLogout logs a user out from the specified server
func CommandLogout(appName string, server string) error {
if server == "" {
return errors.New("Missing server argument")
}
if server == "hub.docker.com" || server == "docker.com" {
server = "docker.io"
}
env := map[string]string{}
if appName != "" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
configDir := GetAppRegistryConfigDir(appName)
if !common.DirectoryExists(configDir) {
return fmt.Errorf("No registry credentials found for app %s", appName)
}
env["DOCKER_CONFIG"] = GetAppRegistryConfigPath(appName)
}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: []string{"logout", server},
Env: env,
StreamStdio: true,
})
if err != nil {
return fmt.Errorf("Unable to run docker logout: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("Unable to run docker logout: %s", result.StderrContents())
}
return nil
}
// CommandReport displays a registry report for one or more apps
func CommandReport(appName string, format string, infoFlag string) error {
if len(appName) == 0 {

View File

@@ -2,6 +2,7 @@ package registry
import (
"fmt"
"os"
"strings"
"github.com/dokku/dokku/plugins/common"
@@ -57,6 +58,11 @@ func TriggerInstall() error {
return nil
}
// TriggerPostCreate creates the registry config directory for a new app
func TriggerPostCreate(appName string) error {
return common.PropertySetupApp("registry", appName)
}
// TriggerPostAppCloneSetup creates new registry files
func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error {
err := common.PropertyClone("registry", oldAppName, newAppName)
@@ -64,6 +70,18 @@ func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error {
return err
}
// Clone docker config.json if it exists
oldConfigPath := GetAppRegistryConfigPath(oldAppName)
if common.FileExists(oldConfigPath) {
newConfigDir := GetAppRegistryConfigDir(newAppName)
if err := os.MkdirAll(newConfigDir, 0700); err != nil {
return fmt.Errorf("Unable to create registry config directory: %w", err)
}
if err := common.Copy(oldConfigPath, GetAppRegistryConfigPath(newAppName)); err != nil {
return fmt.Errorf("Unable to clone registry config: %w", err)
}
}
return nil
}
@@ -73,6 +91,18 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error {
return err
}
// Move docker config.json if it exists
oldConfigPath := GetAppRegistryConfigPath(oldAppName)
if common.FileExists(oldConfigPath) {
newConfigDir := GetAppRegistryConfigDir(newAppName)
if err := os.MkdirAll(newConfigDir, 0700); err != nil {
return fmt.Errorf("Unable to create registry config directory: %w", err)
}
if err := os.Rename(oldConfigPath, GetAppRegistryConfigPath(newAppName)); err != nil {
return fmt.Errorf("Unable to rename registry config: %w", err)
}
}
if err := common.PropertyDestroy("registry", oldAppName); err != nil {
return err
}