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
9 changes: 6 additions & 3 deletions appconfig/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type AppEnvVar struct {

type BuildConfig struct {
Dockerfile string `toml:"dockerfile"`
SourceRoot string `toml:"source_root,omitempty"`
OnBuild []string `toml:"onbuild"`
Version string `toml:"version"`

Expand Down Expand Up @@ -385,8 +386,7 @@ func LoadAppConfig() (*AppConfig, error) {

// LoadAppConfigWithPath loads the app config and returns the file path it was
// loaded from. The path is also returned alongside a parse error, so a caller
// can tell which file was at fault. Returns (nil, "", nil) if no config file
// is found.
// can tell which file was at fault. Returns (nil, "", nil) if no config is found.
func LoadAppConfigWithPath() (*AppConfig, string, error) {
dir, err := os.Getwd()
if err != nil {
Expand Down Expand Up @@ -477,8 +477,11 @@ func (ac *AppConfig) Validate() error {
}
}

// Validate build secrets
// Validate build configuration
if ac.Build != nil {
if filepath.IsAbs(ac.Build.SourceRoot) {
return &ValidationError{KeyPath: "build.source_root", Message: "build.source_root must be a relative path"}
}
seen := make(map[string]bool, len(ac.Build.Secrets))
for i, bs := range ac.Build.Secrets {
if err := bs.validate(fmt.Sprintf("build.secrets[%d]", i)); err != nil {
Expand Down
142 changes: 142 additions & 0 deletions appconfig/appconfig_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,157 @@
package appconfig

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"miren.dev/runtime/pkg/tarx"
)

func TestSourceRoot(t *testing.T) {
dir := t.TempDir()
configDir := filepath.Join(dir, "services", "api")
for _, tc := range []struct {
root string
want string
}{
{"", configDir},
{".", configDir},
{"../..", dir},
{"src", filepath.Join(configDir, "src")},
} {
t.Run(tc.root, func(t *testing.T) {
ac, err := Parse([]byte("[build]\nsource_root = " + strconv.Quote(tc.root)))
require.NoError(t, err)
require.Equal(t, tc.want, ac.SourceDir(configDir))
if tc.want == configDir {
require.Empty(t, ac.UploadFiles(configDir))
}
})
}
var absent *AppConfig
require.Equal(t, configDir, absent.SourceDir(configDir))
require.Empty(t, absent.UploadFiles(configDir))
_, err := Parse([]byte("[build]\nsource_root = " + strconv.Quote(dir)))
require.ErrorContains(t, err, "build.source_root must be a relative path")
}

func TestSourceRootUploads(t *testing.T) {
for _, layout := range []string{"with default", "without default", "gitignored", "included"} {
t.Run(layout, func(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"main.go": "package main\n",
"services/api/.miren/app.toml": "name = 'api-app'\n[build]\nsource_root = '../..'\n",
"services/worker/.miren/app.toml": "name = 'background-worker'\n[build]\nsource_root = '../..'\n",
}
if layout != "without default" {
files[".miren/app.toml"] = "name = 'default-app'\n"
}
if layout == "gitignored" {
files[".gitignore"] = ".miren/\n"
}
var include []string
if layout == "included" {
include = []string{"main.go"}
}
for name, body := range files {
path := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
require.NoError(t, os.WriteFile(path, []byte(body), 0644))
}
for _, app := range []string{"api", "worker"} {
configDir := filepath.Join(dir, "services", app)
ac, err := LoadAppConfigUnder(configDir)
require.NoError(t, err)
want := files["services/"+app+"/.miren/app.toml"]
manifest, err := tarx.ComputeManifest(ac.SourceDir(configDir), include, ac.UploadFiles(configDir)...)
require.NoError(t, err)
count := 0
for _, entry := range manifest {
if entry.Path == ".miren/app.toml" {
count++
hash := sha256.Sum256([]byte(want))
require.Equal(t, hex.EncodeToString(hash[:]), entry.Hash)
require.Equal(t, int64(len(want)), entry.Size)
require.Equal(t, int32(0644), entry.Mode)
}
}
require.Equal(t, 1, count, "manifest must replace, not duplicate, the default config")
for _, mode := range []string{"full", "delta config", "cached config"} {
var written atomic.Int64
var archive io.ReadCloser
if mode == "full" {
archive, err = tarx.MakeTar(ac.SourceDir(configDir), include, &written, ac.UploadFiles(configDir)...)
} else {
needed := map[string]bool{".miren/app.toml": mode == "delta config", "main.go": mode == "cached config"}
archive, err = tarx.MakeFilteredTar(ac.SourceDir(configDir), include, needed, &written, ac.UploadFiles(configDir)...)
}
require.NoError(t, err)
packed, err := io.ReadAll(archive)
require.NoError(t, err)
gz, err := gzip.NewReader(bytes.NewReader(packed))
require.NoError(t, err)
tr := tar.NewReader(gz)
entries := make(map[string]string)
var totalBytes int64
for {
header, err := tr.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
if header.Typeflag != tar.TypeReg {
continue
}
body, err := io.ReadAll(tr)
require.NoError(t, err)
require.NotContains(t, entries, header.Name, "archive must not contain duplicate files")
entries[header.Name] = string(body)
totalBytes += int64(len(body))
}
require.NoError(t, gz.Close())
require.NoError(t, archive.Close())
require.Equal(t, totalBytes, written.Load())
if mode == "cached config" {
require.Equal(t, map[string]string{"main.go": files["main.go"]}, entries)
} else {
require.Equal(t, want, entries[".miren/app.toml"])
if mode == "delta config" {
require.Len(t, entries, 1)
} else {
require.Equal(t, files["main.go"], entries["main.go"])
}
}
extracted := t.TempDir()
_, err = tarx.TarFS(bytes.NewReader(packed), extracted)
require.NoError(t, err)
for path, want := range entries {
body, err := os.ReadFile(filepath.Join(extracted, path))
require.NoError(t, err)
require.Equal(t, want, string(body))
}
}
}
for name, want := range files {
body, err := os.ReadFile(filepath.Join(dir, name))
require.NoError(t, err)
require.Equal(t, want, string(body), "upload must not modify %s", name)
}
})
}
}

func TestCloneAddonsConfig(t *testing.T) {
for _, tc := range []struct {
config string
Expand Down
24 changes: 24 additions & 0 deletions appconfig/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package appconfig

import (
"path/filepath"

"miren.dev/runtime/pkg/tarx"
)

// SourceDir resolves the build context relative to the directory containing .miren.
func (ac *AppConfig) SourceDir(configDir string) string {
if ac == nil || ac.Build == nil || ac.Build.SourceRoot == "" {
return configDir
}
return filepath.Join(configDir, ac.Build.SourceRoot)
}

// UploadFiles projects this app's config into the build context without changing
// the source tree or using another app's config from the source root.
func (ac *AppConfig) UploadFiles(configDir string) []tarx.FileOverride {
if filepath.Clean(ac.SourceDir(configDir)) == filepath.Clean(configDir) {
return nil
}
return []tarx.FileOverride{{Path: AppConfigPath, Source: filepath.Join(configDir, AppConfigPath)}}
}
14 changes: 14 additions & 0 deletions cli/alias_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -9,6 +11,18 @@ import (
"miren.dev/runtime/appconfig"
)

func TestRunUsesNestedAppAliases(t *testing.T) {
dir := t.TempDir()
appDir := filepath.Join(dir, "services", "api")
t.Setenv("HOME", t.TempDir())
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".miren"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(appDir, ".miren"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".miren", "app.toml"), []byte("[aliases]\nwho = 'unknown-command'\n"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(appDir, ".miren", "app.toml"), []byte("[build]\nsource_root = '../..'\n[aliases]\nwho = 'version'\n"), 0644))
t.Chdir(appDir)
require.Equal(t, 0, Run([]string{"miren", "who"}))
}

func TestTokenizeCommand(t *testing.T) {
tests := []struct {
name string
Expand Down
11 changes: 5 additions & 6 deletions cli/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type AppCentric struct {
clusterPicked bool
}

// ResolvedDir returns the directory that should be used as the app source.
// ResolvedDir returns the config root, the directory containing .miren.
// When a config is found in a parent directory, this returns that parent
// directory instead of the original Dir value.
func (a *AppCentric) ResolvedDir() string {
Expand Down Expand Up @@ -73,7 +73,7 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error {
}
if err == nil && ac != nil && configPath != "" {
// Config was found — always record the resolved directory.
configDir := filepath.Dir(filepath.Dir(configPath)) // strip .miren/app.toml
configDir := filepath.Dir(filepath.Dir(configPath))
a.resolvedDir = configDir
if cwd, wdErr := os.Getwd(); wdErr == nil && configDir != cwd {
a.foundInParent = true
Expand Down Expand Up @@ -120,11 +120,12 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error {
return fmt.Errorf("app is required; pass -a <name> or run from a directory with .miren/app.toml")
} else {
// No app name from flag or config — try to help the user.
noAppMsg := fmt.Sprintf("no app configuration found at %s — run 'miren init' to get started, or pass -a <name>", appconfig.AppConfigPath)
workDir := a.Dir
if workDir == "." || workDir == "" {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("no app configuration found — run 'miren init' to get started, or pass -a <name>")
return fmt.Errorf("%s", noAppMsg)
}
workDir = wd
} else {
Expand All @@ -136,14 +137,12 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error {

appName := inferAppName(workDir)

noAppMsg := "no app configuration found — run 'miren init' to get started, or pass -a <name>"

if !a.isInteractive() {
return fmt.Errorf("%s", noAppMsg)
}

confirmed, err := ui.Confirm(
ui.WithMessage(fmt.Sprintf("Looks like this directory isn't set up yet. Run 'miren init' to create app %q here?", appName)),
ui.WithMessage(fmt.Sprintf("No app configuration found at %s. Run 'miren init' to create it for app %q?", appconfig.AppConfigPath, appName)),
ui.WithDefault(true),
ui.WithIndent(" "),
)
Expand Down
30 changes: 17 additions & 13 deletions cli/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ func jsonlExitError(deployErr, streamErr error) error {
// app is routable) so the caller can report a partial result on failure.
func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, events *deployEventStream) error {
name := opts.App
dir := opts.ResolvedDir()
configDir := opts.ResolvedDir()

// A nil *deployEventStream must not become a non-nil interface.
var healthObs healthObserver
Expand Down Expand Up @@ -411,7 +411,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
}

bc := build_v1alpha.NewBuilderClient(cl)
return analyzeApp(ctx, bc, dir)
return analyzeApp(ctx, bc, configDir)
}

// Handle --version flag: deploy an existing version (skip build)
Expand Down Expand Up @@ -526,6 +526,12 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
return nil
}

ac, err := appconfig.LoadAppConfigUnder(configDir)
if err != nil {
return fmt.Errorf("failed to load app config: %w", err)
}
dir := ac.SourceDir(configDir)

// Set up OTel tracing via the server's telemetry proxy.
// Spans are shipped through the existing RPC connection — no client-side
// OTLP config needed. If the server isn't reachable, tracing is a no-op.
Expand Down Expand Up @@ -563,7 +569,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
needsConfirm := opts.foundInParent || (!hasSingleCluster && !opts.clusterPicked)
if !opts.Yes && !opts.Force && isInteractive && needsConfirm {
if opts.foundInParent {
ctx.Printf(" ℹ App config found in parent directory %s\n", dir)
ctx.Printf(" ℹ App config found in parent directory %s\n", configDir)
}
message := fmt.Sprintf("Deploy app '%s' to cluster '%s'?", name, ctx.ClusterName)
confirmed, err := ui.Confirm(
Expand Down Expand Up @@ -602,7 +608,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
serverOwnsDeployment := ephemeralLabel == "" && cl.HasMethodParam(ctx.Context, "buildFromTar", "deployment")

// Check if deployment is allowed before proceeding
remedy, err := deploygating.CheckDeployAllowed(dir)
remedy, err := deploygating.CheckDeployAllowed(configDir)
if err != nil {
if remedy != "" {
ctx.Printf("Error: %s\n%s\n\n", err, remedy)
Expand Down Expand Up @@ -949,11 +955,6 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even

// Load AppConfig to get include patterns
var includePatterns []string
ac, err := appconfig.LoadAppConfigUnder(dir)
if err != nil {
updateDeploymentOnError(fmt.Sprintf("Failed to load app config: %v", err))
return err
}
if ac != nil && ac.Include != nil {
// Validate patterns before using them
for _, pattern := range ac.Include {
Expand All @@ -980,7 +981,8 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
neededPaths map[string]bool
)

manifest, manifestErr := tarx.ComputeManifest(dir, includePatterns)
uploadFiles := ac.UploadFiles(configDir)
manifest, manifestErr := tarx.ComputeManifest(dir, includePatterns, uploadFiles...)
if manifestErr == nil {
totalFiles = len(manifest)

Expand Down Expand Up @@ -1038,11 +1040,11 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even
var uncompressedWritten atomic.Int64
var r io.ReadCloser
if useOptimized && len(neededPaths) > 0 {
r, err = tarx.MakeFilteredTar(dir, includePatterns, neededPaths, &uncompressedWritten)
r, err = tarx.MakeFilteredTar(dir, includePatterns, neededPaths, &uncompressedWritten, uploadFiles...)
} else if useOptimized {
r = tarx.MakeEmptyTar()
} else {
r, err = tarx.MakeTar(dir, includePatterns, &uncompressedWritten)
r, err = tarx.MakeTar(dir, includePatterns, &uncompressedWritten, uploadFiles...)
}
if err != nil {
uploadSpan.RecordError(err)
Expand Down Expand Up @@ -2244,7 +2246,9 @@ func analyzeApp(ctx *Context, bc *build_v1alpha.BuilderClient, dir string) error
includePatterns = ac.Include
}

r, err := tarx.MakeTar(dir, includePatterns, nil)
configDir := dir
dir = ac.SourceDir(configDir)
r, err := tarx.MakeTar(dir, includePatterns, nil, ac.UploadFiles(configDir)...)
if err != nil {
return fmt.Errorf("failed to create tar: %w", err)
}
Expand Down
Loading
Loading