Skip to content
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
50 changes: 47 additions & 3 deletions pkg/main/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import (
// CW_CLIENT_0_RESTART_DOCKER_CONTAINER2 ... etc.
// Note: Restart is based on file update, so use the vars above to set a file update time window and day(s) of week
// CW_CLIENT_0_RESTART_DOCKER_STOP_ONLY - if 'true' docker containers will be stopped instead of restarted (this is useful if another process like systemctl will start them back up)
// CW_CLIENT_0_COMMAND_DOCKER_CONTAINER0 - execute command in container after cert update, format: "container_name:command arg1 arg2" (e.g., "nginx:nginx -s reload")
// CW_CLIENT_0_COMMAND_DOCKER_CONTAINER1 - another container command (keep adding 1 to the number for more)
// CW_CLIENT_0_COMMAND_DOCKER_CONTAINER2 ... etc.

// CW_CLIENT_0_CERT_PATH - the path to save all keys and certificates to
// CW_CLIENT_0_KEY_PEM_FILENAME - filename to save the key pem as (default is key0.pem ; number == index)
Expand Down Expand Up @@ -118,6 +121,12 @@ type app struct {
cipherAEAD []cipher.AEAD
}

// containerCommand holds a container name and command to execute
type containerCommand struct {
ContainerName string
Command []string
}

// certConfig contains all of the configuration specific to
// a given certificate
type certConfig struct {
Expand All @@ -128,6 +137,7 @@ type certConfig struct {
FileUpdateTimeIncludesMidnight bool
FileUpdateDaysOfWeek map[time.Weekday]struct{}
DockerContainersToRestart []string
DockerContainerCommands []containerCommand
DockerStopOnly bool
KeyName string
KeyApiKey string
Expand Down Expand Up @@ -326,21 +336,55 @@ func configureApp() (*app, error) {
cert.DockerContainersToRestart = append(cert.DockerContainersToRestart, containerName)
}

// CW_CLIENT_COMMAND_DOCKER_CONTAINER (0... etc.)
// Format: "container_name:command arg1 arg2"
cert.DockerContainerCommands = []containerCommand{}
for i := 0; true; i++ {
commandStr := os.Getenv(prefix + "COMMAND_DOCKER_CONTAINER" + strconv.Itoa(i))
if commandStr == "" {
// if next number not specified, done
break
}

// Parse format: "container_name:command arg1 arg2"
parts := strings.SplitN(commandStr, ":", 2)
if len(parts) != 2 {
app.logger.Errorf("%sCOMMAND_DOCKER_CONTAINER%d has invalid format (expected 'container:command'), skipping", prefix, i)
continue
}

containerName := strings.TrimSpace(parts[0])
commandPart := strings.TrimSpace(parts[1])

if containerName == "" || commandPart == "" {
app.logger.Errorf("%sCOMMAND_DOCKER_CONTAINER%d has empty container name or command, skipping", prefix, i)
continue
}

// Split command into parts (space-separated)
commandArgs := strings.Fields(commandPart)

cert.DockerContainerCommands = append(cert.DockerContainerCommands, containerCommand{
ContainerName: containerName,
Command: commandArgs,
})
}

// ensure this only happens once -- app has one common api client
if len(cert.DockerContainersToRestart) > 0 && app.dockerAPIClient == nil {
if (len(cert.DockerContainersToRestart) > 0 || len(cert.DockerContainerCommands) > 0) && app.dockerAPIClient == nil {
app.dockerAPIClient, err = dockerClient.NewClientWithOpts(
dockerClient.FromEnv,
dockerClient.WithAPIVersionNegotiation(),
)
if err != nil {
return app, fmt.Errorf("specified %sRESTART_DOCKER_CONTAINER but couldn't make docker api client (%s)", prefix, err)
return app, fmt.Errorf("specified %sRESTART_DOCKER_CONTAINER or %sCOMMAND_DOCKER_CONTAINER but couldn't make docker api client (%s)", prefix, prefix, err)
}

testPingCtx, cancelPing := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelPing()
_, err := app.dockerAPIClient.Ping(testPingCtx)
if err != nil {
app.logger.Errorf("specified %sRESTART_DOCKER_CONTAINER but couldn't connect to docker api (%s), verify access to docker or restarts will not occur", prefix, err)
app.logger.Errorf("specified %sRESTART_DOCKER_CONTAINER or %sCOMMAND_DOCKER_CONTAINER but couldn't connect to docker api (%s), verify access to docker or operations will not occur", prefix, prefix, err)
}
}

Expand Down
81 changes: 81 additions & 0 deletions pkg/main/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package main

import (
"context"
"io"
"strings"
"time"

dockerContainerTypes "github.com/docker/docker/api/types/container"
)

const dockerRestartContextTimeout = 3 * time.Minute
const dockerGracefulExitTimeoutSeconds = 60
const dockerExecContextTimeout = 2 * time.Minute

// restartOrStopDockerContainers stops or restarts each of the container names specified in the
// config file; this func is called after cert files are updated; restarts/stops are done
Expand Down Expand Up @@ -49,3 +52,81 @@ func (app *app) restartOrStopDockerContainers(certIndex int) {
}(container)
}
}

// executeDockerContainerCommands executes commands in specified Docker containers
// after cert files are updated; executions are done async and results are logged
func (app *app) executeDockerContainerCommands(certIndex int) {
app.logger.Infof("executing docker container command(s) for cert %d", certIndex)

// abort if invalid index
if certIndex > len(app.cfg.Certs) {
app.logger.Errorf("docker command execution failed, invalid cert index %d (how'd that happen??)", certIndex)
return
}

for _, containerCmd := range app.cfg.Certs[certIndex].DockerContainerCommands {
go func(asyncContainerCmd containerCommand) {
execCtx, cancel := context.WithTimeout(context.Background(), dockerExecContextTimeout)
defer cancel()

// Create exec configuration
execConfig := dockerContainerTypes.ExecOptions{
AttachStdout: true,
AttachStderr: true,
Cmd: asyncContainerCmd.Command,
}

// Create exec instance
execID, err := app.dockerAPIClient.ContainerExecCreate(execCtx, asyncContainerCmd.ContainerName, execConfig)
if err != nil {
app.logger.Errorf("failed to create exec for container %s command %v (%s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, err)
return
}

// Start exec
execStartCheck := dockerContainerTypes.ExecStartOptions{
Detach: false,
Tty: false,
}

resp, err := app.dockerAPIClient.ContainerExecAttach(execCtx, execID.ID, execStartCheck)
if err != nil {
app.logger.Errorf("failed to attach exec for container %s command %v (%s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, err)
return
}
defer resp.Close()

// Read output
output, err := io.ReadAll(resp.Reader)
if err != nil {
app.logger.Errorf("failed to read exec output for container %s command %v (%s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, err)
}

// Check exit code
inspectResp, err := app.dockerAPIClient.ContainerExecInspect(execCtx, execID.ID)
if err != nil {
app.logger.Errorf("failed to inspect exec for container %s command %v (%s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, err)
return
}

if inspectResp.ExitCode == 0 {
outputStr := strings.TrimSpace(string(output))
if outputStr != "" {
app.logger.Infof("successfully executed command in container %s: %v (output: %s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, outputStr)
} else {
app.logger.Infof("successfully executed command in container %s: %v",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command)
}
} else {
app.logger.Errorf("command failed in container %s: %v (exit code: %d, output: %s)",
asyncContainerCmd.ContainerName, asyncContainerCmd.Command, inspectResp.ExitCode, strings.TrimSpace(string(output)))
}

}(containerCmd)
}
}
10 changes: 10 additions & 0 deletions pkg/main/update_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ func (app *app) updateCertFilesAndRestartContainers(certIndex int, onlyIfMissing
}
}

// execute commands in docker containers (if any files written)
if len(app.cfg.Certs[certIndex].DockerContainerCommands) > 0 {
if wroteAnyFiles {
app.logger.Infof("at least one file changed for cert %d, executing docker container commands", certIndex)
app.executeDockerContainerCommands(certIndex)
} else {
app.logger.Debugf("not executing docker container commands for cert %d, no changes were written to disk", certIndex)
}
}

// log result
diskNeedsUpdate = false
if failedAnyWrite {
Expand Down