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
33 changes: 30 additions & 3 deletions cmd/deploy/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ var gitOpsRenderCmd = &cobra.Command{
},
}

var gitOpsSnapshotCmd = &cobra.Command{
Use: "snapshot [module]",
Short: "Render and validate the immutable service snapshot consumed by module generators",
Args: cobra.MaximumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
ctx, done := common.NewContext()
defer done()
workspace, module, err := common.LoadRequiredModuleE(ctx, args)
if err != nil {
return err
}
env, err := orchestration.SelectEnvironment(workspace, gitOpsEnv)
if err != nil {
return err
}
result, err := gitops.RenderModuleSnapshot(ctx, workspace, module, env, gitOpsProject, cli.NewOutputSink())
if err != nil {
return err
}
cli.Info("Rendered service snapshot %s", result.Path)
cli.Info("Digest %s", result.Inventory.Digest)
return nil
},
}

var gitOpsPlanCmd = &cobra.Command{
Use: "plan [module]",
Short: "Inspect the exact GitOps publication diff",
Expand Down Expand Up @@ -243,11 +268,13 @@ var (
)

func init() {
GitOpsCmd.AddCommand(gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd)
for _, command := range []*cobra.Command{gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} {
GitOpsCmd.AddCommand(gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd)
for _, command := range []*cobra.Command{gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} {
command.Flags().StringVar(&gitOpsEnv, "env", "local", "Environment to promote")
}
gitOpsRenderCmd.Flags().StringVar(&gitOpsProject, "app-project", "", "AppProject contract for cluster-scoped resources")
for _, command := range []*cobra.Command{gitOpsSnapshotCmd, gitOpsRenderCmd} {
command.Flags().StringVar(&gitOpsProject, "app-project", "", "AppProject contract for cluster-scoped resources")
}
for _, command := range []*cobra.Command{gitOpsPlanCmd, gitOpsPublishCmd, gitOpsRollbackCmd} {
command.Flags().StringVar(&gitOpsBranch, "promotion-branch", "", "Promotion branch (deterministic default when empty)")
command.Flags().BoolVar(&gitOpsLocal, "local", false, "Use a disposable local file Git remote for k3d qualification")
Expand Down
2 changes: 1 addition & 1 deletion cmd/deploy/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestGitOpsCommandExposesCompletePromotionLifecycle(t *testing.T) {
t.Fatalf("gitops %s is not exclusively RunE", command.Name())
}
}
for _, name := range []string{"render", "plan", "publish", "observe", "rollback"} {
for _, name := range []string{"snapshot", "render", "plan", "publish", "observe", "rollback"} {
if !names[name] {
t.Errorf("gitops %s command is missing", name)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/asottile/dockerfile v3.1.0+incompatible
github.com/blang/semver v3.5.1+incompatible
github.com/briandowns/spinner v1.23.2
github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6
github.com/codefly-dev/core v0.2.52
github.com/codefly-dev/golor v0.1.3
github.com/codefly-dev/llm v0.1.0
github.com/codefly-dev/sdk-go v0.1.58
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6 h1:kxbKE3GNzNw2GkoRgLI9tI+WnD2mQO4rWmYGT4M7uzk=
github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6/go.mod h1:cTztO7gmPNZuvjGfAedqRuyTjowYOkvMCVXcZydkEFg=
github.com/codefly-dev/core v0.2.52 h1:bHudneVK/yLMxEc163v9Hm590E1Z6x7HWkZVdoA3CIA=
github.com/codefly-dev/core v0.2.52/go.mod h1:hHJm+wOsHxpxKn4UMiFqBrGy0BE56iby9yptfygbdR4=
github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc=
github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY=
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
Expand Down
2 changes: 1 addition & 1 deletion pkg/gitops/observe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func observedPublication(t *testing.T) ObserveRequest {
t.Helper()
remote := createBareRepository(t)
workspace := loadGitopsWorkspace(t, remote)
destination := filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments")
destination := filepath.Join(workspace.Dir(), "deployments", "modules", "payments")
_, err := RenderOwnedTree(context.Background(), &RenderOptions{
Destination: destination, Module: "payments", Environment: "local",
AppProject: "payments", Promotable: true,
Expand Down
108 changes: 96 additions & 12 deletions pkg/gitops/orchestrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,38 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/codefly-dev/cli/pkg/orchestration"
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
"github.com/codefly-dev/core/resources"
)

func RenderModule(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) {
destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "modules", module.Name)
return renderModuleTree(ctx, workspace, module, env, project, sink, true)
}

func RenderModuleSnapshot(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) {
return renderModuleTree(ctx, workspace, module, env, project, sink, false)
}

func renderModuleTree(
ctx context.Context,
workspace *resources.Workspace,
module *resources.Module,
env *resources.Environment,
project string,
sink orchestration.OutputSink,
includeBootstrap bool,
) (RenderResult, error) {
destination := filepath.Join(workspace.Dir(), "deployments", "modules", module.Name)
return RenderOwnedTree(ctx, &RenderOptions{
Destination: destination,
Module: module.Name, Environment: env.Name, AppProject: project,
Promotable: !env.IsK3d(),
Promotable: true,
}, func(ctx context.Context, stage string) error {
static := filepath.Join(module.Dir(), "deployment", "kustomize")
if info, err := os.Stat(static); err == nil && info.IsDir() {
if err := copyTree(static, filepath.Join(stage, "kustomize")); err != nil {
return fmt.Errorf("copy module kustomize tree: %w", err)
}
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("inspect module kustomize tree: %w", err)
}
for _, reference := range module.ServiceReferences {
service, err := module.LoadServiceFromName(ctx, reference.Name)
if err != nil {
Expand All @@ -37,16 +48,86 @@ func RenderModule(ctx context.Context, workspace *resources.Workspace, module *r
return fmt.Errorf("render service %s: %w", service.Name, err)
}
}
return nil
if !includeBootstrap {
return nil
}
return generateEnvironmentBootstrap(ctx, workspace, module, env.Name, stage)
})
}

func generateEnvironmentBootstrap(
ctx context.Context,
workspace *resources.Workspace,
module *resources.Module,
environment,
destination string,
) error {
if module.Agent == nil {
_, err := copySelectedEnvironmentBootstrap(module.Dir(), environment, destination)
return err
}
binary, err := module.Agent.Path(ctx)
if err != nil {
return fmt.Errorf("resolve module generator %s: %w", module.Agent.Identifier(), err)
}
target := filepath.Join(destination, "kustomize")
command := exec.CommandContext(
ctx,
binary,
"gitops",
module.Dir(),
workspace.Dir(),
environment,
target,
)
output, err := command.CombinedOutput()
if err != nil {
return fmt.Errorf(
"generate %s module bootstrap with %s: %w: %s",
environment,
module.Agent.Identifier(),
err,
strings.TrimSpace(string(output)),
)
}
return nil
}

func copySelectedEnvironmentBootstrap(moduleDir, environment, destination string) (bool, error) {
static := filepath.Join(moduleDir, "deployment", "kustomize")
info, err := os.Stat(static)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect module kustomize tree: %w", err)
}
if !info.IsDir() {
return false, fmt.Errorf("module kustomize path is not a directory")
}
environmentBootstrap := filepath.Join(static, "overlays", environment)
info, err = os.Stat(environmentBootstrap)
if err != nil {
return false, fmt.Errorf("inspect generated %s module bootstrap: %w", environment, err)
}
if !info.IsDir() {
return false, fmt.Errorf("generated %s module bootstrap is not a directory", environment)
}
if err := copyTree(
environmentBootstrap,
filepath.Join(destination, "kustomize", "overlays", environment),
); err != nil {
return false, fmt.Errorf("copy generated %s module bootstrap: %w", environment, err)
}
return true, nil
}

func RenderService(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, project string, standAlone bool, sink orchestration.OutputSink) (RenderResult, error) {
destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "services", module.Name, service.Name)
return RenderOwnedTree(ctx, &RenderOptions{
Destination: destination,
Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project,
Promotable: !env.IsK3d(),
Promotable: true,
}, func(ctx context.Context, stage string) error {
return renderServiceFlow(ctx, workspace, module, service, env, standAlone, sink, serviceRenderDestinations(stage))
})
Expand Down Expand Up @@ -88,6 +169,9 @@ func renderServiceFlow(
return err
}
flow.WithDeploymentDestination(destination)
flow.WithKubernetesOutputProfile(
builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1,
)
if err := flow.Deploy(ctx); err != nil {
return err
}
Expand Down
40 changes: 28 additions & 12 deletions pkg/gitops/orchestrate_test.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
package gitops

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

"github.com/codefly-dev/core/resources"
)

func TestServiceRenderDestinationsKeepDependenciesInDistinctOwnedPaths(t *testing.T) {
resolve := serviceRenderDestinations("/render")
api := resolve(&resources.Module{Name: "payments"}, &resources.Service{Name: "api"})
database := resolve(&resources.Module{Name: "platform"}, &resources.Service{Name: "postgres"})
if api != filepath.Join("/render", "modules", "payments", "services", "api") {
t.Fatalf("origin destination = %q", api)
func TestCopySelectedEnvironmentBootstrapExcludesOtherEnvironments(t *testing.T) {
module := t.TempDir()
for _, environment := range []string{"local", "aws"} {
root := filepath.Join(module, "deployment", "kustomize", "overlays", environment)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(environment+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
destination := t.TempDir()

copied, err := copySelectedEnvironmentBootstrap(module, "local", destination)
if err != nil {
t.Fatal(err)
}
if !copied {
t.Fatal("selected environment bootstrap was not copied")
}
data, err := os.ReadFile(filepath.Join(destination, "kustomize", "overlays", "local", "kustomization.yaml"))
if err != nil {
t.Fatal(err)
}
if database != filepath.Join("/render", "modules", "platform", "services", "postgres") {
t.Fatalf("dependency destination = %q", database)
if string(data) != "local\n" {
t.Fatalf("selected bootstrap = %q", data)
}
if api == database {
t.Fatal("origin and dependency render destinations collide")
if _, err := os.Stat(filepath.Join(destination, "kustomize", "overlays", "aws")); !os.IsNotExist(err) {
t.Fatalf("unselected bootstrap was copied: %v", err)
}
}
4 changes: 2 additions & 2 deletions pkg/gitops/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request
if err != nil {
return nil, err
}
rendered := filepath.Join(workspace.Dir(), "deployments", "environments", request.Environment, "modules", request.Module)
rendered := filepath.Join(workspace.Dir(), "deployments", "modules", request.Module)
var inventory Inventory
if restoreRevision == "" {
if err := ValidateRenderedTree(rendered, "", true); err != nil {
Expand All @@ -130,7 +130,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request
cleanup()
return nil, err
}
targetPath := filepath.ToSlash(filepath.Join(pathRoot, request.Environment, "modules", request.Module))
targetPath := filepath.ToSlash(filepath.Join(pathRoot, "deployments", "modules", request.Module))
target, err := confinedJoin(repo, targetPath)
if err != nil {
return fail(err)
Expand Down
4 changes: 2 additions & 2 deletions pkg/gitops/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) {
if plan.ID == "" || plan.Diff == "" || len(plan.Changed) == 0 {
t.Fatalf("publication plan is not inspectable: %+v", plan)
}
if plan.Path != "environments/production/modules/payments" {
if plan.Path != "environments/deployments/modules/payments" {
t.Fatalf("publication path = %q", plan.Path)
}
if _, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, mutationauthority.PreparedPermit{}); err == nil || !strings.Contains(err.Error(), "prepared authority") {
Expand Down Expand Up @@ -304,7 +304,7 @@ gitops:

func renderPublishFixture(t *testing.T, root, module, environment, name string) {
t.Helper()
destination := filepath.Join(root, "deployments", "environments", environment, "modules", module)
destination := filepath.Join(root, "deployments", "modules", module)
_, err := RenderOwnedTree(context.Background(), &RenderOptions{
Destination: destination, Module: module, Environment: environment, Promotable: true,
}, func(ctx context.Context, stage string) error {
Expand Down
4 changes: 2 additions & 2 deletions pkg/gitops/qualification_k3d_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestLocalK3dDisposableGitQualification(t *testing.T) {
remote := createBareRepository(t)
workspace := loadGitopsWorkspace(t, remote)
_, err := RenderOwnedTree(context.Background(), &RenderOptions{
Destination: filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments"),
Destination: filepath.Join(workspace.Dir(), "deployments", "modules", "payments"),
Module: "payments", Environment: "local", AppProject: "payments", Promotable: true,
}, func(ctx context.Context, root string) error {
if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1
Expand Down Expand Up @@ -117,7 +117,7 @@ spec:
source:
repoURL: %s
targetRevision: main
path: environments/local/modules/payments
path: environments/deployments/modules/payments
destination:
server: https://kubernetes.default.svc
namespace: payments
Expand Down
23 changes: 21 additions & 2 deletions pkg/gitops/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,10 @@ func inspectValue(value any, path []string, promotable bool) error {
if placeholderPattern.MatchString(typed) {
return fmt.Errorf("%s contains an unresolved placeholder", strings.Join(path, "."))
}
if err := validateURLValue(strings.Join(path, "."), typed); err != nil {
return err
if isURLPath(path) {
if err := validateURLValue(strings.Join(path, "."), typed); err != nil {
return err
}
}
if isAuthorityPath(path) && strings.Contains(typed, "*") {
return fmt.Errorf("%s contains wildcard authority", strings.Join(path, "."))
Expand All @@ -577,6 +579,23 @@ func inspectValue(value any, path []string, promotable bool) error {
return nil
}

func isURLPath(path []string) bool {
for index := len(path) - 1; index >= 0; index-- {
part := path[index]
if strings.HasPrefix(part, "[") {
continue
}
normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(part))
return strings.Contains(normalized, "url") ||
strings.Contains(normalized, "uri") ||
normalized == "server" ||
normalized == "repository" ||
normalized == "repo" ||
normalized == "sourcerepos"
}
return false
}

func extendPath(path []string, part string) []string {
extended := make([]string, len(path)+1)
copy(extended, path)
Expand Down
11 changes: 11 additions & 0 deletions pkg/gitops/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,17 @@ rules: []
}
}

func TestRenderAcceptsOCIImageSelectorsWithoutTreatingThemAsURLs(t *testing.T) {
err := inspectValue(map[string]any{
"images": []any{
map[string]any{"name": "image:tag"},
},
}, nil, false)
if err != nil {
t.Fatal(err)
}
}

func TestRenderAllowsOnlyClusterScopeDeclaredBySelectedProject(t *testing.T) {
manifests := pinnedDeployment + `---
apiVersion: argoproj.io/v1alpha1
Expand Down
Loading