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
15 changes: 13 additions & 2 deletions cmd/grounds/commands/push/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -27,13 +28,14 @@ func NewPushCommand() *cobra.Command {

func newPush() *cobra.Command {
var target string
var flavor string
var force bool
var local []string
var withLocal bool
cmd := &cobra.Command{
Use: "push [--target=dev|staging] [--force] [--local=<id>[,<id>]] [--with-local]",
Use: "push [--target=dev|staging] [--flavor=<key>] [--force] [--local=<id>[,<id>]] [--with-local]",
Short: "Build via Gradle plugin and deploy to a target",
Example: " grounds push\n grounds push --target=staging\n grounds push --force\n grounds push --local=plugin-chat\n grounds push --with-local",
Example: " grounds push\n grounds push --flavor=velocity\n grounds push --target=staging\n grounds push --force\n grounds push --local=plugin-chat\n grounds push --with-local",
Long: `Build the current project with the grounds-push Gradle plugin and deploy it.

Targets:
Expand All @@ -49,6 +51,7 @@ image moved under a stable tag, or to re-observe the build flow.`,
if target != "dev" && target != "staging" {
return fmt.Errorf("invalid --target %q: must be \"dev\" or \"staging\"", target)
}
flavor = strings.TrimSpace(flavor)
cwd, err := os.Getwd()
if err != nil {
return err
Expand Down Expand Up @@ -80,6 +83,9 @@ image moved under a stable tag, or to re-observe the build flow.`,
}

args := []string{"groundsPush", "--target=" + target}
if flavor != "" {
args = append(args, "--flavor="+flavor)
}
Comment thread
lusu007 marked this conversation as resolved.
if force {
args = append(args, "--force")
}
Expand All @@ -92,6 +98,7 @@ image moved under a stable tag, or to re-observe the build flow.`,
plan, err := internalworkspace.Resolve(ctx, manifestPath, workspaceConfig, internalworkspace.ResolveOptions{
LocalIDs: local,
WithLocal: withLocal,
Flavor: flavor,
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
})
Expand Down Expand Up @@ -120,6 +127,10 @@ image moved under a stable tag, or to re-observe the build flow.`,
_ = cmd.RegisterFlagCompletionFunc("target", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return []string{"dev", "staging"}, cobra.ShellCompDirectiveNoFileComp
})
cmd.Flags().StringVar(&flavor, "flavor", "", "app flavor from grounds.yaml flavors (for example paper or velocity)")
_ = cmd.RegisterFlagCompletionFunc("flavor", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
cmd.Flags().BoolVar(&force, "force", false, "skip contentHash dedup and force a fresh build")
cmd.Flags().StringArrayVar(&local, "local", nil, "use local workspace override for plugin id (repeatable, comma-separated)")
cmd.Flags().BoolVar(&withLocal, "with-local", false, "use all enabled local workspace overrides present in grounds.yaml")
Expand Down
54 changes: 54 additions & 0 deletions cmd/grounds/commands/push/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -67,6 +69,54 @@ func TestPushTargetCompletion(t *testing.T) {
}
}

func TestPushFlavorFlagIsForwardedToGradle(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell gradle wrapper")
}
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(cwd); err != nil {
t.Fatalf("Chdir(%q) error = %v", cwd, err)
}
})
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir(%q) error = %v", dir, err)
}
if err := os.WriteFile("grounds.yaml", []byte("name: plugin-config\n"), 0o644); err != nil {
t.Fatalf("WriteFile(grounds.yaml) error = %v", err)
}
argsPath := filepath.Join(dir, "args.txt")
wrapper := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + shellQuote(argsPath) + "\n"
if err := os.WriteFile("gradlew", []byte(wrapper), 0o755); err != nil {
t.Fatalf("WriteFile(gradlew) error = %v", err)
}
t.Setenv("GROUNDS_TOKEN", "test-token")

cmd := newPush()
cmd.SetArgs([]string{"--flavor= velocity "})
cmd.SilenceUsage = true
cmd.SilenceErrors = true

if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw, err := os.ReadFile(argsPath)
if err != nil {
t.Fatalf("ReadFile(args) error = %v", err)
}
got := string(raw)
if !strings.Contains(got, "groundsPush\n") || !strings.Contains(got, "--flavor=velocity\n") {
t.Fatalf("gradle args = %q, want groundsPush and --flavor=velocity", got)
}
if strings.Contains(got, "--flavor= velocity ") {
t.Fatalf("gradle args = %q, want normalized flavor value", got)
}
}

func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) {
cmd := NewPushCommand()

Expand All @@ -89,6 +139,10 @@ func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) {
}
}

func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

func TestPushRootRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) {
for _, args := range [][]string{
{"definitely-not-a-command"},
Expand Down
42 changes: 38 additions & 4 deletions internal/workspace/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
type ResolveOptions struct {
LocalIDs []string
WithLocal bool
Flavor string
Stdout io.Writer
Stderr io.Writer
}
Expand Down Expand Up @@ -76,7 +77,7 @@ func NormalizeLocalIDs(values []string) []string {
}

func Resolve(ctx context.Context, manifestPath string, cfg *Config, opts ResolveOptions) (*Plan, error) {
plugins, err := loadManifestPlugins(manifestPath)
plugins, err := loadManifestPlugins(manifestPath, opts.Flavor)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -141,20 +142,53 @@ func WritePlanFile(path string, plan *Plan) error {
return os.WriteFile(path, raw, 0o600)
}

func loadManifestPlugins(path string) ([]manifestPlugin, error) {
func loadManifestPlugins(path, flavor string) ([]manifestPlugin, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc struct {
Plugins []yaml.Node `yaml:"plugins"`
Flavors map[string]struct {
Plugins []yaml.Node `yaml:"plugins"`
} `yaml:"flavors"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, err
}
if len(doc.Flavors) > 0 {
if len(doc.Plugins) > 0 {
return nil, fmt.Errorf("grounds.yaml: found both top-level plugins and flavors; use only one")
}
return parseFlavorManifestPlugins(doc.Flavors, flavor)
}
return parsePluginNodes(doc.Plugins)
Comment thread
lusu007 marked this conversation as resolved.
}

func parseFlavorManifestPlugins(flavors map[string]struct {
Plugins []yaml.Node `yaml:"plugins"`
}, flavor string) ([]manifestPlugin, error) {
flavor = strings.TrimSpace(flavor)
keys := make([]string, 0, len(flavors))
for key := range flavors {
keys = append(keys, key)
}
sort.Strings(keys)
available := strings.Join(keys, ", ")
if flavor == "" {
return nil, fmt.Errorf("grounds.yaml: flavor selection required (available=%s)", available)
}
selected, ok := flavors[flavor]
if !ok {
return nil, fmt.Errorf("grounds.yaml: unknown flavor %q (available=%s)", flavor, available)
}
return parsePluginNodes(selected.Plugins)
}

func parsePluginNodes(nodes []yaml.Node) ([]manifestPlugin, error) {
var plugins []manifestPlugin
for i := range doc.Plugins {
node := doc.Plugins[i]
for i := range nodes {
node := nodes[i]
switch node.Kind {
case yaml.ScalarNode:
source := strings.TrimSpace(node.Value)
Expand Down
97 changes: 97 additions & 0 deletions internal/workspace/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,103 @@ plugins:
}
}

func TestResolveUsesSelectedManifestFlavorPlugins(t *testing.T) {
app := t.TempDir()
writeFile(t, filepath.Join(app, "grounds.yaml"), `
name: plugin-config
flavors:
paper:
type: paper
baseImage: paper
plugins:
- id: plugin-chat
variant: paper
source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat-paper.jar
velocity:
type: velocity
baseImage: velocity
plugins:
- id: plugin-chat
variant: velocity
source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat-velocity.jar
- id: plugin-proxy
variant: velocity
source: github:groundsgg/plugin-proxy@v1.0.0:plugin-proxy.jar
`)
repo := t.TempDir()
mkdirAll(t, filepath.Join(repo, "velocity", "build", "libs"))
writeFile(t, filepath.Join(repo, "velocity", "build", "libs", "plugin-chat.jar"), "jar")
initGitRepo(t, repo)

plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{
"plugin-chat": {
Path: repo,
Variants: map[string]Variant{
"velocity": {Artifact: "velocity/build/libs/*.jar", Enabled: true},
},
},
}}, ResolveOptions{
Flavor: "velocity",
LocalIDs: []string{"plugin-chat"},
WithLocal: true,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if len(plan.Plugins) != 2 {
t.Fatalf("Plugins len = %d, want 2", len(plan.Plugins))
}
if plan.Plugins[0].Variant != "velocity" || plan.Plugins[0].LocalPath == "" {
t.Fatalf("plugin-chat should use local velocity variant: %#v", plan.Plugins[0])
}
if plan.Plugins[1].Source != "github:groundsgg/plugin-proxy@v1.0.0:plugin-proxy.jar" {
t.Fatalf("second selected flavor plugin = %#v", plan.Plugins[1])
}
}

func TestResolveRequiresFlavorForFlavorManifest(t *testing.T) {
app := t.TempDir()
writeFile(t, filepath.Join(app, "grounds.yaml"), `
name: plugin-config
flavors:
paper:
type: paper
baseImage: paper
plugins:
- id: plugin-chat
source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar
`)

_, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{WithLocal: true})
if err == nil || !strings.Contains(err.Error(), "flavor selection required") {
t.Fatalf("Resolve() error = %v, want flavor selection error", err)
}
}

func TestResolveRejectsManifestWithTopLevelPluginsAndFlavors(t *testing.T) {
app := t.TempDir()
writeFile(t, filepath.Join(app, "grounds.yaml"), `
name: plugin-config
plugins:
- github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar
flavors:
paper:
type: paper
baseImage: paper
plugins:
- id: plugin-chat
source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar
`)

_, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{})
if err == nil {
t.Fatal("Resolve() error = nil, want mixed plugins/flavors error")
}
if !strings.Contains(err.Error(), "grounds.yaml: found both top-level plugins and flavors") {
t.Fatalf("Resolve() error = %v, want prefixed mixed plugins/flavors error", err)
}
}

func TestResolveWithLocalSelectsEnabledSingleVariantForLegacyPluginString(t *testing.T) {
app := t.TempDir()
writeFile(t, filepath.Join(app, "grounds.yaml"), "plugins:\n - github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar\n")
Expand Down
Loading