Skip to content

deploy support for machine configs with containers #4289

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

Open
wants to merge 2 commits into
base: master
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
161 changes: 161 additions & 0 deletions internal/command/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package deploy

import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -178,6 +181,16 @@ var CommonFlags = flag.Set{
Description: "Number of times to retry a deployment if it fails",
Default: "auto",
},
flag.String{
Name: "autostop",
Description: "Automatically stop a machine when there are no network requests for a time. Options include 'off' and a duration. Default is 'off'",
Default: "off",
NoOptDefVal: "1h",
},
flag.String{
Name: "machine-config-template",
Description: "Read machine config template from json file or string. When containers array is present, an 'app' container is required.",
},
}

type Command struct {
Expand Down Expand Up @@ -216,6 +229,11 @@ func New() *Command {
Description: "Do not run the release command during deployment.",
Default: false,
},
flag.Bool{
Name: "print-machine-config",
Description: "Process the machine config template and print the resulting machine configuration without deploying",
Default: false,
},
flag.String{
Name: "export-manifest",
Description: "Specify a file to export the deployment configuration to a deploy manifest file, or '-' to print to stdout.",
Expand Down Expand Up @@ -451,6 +469,11 @@ func deployToMachines(
metrics.Status(ctx, "deploy_machines", err == nil)
}()

// Check if we should just print the machine config and exit
if flag.GetBool(ctx, "print-machine-config") {
return printMachineConfig(ctx, cfg, img)
}

releaseCmdTimeout, err := parseDurationFlag(ctx, "release-command-timeout")
if err != nil {
return err
Expand Down Expand Up @@ -678,3 +701,141 @@ func determineAppConfig(ctx context.Context) (cfg *appconfig.Config, err error)
tb.Done("Verified app config")
return cfg, nil
}

// printMachineConfig processes the machine config template and prints it without deploying
func printMachineConfig(ctx context.Context, cfg *appconfig.Config, img *imgsrc.DeploymentImage) error {
var io = iostreams.FromContext(ctx)

// Process each process group in the app config
processGroups := cfg.ProcessNames()
if len(processGroups) == 0 {
processGroups = []string{cfg.DefaultProcessName()}
}

fmt.Fprintf(io.Out, "Processing machine configuration template...\n\n")

// Process each process group
for _, group := range processGroups {
fmt.Fprintf(io.Out, "=== Machine Config for Process Group: %s ===\n", group)

// Get machine config for this process group
mConfig, err := cfg.ToMachineConfig(group, nil)
if err != nil {
return fmt.Errorf("error creating machine config for group %s: %w", group, err)
}

// Apply the image
mConfig.Image = img.Tag

// Check for machine config template from flag or experimental config
var configTemplatePath string
if flag.IsSpecified(ctx, "machine-config-template") {
configTemplatePath = flag.GetString(ctx, "machine-config-template")
} else if cfg.Experimental != nil && cfg.Experimental.MachineConfig != "" {
configTemplatePath = cfg.Experimental.MachineConfig
}

// Apply machine config from template if provided
if configTemplatePath != "" {
var buf []byte

switch {
case strings.HasPrefix(configTemplatePath, "{"):
buf = []byte(configTemplatePath)
case strings.HasSuffix(configTemplatePath, ".json"):
fo, err := os.Open(configTemplatePath)
if err != nil {
return fmt.Errorf("error reading machine config file: %w", err)
}
defer fo.Close()
buf, err = ioutil.ReadAll(fo)
if err != nil {
return fmt.Errorf("error reading machine config file: %w", err)
}
default:
return fmt.Errorf("invalid machine config source: %q", configTemplatePath)
}

// Handle nested config structure (as used in postgres-juicefs)
var nestedConfig struct {
Config fly.MachineConfig `json:"config"`
Region string `json:"region"`
}

var templateConfig fly.MachineConfig

// Try to unmarshal as a nested config first
if err := json.Unmarshal(buf, &nestedConfig); err == nil && nestedConfig.Config.Containers != nil {
templateConfig = nestedConfig.Config
} else {
// Otherwise try as a direct machine config
if err := json.Unmarshal(buf, &templateConfig); err != nil {
return fmt.Errorf("invalid machine config %q: %w", configTemplatePath, err)
}
}

// Check for required 'app' container when containers are specified
if templateConfig.Containers != nil && len(templateConfig.Containers) > 0 {
// Check if the templateConfig has an 'app' container
hasAppContainer := false
appContainerIndex := -1
for i, container := range templateConfig.Containers {
if container != nil && container.Name == "app" {
hasAppContainer = true
appContainerIndex = i
break
}
}

if !hasAppContainer {
return fmt.Errorf("machine config template with containers must include an 'app' container")
}

// Update the app container image
if appContainerIndex >= 0 {
templateConfig.Containers[appContainerIndex].Image = img.Tag
}
}

// Merge the configs, with the template taking precedence
if templateConfig.Containers != nil {
mConfig.Containers = templateConfig.Containers
}
if templateConfig.Services != nil {
mConfig.Services = templateConfig.Services
}
if templateConfig.Checks != nil {
mConfig.Checks = templateConfig.Checks
}
if templateConfig.Guest != nil {
mConfig.Guest = templateConfig.Guest
}
if templateConfig.DNS != nil {
mConfig.DNS = templateConfig.DNS
}
if templateConfig.Volumes != nil {
mConfig.Volumes = templateConfig.Volumes
}
if templateConfig.Restart != nil {
mConfig.Restart = templateConfig.Restart
}
if templateConfig.StopConfig != nil {
mConfig.StopConfig = templateConfig.StopConfig
}

// Preserve the image at the machine level
mConfig.Image = img.Tag
}

// Output the machine config as JSON
encoder := json.NewEncoder(io.Out)
encoder.SetIndent("", " ")
if err := encoder.Encode(mConfig); err != nil {
return fmt.Errorf("error encoding machine config: %w", err)
}

fmt.Fprintf(io.Out, "\n")
}

return nil
}
112 changes: 112 additions & 0 deletions internal/command/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
"bytes"
"context"
"embed"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/superfly/fly-go"
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/build/imgsrc"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/internal/flyutil"
"github.com/superfly/flyctl/internal/inmem"
Expand All @@ -21,6 +28,108 @@
//go:embed testdata
var testdata embed.FS

// Test that `printMachineConfig` correctly processes machine config templates
func TestPrintMachineConfig(t *testing.T) {
testCases := []struct {
name string
configTemplate string
expectedContainer string
}{
{
name: "Template with single container",
configTemplate: `{
"containers": {
"app": {
"image": "will-be-overridden",
"env": {
"TEST_VAR": "test_value"
}
}
}
}`,
expectedContainer: "app",
},
{
name: "Template with multiple containers",
configTemplate: `{
"containers": {
"app": {
"image": "will-be-overridden",
"env": {
"TEST_VAR": "test_value"
}
},
"sidecar": {
"image": "redis:latest",
"env": {
"REDIS_PORT": "6379"
}
}
}
}`,
expectedContainer: "app",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup test context
var buf bytes.Buffer
ios := &iostreams.IOStreams{Out: &buf, ErrOut: &buf}
ctx := iostreams.NewContext(context.Background(), ios)

// Create a temporary file for the config template
tmpfile, err := os.CreateTemp("", "machine-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpfile.Name())

_, err = tmpfile.Write([]byte(tc.configTemplate))
require.NoError(t, err)
err = tmpfile.Close()
require.NoError(t, err)

// Setup the flags
ctx = flag.NewContext(ctx, nil)
ctx = flag.WithValue(ctx, "machine-config-template", tmpfile.Name())

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: flag.WithValue

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (macos-latest, ., test)

undefined: flag.WithValue

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

undefined: flag.WithValue

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / lint

undefined: flag.WithValue (typecheck)

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (ubuntu-latest, ., test)

undefined: flag.WithValue

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (windows-latest, ., test)

undefined: flag.WithValue

Check failure on line 93 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: flag.WithValue

// Create app config and deployment image
appConfig := &appconfig.Config{
AppName: "test-app",
Experimental: &appconfig.Experimental{
MachineConfig: tmpfile.Name(),
},
}

img := &imgsrc.DeploymentImage{
Tag: "registry.fly.io/test-app:deployment-123456789",
}

// Call the function
err = printMachineConfig(ctx, appConfig, img)
require.NoError(t, err)

// Check the output
output := buf.String()
assert.Contains(t, output, "Processing machine configuration template")
assert.Contains(t, output, "Machine Config for Process Group: app")

// Parse the JSON output to verify the container structure
jsonStart := strings.Index(output, "{")
jsonEnd := strings.LastIndex(output, "}") + 1
jsonOutput := output[jsonStart:jsonEnd]

var machineConfig fly.MachineConfig
err = json.Unmarshal([]byte(jsonOutput), &machineConfig)
require.NoError(t, err)

// Verify that containers are present and properly configured
assert.NotNil(t, machineConfig.Containers, "Containers should be present in the output")
assert.Contains(t, machineConfig.Containers, tc.expectedContainer, "The 'app' container should be included")
assert.Equal(t, img.Tag, machineConfig.Image, "The image should be preserved")
})
}
}

func TestCommand_Execute(t *testing.T) {
makeTerminalLoggerQuiet(t)

Expand Down Expand Up @@ -101,3 +210,6 @@
}
})
}

func makeTerminalLoggerQuiet(t testing.TB) {

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (macos-latest, ., test)

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / lint

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (ubuntu-latest, ., test)

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test / test (windows-latest, ., test)

other declaration of makeTerminalLoggerQuiet

Check failure on line 214 in internal/command/deploy/deploy_test.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

other declaration of makeTerminalLoggerQuiet
}
Loading
Loading