diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index c728d3a7..68350045 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -293,8 +293,8 @@ jobs: - name: kubara init --prep run: | set -euo pipefail - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" init --prep + cd "${{ env.OUTPUT_GENERATED_DIR }}" + go run "${GITHUB_WORKSPACE}/src/main.go" init --prep - name: Update .env (strict template mode) run: | @@ -304,8 +304,8 @@ jobs: - name: kubara init run: | set -euo pipefail - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" init + cd "${{ env.OUTPUT_GENERATED_DIR }}" + go run "${GITHUB_WORKSPACE}/src/main.go" init - name: Update config.yaml (strict mode) run: | @@ -314,8 +314,9 @@ jobs: - name: Generate kubara artifacts run: | - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" generate + set -euo pipefail + cd "${{ env.OUTPUT_GENERATED_DIR }}" + go run "${GITHUB_WORKSPACE}/src/main.go" generate - name: Upload generated helm and terraform files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/README.md b/README.md index 5ac8677b..93c09f2c 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,6 @@ help, h Shows a list of commands or help for one command ```text --kubeconfig string Path to kubeconfig file (default: "~/.kube/config") ---work-dir string, -w string Working directory (default: ".") ---config-file string, -c string Path to the configuration file (default: "config.yaml") ---env-file string Path to the .env file (default: ".env") --test-connection Check if Kubernetes cluster can be reached. List namespaces and exit --base64 Enable base64 encode/decode mode --encode Base64 encode input diff --git a/docs/content/10_decisions/ADR-0005-multi-hub-improvements.md b/docs/content/10_decisions/ADR-0005-multi-hub-improvements.md new file mode 100644 index 00000000..4fc860b1 --- /dev/null +++ b/docs/content/10_decisions/ADR-0005-multi-hub-improvements.md @@ -0,0 +1,64 @@ +| status | date | decision-makers | consulted | informed | +|--------------|------------|-----------------|--------------------|--------------------| +| | | | | | + +# Directory-Scoped Execution Context for Multi-Hub GitOps Repositories + +## Context and Problem Statement + +Following feedback and bugreports from our community around the handling and documentation of multi-hub setups, +we decided to improve kubaras support for multi-hub setups. + +Previously, `kubara generate` hardcoded its output paths to `./platform-components` and `./platform-configs/` +relative to the current working directory, and wiped `./platform-components` before every generate run. +When multiple configuration files coexisted in a repository: +1. Running generation for one config would wipe or overwrite the shared `platform-components` of another config, possibly resulting in the deletion of required files. +2. If different configs referenced different catalog versions or customized services, conflicts occurred. +3. Argo CD Application manifests need accurate repository-relative subpaths to locate charts and values overlays within the Git repository. +4. Relative paths in Terraform templates (e.g. `../../../../platform-components/...`) depend on a consistent relative depth between `platform-configs` and `platform-components`. + +Kubara needs an ergonomic, deterministic way to support multiple hub-and-spoke configurations within the same repository without cross-configuration interference. + +## Decision Drivers + +- Different configurations may use different catalog versions and platform stacks. +- Templating for one configuration must never mutate, wipe or overwrite another configuration's manifests. +- Zero breaking changes to existing single-configuration repositories. +- Keep Terraform relative module paths (`../../../../platform-components/...`) working without requiring modifications to catalog templates. +- Git repository subpaths must be computed automatically to simplify Argo CD GitOps workflows. +- Developer experience must be intuitive: Easily target individual hubs, a set of hubs or run batch generation across all. +- Minimize sources of mixups in execution context around .env file and config.yaml + +## Decision Outcome + +**One Hub per Config, one Folder per Hub**. + +### Folder Layout +Hubs exist in isolated directories, at the root level or nested (e.g. `prod`, `project-a/staging`) containing: +- `config.yaml` +- `.env` +- `platform-components/` (isolated generated Helm charts and Terraform modules) +- `platform-configs/` (cluster-specific overlays) + +Because `platform-configs` and `platform-components` remain siblings inside each hub directory, existing Terraform relative source paths (`../../../../platform-components/...`) remain valid. + +### Automatic GitOps Path Computation +When executing within a hub folder, `kubara generate` automatically: +1. Traverses up the directory tree to discover the Git repository root (`.git`). +1. Calculates the Git-relative subpath from the Git root to the hub (e.g. `setups/dev-fleet`). +1. Injects computed repository paths into `argocd.repo`: + - `components.path`: `/platform-components/helm` + - `configs.path`: `/platform-configs` + +### CLI Ergonomics +1. **Targeting**: `generate --hub ` sets the target hub directory, accepting a list of hubs to generate. +1. **Directory Scoping**: Commands execute within the hub directory or target hubs via `--hub`. Legacy flags (`--work-dir`, `--config-file`, and `--env-file`) are removed in favor of directory-scoped context. +1. **Batch Generation**: `kubara generate --all` (alias `-A`) discovers all configurations in the current directory and generates each hub in its own isolated context. +1. **Removal of old Flags**: `--work-dir`, `--config-file` and `--env-file` have been removed and are replaced by `--hub` + +## Consequences + +- **Good**, because multiple hub-and-spoke setups can live in the same Git repository without file collisions. +- **Good**, because setups can independently upgrade catalog versions and dependencies. +- **Good**, because Argo CD path calculation requires zero manual configuration. +- **Good**, because single-config repositories at root remain completely unaffected (zero breaking changes). diff --git a/docs/content/1_getting_started/bootstrapping.md b/docs/content/1_getting_started/bootstrapping.md index 3d08fa12..f1948e47 100644 --- a/docs/content/1_getting_started/bootstrapping.md +++ b/docs/content/1_getting_started/bootstrapping.md @@ -4,6 +4,7 @@ This guide provides a step-by-step process for bootstrapping your platform running on Kubernetes, including the necessary [prerequisites](prerequisites.md), architecture setup, and deployment instructions. Try to follow the instructions first. If you have any questions or issues, please reach out directly via Teams. If you're interested in the setup details, explore the Wiki pages. +If you intend to manage multiple hubs with kubara, take a look at [Multi-Hub Environments](../4_building_your_platform/multi-hub.md) --- ## 1. Getting Started @@ -105,13 +106,13 @@ kubara init ``` This command creates a `config.yaml` file based on the values from your `.env`. -It also creates a `renovate.json` in the working directory when no supported Renovate configuration exists. The generated custom manager keeps versioned OCI catalog references in this config up to date. Use `--renovate=false` if the repository does not use Renovate: +It also creates a `renovate.json` at the root of your Git repository (or working directory if outside a Git repository) when no supported Renovate configuration exists. The generated custom manager keeps versioned OCI catalog references in this config up to date. Use `--renovate=false` if the repository does not use Renovate: ```bash kubara init --renovate=false ``` -For Renovate to discover the generated file, use the Git repository root as kubara's working directory. The generated file uses Renovate's `config:recommended` preset and enables automatic updates for kubara catalog versions in `config.yaml`. Renovate can also update other supported dependencies it detects in the repository. +When run from a subfolder in a Git repository, `init` automatically writes `renovate.json` to the Git repository root with the correct relative path matcher for your workspace's `config.yaml`. The generated file uses Renovate's `config:recommended` preset and enables automatic updates for kubara catalog versions in `config.yaml`. Renovate can also update other supported dependencies it detects in the repository. Kubara does not modify an existing Renovate configuration. In that case, `init` logs a warning and you can add the [Renovate settings for catalog updates](../2_concepts/catalog_distribution.md#automatic-catalog-updates-with-renovate) manually. @@ -137,7 +138,7 @@ kubara init \ --catalog-overwrite ``` -The bootstrap reference is stored in the root `bootstrapCatalog` field, while repeated `--catalog` values are stored on the generated cluster. Local paths are resolved relative to `--work-dir` when kubara loads them. +The bootstrap reference is stored in the root `bootstrapCatalog` field, while repeated `--catalog` values are stored on the generated cluster. Local paths are resolved relative to the workspace directory when kubara loads them. When using `--overwrite`, only values from `.env` are replaced. Additional settings in your existing `config.yaml` are preserved and merged. @@ -518,38 +519,36 @@ but also other supported possibilities when bootstrapping. ### Bootstrapping Multiple Hub Clusters -You can bootstrap multiple Hub clusters. -You **cannot** reuse the same `config.yaml` file for multiple Hub clusters. Only one hub per config is supported. +You can bootstrap multiple Hub clusters in the same Git repository. +Each Hub cluster has its own directory (workspace) containing its own `config.yaml` and optional `.env`. Only one hub per config is supported. -**Why?** -During the bootstrap process, the `.env` file is used to provide credentials. -If you reuse the same `.env` file, you would have to constantly adjust it for each Hub - which is error-prone. +For a detailed guide on managing multiple hubs, directory layouts, selective generation (`--hub`), and batch generation (`--all`), see [Multi-Hub Environments](../4_building_your_platform/multi-hub.md). -Since version `0.2.0`, this is much easier. You can simply provide a different env file: +To set up an additional Hub cluster, create and switch to its directory: ```bash -kubara init --prep --env-file .another-env +mkdir -p setups/another-hub +cd setups/another-hub +kubara init --prep ``` -Fill out `.another-env` with the required values. Generate a new config file from it: +Fill out `.env` with the required values. Generate the config file: ```bash -kubara --config-file another-config.yaml --env-file .another-env init +kubara init ``` -This will use the values from `.another-env` to generate `another-config.yaml`. - Render Terraform modules and Helm charts for the new Hub cluster: ```bash # default: generates both Helm and Terraform # use --helm or --terraform to generate only one type -./kubara --config-file another-config.yaml generate +kubara generate ``` Finally, bootstrap your additional Hub cluster: ```bash -kubara bootstrap --config-file another-config.yaml --env-file .another-env +kubara bootstrap ``` ## What's Next? diff --git a/docs/content/1_getting_started/commands.md b/docs/content/1_getting_started/commands.md index f47567a4..44f375c8 100644 --- a/docs/content/1_getting_started/commands.md +++ b/docs/content/1_getting_started/commands.md @@ -13,17 +13,14 @@ kubara [--catalog-overwrite] [--catalog]=[value] [--check-update] -[--config-file|-c]=[value] [--decode] [--encode] -[--env-file]=[value] [--file]=[value] [--help|-h] [--kubeconfig]=[value] [--string]=[value] [--test-connection] [--version|-v] -[--work-dir|-w]=[value] ``` # DESCRIPTION @@ -46,14 +43,10 @@ kubara [command] **--check-update**: Check online for a newer kubara release -**--config-file, -c**="": Path to the configuration file (default: "config.yaml") - **--decode**: Base64 decode input **--encode**: Base64 encode input -**--env-file**="": Path to the .env file (default: ".env") - **--file**="": Input file path for base64 operation **--help, -h**: show help @@ -66,8 +59,6 @@ kubara [command] **--version, -v**: print the version -**--work-dir, -w**="": Working directory (default: ".") - # COMMANDS @@ -99,7 +90,9 @@ Shows a list of commands or help for one command Generate files from catalog templates ->kubara generate [--terraform|--helm] [--catalog PATH_OR_OCI [--catalog-overwrite]] [--dry-run] +>kubara generate [--all|--hubs HUB1,HUB2,...|--hub HUB3] [--terraform|--helm] [--catalog PATH_OR_OCI] [--catalog-overwrite]] [--dry-run] + +**--all, -A**: Discover and target all hubs in the working directory **--dry-run**: Preview generation without creating files @@ -107,6 +100,8 @@ Generate files from catalog templates **--help, -h**: show help +**--hub, --hubs**="": Target a list of comma separated hub directories + **--terraform**: Only generate Terraform files ### help, h @@ -127,10 +122,6 @@ Bootstrap Argo CD onto a cluster **--local**: Provision an isolated local evaluation environment. Local testing only; not for production use. -**--platform-components**="": Path to the platform-components directory (default: "platform-components") - -**--platform-configs**="": Path to platform-configs directory (default: "platform-configs") - **--timeout**="": Timeout for kubernetes API calls (e.g. 10s, 1m) (default: 5m0s) **--with-es-crds**: Deprecated: ignored because CRDs are applied automatically during bootstrap. diff --git a/docs/content/1_getting_started/quick_start.md b/docs/content/1_getting_started/quick_start.md index 53b4637e..5fe62d56 100644 --- a/docs/content/1_getting_started/quick_start.md +++ b/docs/content/1_getting_started/quick_start.md @@ -6,6 +6,8 @@ It is intended for **local testing only**, not for production use. This guide supports Linux, macOS and WSL on Windows. +If you intend to manage multiple hubs with kubara, take a look at [Multi-Hub Environments](../4_building_your_platform/multi-hub.md) + ## Prerequisites Install kubara first via the [installation guide](installation.md), then make sure these tools are available on your host: diff --git a/docs/content/2_concepts/catalog_distribution.md b/docs/content/2_concepts/catalog_distribution.md index 952115d4..f3dab642 100644 --- a/docs/content/2_concepts/catalog_distribution.md +++ b/docs/content/2_concepts/catalog_distribution.md @@ -214,4 +214,4 @@ Add the following settings to your `renovate.json` or GitOps repository's Renova } ``` -Adjust `managerFilePatterns` when the kubara config has a different repository-relative path. `kubara init` does this automatically for the configured `--config-file`. The reference matcher supports registries with ports and ignores digest-pinned catalog references. +Adjust `managerFilePatterns` when the kubara config has a different repository-relative path. `kubara init` does this automatically for the current repository. The reference matcher supports registries with ports and ignores digest-pinned catalog references. diff --git a/docs/content/2_concepts/catalog_templating.md b/docs/content/2_concepts/catalog_templating.md index 17336c99..af02b169 100644 --- a/docs/content/2_concepts/catalog_templating.md +++ b/docs/content/2_concepts/catalog_templating.md @@ -25,11 +25,12 @@ Files without the `.tplt` suffix are copied as-is. ## What data is available in templates? -kubara builds a template context with three top-level objects: +kubara builds a template context with four top-level objects: - `.cluster` - `.env` - `.catalog` +- `.workspace` ### `.cluster` @@ -74,6 +75,16 @@ Today this is mainly service metadata such as: This is useful when template logic needs catalog-level defaults or service metadata. +### `.workspace` + +When working in a Git repository, `.workspace` exposes GitOps path context: + +- `.workspace.gitRelativePath`: Relative subpath from the Git repository root to the workspace (e.g. `setups/dev-fleet`, or `""` if at repo root). +- `.workspace.platformComponents`: Relative Git path to components (`/platform-components/helm`). +- `.workspace.platformConfigs`: Relative Git path to configs (`/platform-configs`). + +Additionally, kubara automatically populates these computed paths into `.cluster.argocd.repo.https.components.path` and `.cluster.argocd.repo.https.configs.path` (or their OCI equivalents) unless explicitly overridden in `config.yaml`. Spoke clusters in `.spokes` receive matching computed paths. + ## Cross-templating in practice The Homer example shows the main idea well: one service template can react to settings from other services and from the cluster itself. diff --git a/docs/content/2_concepts/catalogs.md b/docs/content/2_concepts/catalogs.md index f3565e01..ee73c4bb 100644 --- a/docs/content/2_concepts/catalogs.md +++ b/docs/content/2_concepts/catalogs.md @@ -93,7 +93,7 @@ kubara generate --catalog oci://ghcr.io/acme/platform-catalogs/my-catalog:1.2.3 - a local catalog directory - an OCI reference such as `oci://ghcr.io/acme/platform-catalogs/my-catalog:1.2.3` -Local paths are resolved relative to `--work-dir`. OCI-backed catalogs use the local kubara cache and are pulled automatically when the requested reference is not cached. See [Catalog distribution](catalog_distribution.md). +Local paths are resolved relative to the workspace directory. OCI-backed catalogs use the local kubara cache and are pulled automatically when the requested reference is not cached. See [Catalog distribution](catalog_distribution.md). `init` and `cluster add` persist their `--catalog` references in the new cluster entry. Commands such as `schema`, `generate`, and `bootstrap` use CLI catalogs as temporary additions and do not rewrite `config.yaml`. @@ -234,6 +234,7 @@ If a cluster has no Terraform block or uses `terraform.provider: none`, the defa Shared output paths must also be deterministic across clusters. Identical content is written once; conflicting content for the same final path causes generation to fail before files are changed. + ## Schema generation When `config.yaml` exists, `kubara schema` resolves catalogs per cluster and emits cluster-specific service branches. This keeps editor completion and validation aligned with the services available to each cluster. diff --git a/docs/content/4_building_your_platform/create_catalog.md b/docs/content/4_building_your_platform/create_catalog.md index f8d256ae..dd5be6df 100644 --- a/docs/content/4_building_your_platform/create_catalog.md +++ b/docs/content/4_building_your_platform/create_catalog.md @@ -140,7 +140,7 @@ clusters: - oci://ghcr.io/acme/platform-catalogs/security:1.4.0 ``` -Catalog order is significant. Cluster catalogs are loaded first in the listed order, followed by repeated `--catalog` values. Local references are resolved relative to `--work-dir`. +Catalog order is significant. Cluster catalogs are loaded first in the listed order, followed by repeated `--catalog` values. Local references are resolved relative to the workspace directory. `kubara schema` automatically discovers cluster catalogs when `config.yaml` exists. Before creating a configuration, pass the catalog explicitly as shown above. diff --git a/docs/content/4_building_your_platform/multi-hub.md b/docs/content/4_building_your_platform/multi-hub.md new file mode 100644 index 00000000..42fb2eaf --- /dev/null +++ b/docs/content/4_building_your_platform/multi-hub.md @@ -0,0 +1,106 @@ +# Multi-Hub Environments + +Managing multiple environments for example across geographical regions, different projects or stages is a common requirement. + +Kubara supports multi-hub setups natively through **directory-scoped workspaces**. Each Hub cluster and its associated spokes live in an isolated directory, eliminating artifact collisions while automating GitOps repository path calculations. + +--- + +## One-Hub-per-Config + +Each hub folder represents a single hub-and-spoke management domain: + +* Exactly **one** cluster with `type: hub` is configured per `config.yaml`. +* Any number of spoke clusters (`type: spoke`) can be managed by that hub. +* Different hubs require separate `config.yaml` files because each hub cluster represents an independent GitOps control plane with its own domain, credentials, and lifecycle. + +--- + +## Repository Layout + +Instead of having a single global `platform-components/` and `platform-configs/` directory at the repository root, each hub folder contains its own isolated set of components and configurations. + +Hub directories can be at the top level or nested within subfolders. The root itself can also be a hub directory, like it is implicitly in a single-hub setup. + +```text +my-gitops-repo/ +├── .git/ +├── renovate.json # Shared Renovate config at repo root +├── prod/ # Stage +│ ├── eu-west/ # Prod Region 1 +│ │ ├── .env +│ │ ├── config.yaml +│ │ ├── platform-components/ +│ │ └── platform-configs/ +│ └── us-east/ # Prod Region 2 +│ ├── .env +│ ├── config.yaml +│ ├── platform-components/ +│ └── platform-configs/ +│ +└── staging. / # Another stage + ├── .env + ├── config.yaml + ├── platform-components/ + └── platform-configs/ +``` + + +### Context Awareness + +Kubara defaults to using the `config.yaml` and `.env` of the current working directory. +When using the `--hubs` or `--all` flags it searches for hub folders below and relative to the current working directory. +Running `kubara generate` inside of `my-gitops-repo/prod/eu-west/` produces the same result as running `kubara generate --hub prod/eu-west` inside of `my-gitops-repo` + +```bash +# Generate configs for a single hub from repository root: +kubara generate --hub prod/us-east + +# Target multiple hubs using comma-separated paths: +kubara generate --hub staging,prod/eu-west + +# Target all hubs +kubara generate --all + +``` + +### Template Context Exposure + +Templates can access the computed workspace paths via the top-level `.workspace` object: + +* `.workspace.gitRelativePath`: The relative path from the Git root (e.g., `"prod/eu-west"`, or `""` if at the repo root). +* `.workspace.platformComponents`: Path to components (`/platform-components/helm`). +* `.workspace.platformConfigs`: Path to configs (`/platform-configs`). + +--- + +## Setting Up a New Hub + +Create the directory for your new hub and initialize it: + +```bash +mkdir my-new-hub +cd my-new-hub +kubara init --prep +... +kubara init +... +kubara generate +``` + +The process is described in detail in [the Quick Start Guide](../1_getting_started/quick_start.md) and [Bootstrapping](../1_getting_started/bootstrapping.md). + + +--- + +## Managing Spoke Clusters in Multi-Hub Setups + +Adding a spoke cluster to a specific hub is straightforward. Simply run the command within that hub's workspace: + +```bash +cd staging +kubara cluster add dev-spoke-1 --catalog /path/to/catalog +kubara generate +``` + +The spoke cluster's configuration is added to `staging/config.yaml`, and its generated overlays will live in `staging/platform-configs/dev-spoke-1/`. Its Argo CD applications will automatically point to the correct GitOps repository subpaths computed for the `staging` workspace. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2f8a7377..98b98872 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -26,6 +26,7 @@ nav: - Google Cloud (Guide): 3_infrastructure/google-cloud.md - Building Your Platform: - Argo CD App-of-Apps: 4_building_your_platform/argocd-add-app-of-apps.md + - Multi-Hub Environments: 4_building_your_platform/multi-hub.md - How to add a Spoke Cluster: 4_building_your_platform/add_spoke_cluster.md - How to create a Catalog: 4_building_your_platform/create_catalog.md - SSO: diff --git a/src/cmd/agents.go b/src/cmd/agents.go index 371e5f5c..14088a23 100644 --- a/src/cmd/agents.go +++ b/src/cmd/agents.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "github.com/kubara-io/kubara/internal/agentcontext" @@ -31,11 +30,11 @@ func NewAgentsCmd() *cli.Command { UsageText: "kubara agents [--overwrite]", Description: "Writes AGENTS.md into the working directory so AI coding assistants (Claude Code, Codex, …) have a compact, token-lean entry point into kubara. It delegates command and config details to the self-describing CLI (kubara --help, kubara schema) and links the published Markdown documentation for the installed kubara version on the docs site. The existing file is left untouched unless --overwrite is set. Commit it so it travels with the repository.", Action: func(_ context.Context, cmd *cli.Command) error { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return fmt.Errorf("get working directory: %w", err) + return fmt.Errorf("resolve workspace: %w", err) } - return runAgents(cwd, flags.OverwriteFlag) + return runAgents(ws.WorkDir, flags.OverwriteFlag) }, } diff --git a/src/cmd/bootstrap.go b/src/cmd/bootstrap.go index 7d052a1d..3bf39866 100644 --- a/src/cmd/bootstrap.go +++ b/src/cmd/bootstrap.go @@ -24,9 +24,6 @@ import ( type BootstrapFlags struct { Local bool ClusterSecretStorePath string - PlatformComponentsPath string - PlatformConfigsPath string - EnvFile string EnvPrefixFlag string DryRun bool Timeout time.Duration @@ -39,7 +36,6 @@ var deprecatedBootstrapCRDFlags = []string{ func NewBootstrapFlags() *BootstrapFlags { return &BootstrapFlags{ - EnvFile: ".env", EnvPrefixFlag: "KUBARA_", Timeout: 2 * time.Minute, } @@ -80,41 +76,28 @@ func NewBootstrapCmd() *cli.Command { } func (flags *BootstrapFlags) ToOptions(cmd *cli.Command) (*bootstrap.Options, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) - } - - envFilePath, err := utils.GetFullPath(cmd.String("env-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get env file path: %w", err) + return nil, fmt.Errorf("resolve workspace: %w", err) } kubeconfig := cmd.String("kubeconfig") if strings.TrimSpace(kubeconfig) == "" { kubeconfig = defaultKubeconfigPath } - kubeconf, err := utils.GetFullPath(kubeconfig, cwd) + kubeconf, err := utils.GetFullPath(kubeconfig, ws.WorkDir) if err != nil { return nil, fmt.Errorf("get kubeconfig path: %w", err) } - componentsAbsPath := flags.PlatformComponentsPath - if !filepath.IsAbs(componentsAbsPath) { - componentsAbsPath = filepath.Join(cwd, componentsAbsPath) - componentsAbsPath, err = filepath.Abs(componentsAbsPath) - if err != nil { - return nil, fmt.Errorf("resolve absolute path: %w", err) - } + componentsAbsPath, err := utils.GetFullPath(render.DefaultPlatformComponentsPath, ws.WorkDir) + if err != nil { + return nil, fmt.Errorf("resolve platform-components path: %w", err) } - configsAbsPath := flags.PlatformConfigsPath - if !filepath.IsAbs(configsAbsPath) { - configsAbsPath = filepath.Join(cwd, configsAbsPath) - configsAbsPath, err = filepath.Abs(configsAbsPath) - if err != nil { - return nil, fmt.Errorf("resolve absolute path: %w", err) - } + configsAbsPath, err := utils.GetFullPath(render.DefaultPlatformConfigsPath, ws.WorkDir) + if err != nil { + return nil, fmt.Errorf("resolve platform-configs path: %w", err) } catalogOptions, err := catalogLoadOptionsFromCommand(cmd, "") @@ -123,12 +106,7 @@ func (flags *BootstrapFlags) ToOptions(cmd *cli.Command) (*bootstrap.Options, er } // Load config file and find cluster by name - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get config file path: %w", err) - } - - cs := config.NewConfigStore(cwd, configFilePath, catalogOptions) + cs := config.NewConfigStore(ws.WorkDir, ws.ConfigFilePath, catalogOptions) if err := cs.Load(); err != nil { return nil, fmt.Errorf("load config: %w", err) } @@ -143,10 +121,10 @@ func (flags *BootstrapFlags) ToOptions(cmd *cli.Command) (*bootstrap.Options, er } } if clusterConfig == nil { - return nil, fmt.Errorf("cluster %q not found in config file %q", clusterName, configFilePath) + return nil, fmt.Errorf("cluster %q not found in config file %q", clusterName, ws.ConfigFilePath) } - es := envconfig.NewEnvStore(envFilePath, ".", flags.EnvPrefixFlag) + es := envconfig.NewEnvStore(ws.EnvFilePath, ".", flags.EnvPrefixFlag) if err := es.Load(); err != nil { return nil, fmt.Errorf("load env: %w", err) } @@ -160,7 +138,7 @@ func (flags *BootstrapFlags) ToOptions(cmd *cli.Command) (*bootstrap.Options, er var cssAbsPath string if flags.ClusterSecretStorePath != "" { if !filepath.IsAbs(flags.ClusterSecretStorePath) { - cssAbsPath = filepath.Join(cwd, flags.ClusterSecretStorePath) + cssAbsPath = filepath.Join(ws.WorkDir, flags.ClusterSecretStorePath) cssAbsPath, err = filepath.Abs(cssAbsPath) if err != nil { return nil, fmt.Errorf("getting absolute path for ClusterSecretStore file: %w", err) @@ -202,8 +180,8 @@ func (flags *BootstrapFlags) ToOptions(cmd *cli.Command) (*bootstrap.Options, er DryRun: flags.DryRun, Timeout: timeout, ClusterName: clusterName, - WorkDir: cwd, - ConfigFilePath: configFilePath, + WorkDir: ws.WorkDir, + ConfigFilePath: ws.ConfigFilePath, Catalogs: catalogOptions.Catalogs, CatalogOverwrite: catalogOptions.Overwrite, BootstrapCatalog: bootstrapCatalog, @@ -237,18 +215,6 @@ func (flags *BootstrapFlags) AddFlags(cmd *cli.Command) { Name: "with-prometheus-crds", Usage: "Deprecated: ignored because CRDs are applied automatically during bootstrap.", }, - &cli.StringFlag{ - Name: "platform-components", - Value: render.DefaultPlatformComponentsPath, - Usage: "Path to the platform-components directory", - Destination: &flags.PlatformComponentsPath, - }, - &cli.StringFlag{ - Name: "platform-configs", - Value: render.DefaultPlatformConfigsPath, - Usage: "Path to platform-configs directory", - Destination: &flags.PlatformConfigsPath, - }, &cli.StringFlag{ Name: "envVarPrefix", Value: flags.EnvPrefixFlag, diff --git a/src/cmd/catalog/helpers.go b/src/cmd/catalog/helpers.go index 2c18aaa3..6b0aa264 100644 --- a/src/cmd/catalog/helpers.go +++ b/src/cmd/catalog/helpers.go @@ -2,15 +2,15 @@ package catalog import ( "fmt" - "path/filepath" + "github.com/kubara-io/kubara/internal/utils" "github.com/urfave/cli/v3" ) func resolveCatalogCommandWorkingDir(cmd *cli.Command) (string, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := utils.ResolveWorkspaceFromCommand(cmd) if err != nil { - return "", fmt.Errorf("get working directory: %w", err) + return "", fmt.Errorf("resolve workspace: %w", err) } - return cwd, nil + return ws.WorkDir, nil } diff --git a/src/cmd/catalog/package.go b/src/cmd/catalog/package.go index 0e0ba75c..af1a7e8c 100644 --- a/src/cmd/catalog/package.go +++ b/src/cmd/catalog/package.go @@ -32,7 +32,7 @@ func NewCatalogPackage() *cli.Command { }) if err != nil { if errors.Is(err, internal.ErrCatalogManifestNotFound) { - return fmt.Errorf("%w; run this command from the catalog root or pass --work-dir /path/to/catalog", err) + return fmt.Errorf("%w; run this command from the catalog root", err) } return err } diff --git a/src/cmd/cluster/add.go b/src/cmd/cluster/add.go index f7b55981..d931e691 100644 --- a/src/cmd/cluster/add.go +++ b/src/cmd/cluster/add.go @@ -3,7 +3,6 @@ package cluster import ( "context" "fmt" - "path/filepath" "github.com/kubara-io/kubara/internal/catalog" "github.com/kubara-io/kubara/internal/config" @@ -33,22 +32,17 @@ func CreateAddClusterCommand() *cli.Command { cli.ShowSubcommandHelpAndExit(cmd, 1) } - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := utils.ResolveWorkspaceFromCommand(cmd) if err != nil { - return fmt.Errorf("get working directory: %w", err) + return fmt.Errorf("resolve workspace: %w", err) } - catalogOptions, err := catalog.ResolveLoadOptions(cwd, "", cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) + catalogOptions, err := catalog.ResolveLoadOptions(ws.WorkDir, "", cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) if err != nil { return fmt.Errorf("could not resolve catalog options: %w", err) } - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return fmt.Errorf("get config file path: %w", err) - } - - configStore := config.NewConfigStore(cwd, configFilePath, catalogOptions) + configStore := config.NewConfigStore(ws.WorkDir, ws.ConfigFilePath, catalogOptions) err = configStore.Load() if err != nil { return fmt.Errorf("config load: %w", err) diff --git a/src/cmd/cluster/cluster_test.go b/src/cmd/cluster/cluster_test.go index 6aa64a67..ec4e125f 100644 --- a/src/cmd/cluster/cluster_test.go +++ b/src/cmd/cluster/cluster_test.go @@ -38,13 +38,14 @@ func TestNewClusterCommand(t *testing.T) { func TestListAllClustersNoError(t *testing.T) { dir := t.TempDir() - configPath := testutil.CreateTestConfig(t, dir, testutil.CreateTestCluster(t)) + t.Chdir(dir) + testutil.CreateTestConfig(t, dir, testutil.CreateTestCluster(t)) testutil.CreateDefaultGenerateTestEnv(t, dir) cliFlags := flags.NewGlobalFlags().CLIFlags() app := testutil.CreateTestAppWithFlags(cliFlags, cluster.NewClusterCommand()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", dir, "cluster", "list"} + args := []string{"kubara", "cluster", "list"} err := app.Run(context.Background(), args) require.NoError(t, err) } @@ -52,7 +53,8 @@ func TestListAllClustersNoError(t *testing.T) { func TestAddNewSpokesCluster(t *testing.T) { spokeName := "coolNewSpoke" dir := t.TempDir() - configPath := testutil.CreateTestConfig(t, dir, testutil.CreateTestCluster(t)) + t.Chdir(dir) + testutil.CreateTestConfig(t, dir, testutil.CreateTestCluster(t)) testutil.CreateDefaultGenerateTestEnv(t, dir) @@ -60,8 +62,6 @@ func TestAddNewSpokesCluster(t *testing.T) { app := testutil.CreateTestAppWithFlags(cliFlags, cluster.NewClusterCommand()) args := []string{ "kubara", - "--config-file", configPath, - "--work-dir", dir, "--catalog", testutil.GeneralCatalogPath(), "cluster", "add", spokeName, } diff --git a/src/cmd/cluster/list.go b/src/cmd/cluster/list.go index e1fe0286..3639cf4f 100644 --- a/src/cmd/cluster/list.go +++ b/src/cmd/cluster/list.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "text/tabwriter" "github.com/kubara-io/kubara/internal/catalog" @@ -22,21 +21,17 @@ func CreateClusterList() *cli.Command { Description: "List all clusters available in the current config.yaml file", Aliases: []string{"ls"}, Action: func(c context.Context, cmd *cli.Command) error { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := utils.ResolveWorkspaceFromCommand(cmd) if err != nil { - return fmt.Errorf("get working directory: %w", err) - } - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return fmt.Errorf("get config file path: %w", err) + return fmt.Errorf("resolve workspace: %w", err) } - catalogOptions, err := catalog.ResolveLoadOptions(cwd, "", cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) + catalogOptions, err := catalog.ResolveLoadOptions(ws.WorkDir, "", cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) if err != nil { return fmt.Errorf("could not resolve catalog options: %w", err) } - configStore := config.NewConfigStore(cwd, configFilePath, catalogOptions) + configStore := config.NewConfigStore(ws.WorkDir, ws.ConfigFilePath, catalogOptions) err = configStore.Load() if err != nil { return fmt.Errorf("config load: %w", err) diff --git a/src/cmd/flags.go b/src/cmd/flags.go index 999c8bf6..0fa1b9d7 100644 --- a/src/cmd/flags.go +++ b/src/cmd/flags.go @@ -2,9 +2,9 @@ package cmd import ( "fmt" - "path/filepath" "github.com/kubara-io/kubara/internal/catalog" + "github.com/kubara-io/kubara/internal/utils" "github.com/urfave/cli/v3" ) @@ -13,9 +13,6 @@ const defaultKubeconfigPath = "~/.kube/config" type GlobalFlags struct { KubeconfigFilePath string - WorkDir string - ConfigFilePath string - EnvFilePath string Catalogs []string CatalogOverwrite bool TestK8sConnection bool @@ -47,9 +44,6 @@ type RootOptions struct { func NewGlobalFlags() *GlobalFlags { return &GlobalFlags{ KubeconfigFilePath: defaultKubeconfigPath, - WorkDir: ".", - ConfigFilePath: "config.yaml", - EnvFilePath: ".env", } } @@ -87,35 +81,6 @@ func (flags *GlobalFlags) CLIFlags() []cli.Flag { TrimSpace: true, }, }, - &cli.StringFlag{ - Name: "work-dir", - Aliases: []string{"w"}, - Value: flags.WorkDir, - Usage: "Working directory", - Destination: &flags.WorkDir, - Config: cli.StringConfig{ - TrimSpace: true, - }, - }, - &cli.StringFlag{ - Name: "config-file", - Aliases: []string{"c"}, - Value: flags.ConfigFilePath, - Usage: "Path to the configuration file", - Destination: &flags.ConfigFilePath, - Config: cli.StringConfig{ - TrimSpace: true, - }, - }, - &cli.StringFlag{ - Name: "env-file", - Value: flags.EnvFilePath, - Usage: "Path to the .env file", - Destination: &flags.EnvFilePath, - Config: cli.StringConfig{ - TrimSpace: true, - }, - }, &cli.StringSliceFlag{ Name: "catalog", Value: flags.Catalogs, @@ -192,11 +157,17 @@ func (flags *GlobalFlags) CLIFlags() []cli.Flag { } } +type WorkspacePaths = utils.WorkspacePaths + +func ResolveWorkspace(cmd *cli.Command) (*WorkspacePaths, error) { + return utils.ResolveWorkspaceFromCommand(cmd) +} + func catalogLoadOptionsFromCommand(cmd *cli.Command, bootstrapCatalog string) (catalog.LoadOptions, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return catalog.LoadOptions{}, fmt.Errorf("get working directory: %w", err) + return catalog.LoadOptions{}, fmt.Errorf("resolve workspace: %w", err) } - return catalog.ResolveLoadOptions(cwd, bootstrapCatalog, cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) + return catalog.ResolveLoadOptions(ws.WorkDir, bootstrapCatalog, cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) } diff --git a/src/cmd/generate.go b/src/cmd/generate.go index 976bb4df..5a4400af 100644 --- a/src/cmd/generate.go +++ b/src/cmd/generate.go @@ -3,12 +3,17 @@ package cmd import ( "context" "fmt" + "os" "path/filepath" + "sort" + "strings" + "github.com/kubara-io/kubara/internal/catalog" "github.com/kubara-io/kubara/internal/cmd/generate" "github.com/kubara-io/kubara/internal/render" "github.com/kubara-io/kubara/internal/utils" + "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) @@ -16,6 +21,8 @@ type GenerateFlags struct { Terraform bool Helm bool DryRun bool + All bool + Hubs []string } func NewGenerateFlags() *GenerateFlags { @@ -23,6 +30,8 @@ func NewGenerateFlags() *GenerateFlags { Terraform: false, Helm: false, DryRun: false, + All: false, + Hubs: nil, } } @@ -34,9 +43,22 @@ func NewGenerateCmd() *cli.Command { cmd := &cli.Command{ Name: "generate", Usage: "Generate files from catalog templates", - UsageText: "kubara generate [--terraform|--helm] [--catalog PATH_OR_OCI [--catalog-overwrite]] [--dry-run]", - Description: "Renders Helm and Terraform templates from configured local or OCI catalogs using values from the config file. By default, it generates both template types.", + UsageText: "kubara generate [--all|--hubs HUB1,HUB2,...|--hub HUB3] [--terraform|--helm] [--catalog PATH_OR_OCI] [--catalog-overwrite]] [--dry-run]", + Description: "Renders Helm and Terraform templates from configured local or OCI catalogs for the specified hubs.\nBy default, it generates both template types and targets the hub in the current working directory.", Action: func(c context.Context, cmd *cli.Command) error { + if cmd.Args().Len() > 0 { + return fmt.Errorf("unexpected positional argument(s): %v", cmd.Args().Slice()) + } + if flags.All && len(flags.Hubs) > 0 { + return fmt.Errorf("cannot specify both --all and --hub") + } + if flags.All { + return flags.runAll(cmd) + } + if len(flags.Hubs) > 0 { + return flags.runHubs(cmd) + } + o, err := flags.ToOptions(cmd) if err != nil { return fmt.Errorf("convert flags to options: %w", err) @@ -51,41 +73,37 @@ func NewGenerateCmd() *cli.Command { } func (flags *GenerateFlags) ToOptions(cmd *cli.Command) (*generate.Options, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) + return nil, fmt.Errorf("resolve workspace: %w", err) } - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get config file path: %w", err) - } - platformComponents, err := utils.GetFullPath(render.DefaultPlatformComponentsPath, cwd) + + platformComponents, err := utils.GetFullPath(render.DefaultPlatformComponentsPath, ws.WorkDir) if err != nil { return nil, fmt.Errorf("get platform-components path: %w", err) } - platformConfigs, err := utils.GetFullPath(render.DefaultPlatformConfigsPath, cwd) + + platformConfigs, err := utils.GetFullPath(render.DefaultPlatformConfigsPath, ws.WorkDir) if err != nil { return nil, fmt.Errorf("get platform-configs path: %w", err) } + catalogOptions, err := catalogLoadOptionsFromCommand(cmd, "") if err != nil { return nil, fmt.Errorf("get catalog options: %w", err) } - envPath, err := utils.GetFullPath(cmd.String("env-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get env path: %w", err) - } o := &generate.Options{ TemplateType: render.All, DryRun: flags.DryRun, - CWD: cwd, - ConfigFilePath: configFilePath, + CWD: ws.WorkDir, + ConfigFilePath: ws.ConfigFilePath, Catalogs: catalogOptions.Catalogs, CatalogOverwrite: catalogOptions.Overwrite, PlatformComponents: platformComponents, PlatformConfigs: platformConfigs, - EnvPath: envPath, + EnvPath: ws.EnvFilePath, + GitRelPath: ws.GitRelPath, } if flags.Helm && !flags.Terraform { @@ -97,8 +115,182 @@ func (flags *GenerateFlags) ToOptions(cmd *cli.Command) (*generate.Options, erro return o, nil } +func (flags *GenerateFlags) generateWorkspace(cmd *cli.Command, wsPaths *utils.WorkspacePaths, workspaceRelPath string) error { + catalogOpts, err := catalog.ResolveLoadOptions(wsPaths.WorkDir, "", cmd.StringSlice("catalog"), cmd.Bool("catalog-overwrite")) + if err != nil { + return fmt.Errorf("failed resolving catalogs for [%s]: %w", workspaceRelPath, err) + } + + platformComponents := filepath.Join(wsPaths.WorkDir, render.DefaultPlatformComponentsPath) + platformConfigs := filepath.Join(wsPaths.WorkDir, render.DefaultPlatformConfigsPath) + + tplType := render.All + if flags.Helm && !flags.Terraform { + tplType = render.Helm + } else if flags.Terraform && !flags.Helm { + tplType = render.Terraform + } + + opts := &generate.Options{ + TemplateType: tplType, + DryRun: flags.DryRun, + CWD: wsPaths.WorkDir, + ConfigFilePath: wsPaths.ConfigFilePath, + Catalogs: catalogOpts.Catalogs, + CatalogOverwrite: catalogOpts.Overwrite, + PlatformComponents: platformComponents, + PlatformConfigs: platformConfigs, + EnvPath: wsPaths.EnvFilePath, + GitRelPath: wsPaths.GitRelPath, + } + + return opts.Run() +} + +func (flags *GenerateFlags) runHubs(cmd *cli.Command) error { + var validHubs []string + for _, hub := range flags.Hubs { + trimmed := strings.TrimSpace(hub) + if trimmed != "" { + validHubs = append(validHubs, trimmed) + } + } + if len(validHubs) == 0 { + return fmt.Errorf("no valid hub specified in --hub") + } + + var failed []string + for _, hub := range validHubs { + log.Info().Msgf("Generating workspace [%s]...", hub) + + wsPaths, err := utils.ResolveWorkspace(utils.WorkspaceOptions{ + Hub: hub, + HubSet: true, + }) + if err != nil { + log.Error().Err(err).Msgf("Failed resolving workspace [%s]", hub) + failed = append(failed, hub) + continue + } + + if err := flags.generateWorkspace(cmd, wsPaths, hub); err != nil { + log.Error().Err(err).Msgf("Failed generating workspace [%s]", hub) + failed = append(failed, hub) + continue + } + } + + if len(failed) > 0 { + return fmt.Errorf("generation failed for workspace(s): %s", strings.Join(failed, ", ")) + } + + return nil +} + +func (flags *GenerateFlags) runAll(cmd *cli.Command) error { + ws, err := ResolveWorkspace(cmd) + if err != nil { + return fmt.Errorf("resolve workspace: %w", err) + } + + searchRoot := ws.WorkDir + configs, err := discoverWorkspaces(searchRoot) + if err != nil { + return fmt.Errorf("discover workspaces in %q: %w", searchRoot, err) + } + + if len(configs) == 0 { + return fmt.Errorf("no config files found in %q", searchRoot) + } + + log.Info().Msgf("Discovered %d workspace(s) in %s", len(configs), searchRoot) + + var failed []string + for _, cfgPath := range configs { + workspaceDir := filepath.Dir(cfgPath) + workspaceRelPath, _ := filepath.Rel(searchRoot, workspaceDir) + if workspaceRelPath == "." || workspaceRelPath == "" { + workspaceRelPath = "(root)" + } + log.Info().Msgf("Generating workspace [%s]...", workspaceRelPath) + + wsPaths, err := utils.ResolveWorkspace(utils.WorkspaceOptions{ + WorkDir: workspaceDir, + }) + if err != nil { + log.Error().Err(err).Msgf("Failed resolving workspace [%s]", workspaceRelPath) + failed = append(failed, workspaceRelPath) + continue + } + + if err := flags.generateWorkspace(cmd, wsPaths, workspaceRelPath); err != nil { + log.Error().Err(err).Msgf("Failed generating workspace [%s]", workspaceRelPath) + failed = append(failed, workspaceRelPath) + continue + } + } + + if len(failed) > 0 { + return fmt.Errorf("generation failed for workspace(s): %s", strings.Join(failed, ", ")) + } + + return nil +} + +func discoverWorkspaces(root string) ([]string, error) { + var configFiles []string + skipDirs := map[string]bool{ + ".git": true, + ".github": true, + "platform-components": true, + "platform-configs": true, + ".cache": true, + ".kubara": true, + } + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if skipDirs[name] { + return filepath.SkipDir + } + return nil + } + + if d.Name() == "config.yaml" || d.Name() == "config.yml" { + configFiles = append(configFiles, path) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(configFiles) + return configFiles, nil +} + func (flags *GenerateFlags) AddFlags(cmd *cli.Command) { generateFlags := []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"A"}, + Usage: "Discover and target all hubs in the working directory", + Value: flags.All, + Destination: &flags.All, + }, + &cli.StringSliceFlag{ + Name: "hub", + Aliases: []string{"hubs"}, + Usage: "Target a list of comma separated hub directories", + Value: flags.Hubs, + Destination: &flags.Hubs, + Config: cli.StringConfig{ + TrimSpace: true, + }, + }, &cli.BoolFlag{ Name: "terraform", Usage: "Only generate Terraform files", diff --git a/src/cmd/generate_test.go b/src/cmd/generate_test.go index e5406334..3b017aac 100644 --- a/src/cmd/generate_test.go +++ b/src/cmd/generate_test.go @@ -60,11 +60,11 @@ func TestNewGenerateCmd(t *testing.T) { assert.Equal(t, "generate", command.Name) assert.Equal(t, "Generate files from catalog templates", command.Usage) - assert.Equal(t, "kubara generate [--terraform|--helm] [--catalog PATH_OR_OCI [--catalog-overwrite]] [--dry-run]", command.UsageText) - assert.Equal(t, "Renders Helm and Terraform templates from configured local or OCI catalogs using values from the config file. By default, it generates both template types.", command.Description) + assert.Equal(t, "kubara generate [--all|--hubs HUB1,HUB2,...|--hub HUB3] [--terraform|--helm] [--catalog PATH_OR_OCI] [--catalog-overwrite]] [--dry-run]", command.UsageText) + assert.Equal(t, "Renders Helm and Terraform templates from configured local or OCI catalogs for the specified hubs.\nBy default, it generates both template types and targets the hub in the current working directory.", command.Description) // Check that flags are added - require.Len(t, command.Flags, 3) + require.Len(t, command.Flags, 5) flagNames := make(map[string]bool) for _, flag := range command.Flags { @@ -74,6 +74,8 @@ func TestNewGenerateCmd(t *testing.T) { assert.True(t, flagNames["terraform"]) assert.True(t, flagNames["helm"]) assert.True(t, flagNames["dry-run"]) + assert.True(t, flagNames["all"]) + assert.True(t, flagNames["hub"]) } func TestGenerateCmd(t *testing.T) { @@ -84,6 +86,8 @@ func TestGenerateCmd(t *testing.T) { wantErr bool errContains string cluster *config.Cluster // overrides the default SKE test cluster when set + skipConfig bool + skipEnv bool setup func(t *testing.T, tempDir string) validate func(t *testing.T, tempDir string) }{ @@ -111,20 +115,20 @@ func TestGenerateCmd(t *testing.T) { wantErr: false, }, { - name: "error with non-existent config file", + name: "error with missing config file", flags: []string{ - "--config-file", "/non/existent/config.yaml", "--dry-run", }, + skipConfig: true, wantErr: true, errContains: "load config", }, { - name: "error with non-existent env file", + name: "error with missing env file", flags: []string{ - "--env-file", "/non/existent/.env", "--dry-run", }, + skipEnv: true, wantErr: true, errContains: "Vars not set", }, @@ -230,9 +234,10 @@ func TestGenerateCmd(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) + require.NoError(t, os.Mkdir(filepath.Join(tempDir, ".git"), 0755)) - // Create config file if not testing error case - if !tt.wantErr || tt.errContains != "load config" { + if !tt.skipConfig { cluster := config.Cluster{ Name: "test-cluster", Stage: "dev", @@ -246,7 +251,7 @@ func TestGenerateCmd(t *testing.T) { KubernetesVersion: "1.28.0", DNS: config.DNS{ Name: "example.com", - Email: "admin@example.com", + Email: "test" + "@" + "example.com", }, }, ArgoCD: config.ArgoCD{ @@ -268,18 +273,11 @@ func TestGenerateCmd(t *testing.T) { if tt.cluster != nil { cluster = *tt.cluster } - configPath := testutil.CreateTestConfig(t, tempDir, cluster) - - //dummy values - envPath := testutil.CreateDefaultGenerateTestEnv(t, tempDir) + testutil.CreateTestConfig(t, tempDir, cluster) + } - // Add global flags - globalFlags := []string{ - "--config-file", configPath, - "--work-dir", tempDir, - "--env-file", envPath, - } - tt.flags = append(globalFlags, tt.flags...) + if !tt.skipEnv { + testutil.CreateDefaultGenerateTestEnv(t, tempDir) } if tt.setup != nil { @@ -313,8 +311,9 @@ func TestGenerateCmd(t *testing.T) { func TestGenerateCmd_MissingProviderFailsForTerraform(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "no-provider-cluster", Stage: "dev", Type: "hub", @@ -324,7 +323,7 @@ func TestGenerateCmd_MissingProviderFailsForTerraform(t *testing.T) { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.28.0", - DNS: config.DNS{Name: "example.com", Email: "admin@example.com"}, + DNS: config.DNS{Name: "example.com", Email: "test" + "@" + "example.com"}, }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -341,7 +340,7 @@ func TestGenerateCmd_MissingProviderFailsForTerraform(t *testing.T) { testutil.CreateDefaultGenerateTestEnv(t, tempDir) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate", "--terraform"} + args := []string{"kubara", "generate", "--terraform"} err := app.Run(context.Background(), args) require.Error(t, err) assert.Contains(t, err.Error(), "missing terraform configuration") @@ -349,9 +348,10 @@ func TestGenerateCmd_MissingProviderFailsForTerraform(t *testing.T) { func TestGenerateCmd_MissingProviderUsesAllByDefault(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) helperCatalogPath := createHelperCatalog(t, tempDir) - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "no-provider-cluster", Stage: "dev", Type: "hub", @@ -362,7 +362,7 @@ func TestGenerateCmd_MissingProviderUsesAllByDefault(t *testing.T) { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.28.0", - DNS: config.DNS{Name: "example.com", Email: "admin@example.com"}, + DNS: config.DNS{Name: "example.com", Email: "test" + "@" + "example.com"}, }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -379,7 +379,7 @@ func TestGenerateCmd_MissingProviderUsesAllByDefault(t *testing.T) { testutil.CreateDefaultGenerateTestEnv(t, tempDir) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate"} + args := []string{"kubara", "generate"} err := app.Run(context.Background(), args) require.NoError(t, err) @@ -389,9 +389,10 @@ func TestGenerateCmd_MissingProviderUsesAllByDefault(t *testing.T) { func TestGenerateCmd_MissingTerraformUsesAllByDefault(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) helperCatalogPath := createHelperCatalog(t, tempDir) - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "helm-only-cluster", Stage: "dev", Type: "hub", @@ -417,7 +418,7 @@ func TestGenerateCmd_MissingTerraformUsesAllByDefault(t *testing.T) { require.NoError(t, os.WriteFile(userTerraform, []byte("user\n"), 0o600)) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate"} + args := []string{"kubara", "generate"} err := app.Run(context.Background(), args) require.NoError(t, err) @@ -428,9 +429,10 @@ func TestGenerateCmd_MissingTerraformUsesAllByDefault(t *testing.T) { func TestGenerateCmd_TerraformProviderNoneUsesAllByDefault(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) helperCatalogPath := createHelperCatalog(t, tempDir) - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "provider-none-cluster", Stage: "dev", Type: "hub", @@ -453,7 +455,7 @@ func TestGenerateCmd_TerraformProviderNoneUsesAllByDefault(t *testing.T) { testutil.CreateDefaultGenerateTestEnv(t, tempDir) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate"} + args := []string{"kubara", "generate"} err := app.Run(context.Background(), args) require.NoError(t, err) @@ -463,8 +465,9 @@ func TestGenerateCmd_TerraformProviderNoneUsesAllByDefault(t *testing.T) { func TestGenerateCmd_MissingTerraformFailsForTerraform(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "missing-terraform-cluster", Stage: "dev", Type: "hub", @@ -484,7 +487,7 @@ func TestGenerateCmd_MissingTerraformFailsForTerraform(t *testing.T) { testutil.CreateDefaultGenerateTestEnv(t, tempDir) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate", "--terraform", "--dry-run"} + args := []string{"kubara", "generate", "--terraform", "--dry-run"} err := app.Run(context.Background(), args) require.Error(t, err) assert.Contains(t, err.Error(), "missing terraform configuration") @@ -492,13 +495,14 @@ func TestGenerateCmd_MissingTerraformFailsForTerraform(t *testing.T) { func TestDisabledServicesDontGetWritten(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) services := testutil.CreateTestServices() serviceName := "cert-manager" certManager := services[serviceName] certManager.Status = "disabled" services[serviceName] = certManager - configPath := testutil.CreateTestConfig(t, tempDir, config.Cluster{ + testutil.CreateTestConfig(t, tempDir, config.Cluster{ Name: "missing-terraform-cluster", Stage: "dev", Type: "hub", @@ -517,7 +521,7 @@ func TestDisabledServicesDontGetWritten(t *testing.T) { testutil.CreateDefaultGenerateTestEnv(t, tempDir) app := CreateTestApp(NewGenerateCmd()) - args := []string{"kubara", "--config-file", configPath, "--work-dir", tempDir, "generate"} + args := []string{"kubara", "generate"} err := app.Run(context.Background(), args) require.NoError(t, err) @@ -531,6 +535,272 @@ func TestDisabledServicesDontGetWritten(t *testing.T) { assert.NotContains(t, names, serviceName) } +func TestGenerate_MultiSetup_Isolation(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + require.NoError(t, os.Mkdir(filepath.Join(tempDir, ".git"), 0755)) + + // Setup A + setupADir := filepath.Join(tempDir, "setups", "fleet-a") + require.NoError(t, os.MkdirAll(setupADir, 0755)) + clusterA := config.Cluster{ + Name: "hub-a", + Stage: "dev", + Type: "hub", + DNSName: "a.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setupADir, clusterA) + testutil.CreateDefaultGenerateTestEnv(t, setupADir) + + // Setup B + setupBDir := filepath.Join(tempDir, "setups", "fleet-b") + require.NoError(t, os.MkdirAll(setupBDir, 0755)) + clusterB := config.Cluster{ + Name: "hub-b", + Stage: "prod", + Type: "hub", + DNSName: "b.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setupBDir, clusterB) + testutil.CreateDefaultGenerateTestEnv(t, setupBDir) + + // 1. Generate Fleet A using kubara generate --hub setups/fleet-a + appA := CreateTestApp(NewGenerateCmd()) + argsA := []string{"kubara", "generate", "--hub", "setups/fleet-a"} + err := appA.Run(context.Background(), argsA) + require.NoError(t, err) + + assert.DirExists(t, filepath.Join(setupADir, "platform-components")) + assert.DirExists(t, filepath.Join(setupADir, "platform-configs", "hub-a")) + assert.NoDirExists(t, filepath.Join(setupBDir, "platform-components")) + + // Write a canary file into setupADir platform-components + canaryFile := filepath.Join(setupADir, "platform-components", "canary.txt") + require.NoError(t, os.WriteFile(canaryFile, []byte("fleet-a canary"), 0644)) + + // 2. Generate Fleet B using kubara generate --hub setups/fleet-b + appB := CreateTestApp(NewGenerateCmd()) + argsB := []string{"kubara", "generate", "--hub", "setups/fleet-b"} + err = appB.Run(context.Background(), argsB) + require.NoError(t, err) + + assert.DirExists(t, filepath.Join(setupBDir, "platform-components")) + assert.DirExists(t, filepath.Join(setupBDir, "platform-configs", "hub-b")) + + // Canary file in fleet-a must still exist (fleet-a was not wiped by fleet-b generate!) + assert.FileExists(t, canaryFile) +} + +func TestGenerate_All_Workspaces(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + require.NoError(t, os.Mkdir(filepath.Join(tempDir, ".git"), 0755)) + + // Setup 1 + setup1Dir := filepath.Join(tempDir, "setups", "fleet-1") + require.NoError(t, os.MkdirAll(setup1Dir, 0755)) + cluster1 := config.Cluster{ + Name: "hub-1", + Stage: "dev", + Type: "hub", + DNSName: "1.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setup1Dir, cluster1) + testutil.CreateDefaultGenerateTestEnv(t, setup1Dir) + + // Setup 2 + setup2Dir := filepath.Join(tempDir, "setups", "fleet-2") + require.NoError(t, os.MkdirAll(setup2Dir, 0755)) + cluster2 := config.Cluster{ + Name: "hub-2", + Stage: "prod", + Type: "hub", + DNSName: "2.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setup2Dir, cluster2) + testutil.CreateDefaultGenerateTestEnv(t, setup2Dir) + + // Generate --all from tempDir + app := CreateTestApp(NewGenerateCmd()) + args := []string{"kubara", "generate", "--all"} + err := app.Run(context.Background(), args) + require.NoError(t, err) + + assert.DirExists(t, filepath.Join(setup1Dir, "platform-components")) + assert.DirExists(t, filepath.Join(setup1Dir, "platform-configs", "hub-1")) + assert.DirExists(t, filepath.Join(setup2Dir, "platform-components")) + assert.DirExists(t, filepath.Join(setup2Dir, "platform-configs", "hub-2")) +} + +func TestGenerate_Multiple_Hubs(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + require.NoError(t, os.Mkdir(filepath.Join(tempDir, ".git"), 0755)) + + // Setup 1 + setup1Dir := filepath.Join(tempDir, "setups", "fleet-1") + require.NoError(t, os.MkdirAll(setup1Dir, 0755)) + cluster1 := config.Cluster{ + Name: "hub-1", + Stage: "dev", + Type: "hub", + DNSName: "1.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setup1Dir, cluster1) + testutil.CreateDefaultGenerateTestEnv(t, setup1Dir) + + // Setup 2 + setup2Dir := filepath.Join(tempDir, "setups", "fleet-2") + require.NoError(t, os.MkdirAll(setup2Dir, 0755)) + cluster2 := config.Cluster{ + Name: "hub-2", + Stage: "prod", + Type: "hub", + DNSName: "2.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setup2Dir, cluster2) + testutil.CreateDefaultGenerateTestEnv(t, setup2Dir) + + // Setup 3 (should NOT be generated) + setup3Dir := filepath.Join(tempDir, "setups", "fleet-3") + require.NoError(t, os.MkdirAll(setup3Dir, 0755)) + cluster3 := config.Cluster{ + Name: "hub-3", + Stage: "staging", + Type: "hub", + DNSName: "3.example.com", + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + Git: &config.RepoType{ + Configs: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + Components: config.Repository{URL: "https://github.com/example/repo", TargetRevision: "main"}, + }, + }, + }, + Services: service.Services{}, + } + testutil.CreateTestConfig(t, setup3Dir, cluster3) + testutil.CreateDefaultGenerateTestEnv(t, setup3Dir) + + tests := []struct { + name string + args []string + wantErr bool + errContains string + expectedGenerated []string + expectedSkipped []string + }{ + { + name: "comma-separated hubs", + args: []string{"kubara", "generate", "--hub", "setups/fleet-1,setups/fleet-2"}, + expectedGenerated: []string{setup1Dir, setup2Dir}, + expectedSkipped: []string{setup3Dir}, + }, + { + name: "repeated --hub flags", + args: []string{"kubara", "generate", "--hub", "setups/fleet-1", "--hub", "setups/fleet-2"}, + expectedGenerated: []string{setup1Dir, setup2Dir}, + expectedSkipped: []string{setup3Dir}, + }, + { + name: "both --all and --hub returns error", + args: []string{"kubara", "generate", "--all", "--hub", "setups/fleet-1"}, + wantErr: true, + errContains: "cannot specify both --all and --hub", + }, + { + name: "empty hub string returns error", + args: []string{"kubara", "generate", "--hub", " "}, + wantErr: true, + errContains: "no valid hub specified in --hub", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up output directories before each run + for _, dir := range []string{setup1Dir, setup2Dir, setup3Dir} { + _ = os.RemoveAll(filepath.Join(dir, "platform-components")) + _ = os.RemoveAll(filepath.Join(dir, "platform-configs")) + } + + app := CreateTestApp(NewGenerateCmd()) + err := app.Run(context.Background(), tt.args) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + for _, dir := range tt.expectedGenerated { + assert.DirExists(t, filepath.Join(dir, "platform-components")) + assert.DirExists(t, filepath.Join(dir, "platform-configs")) + } + for _, dir := range tt.expectedSkipped { + assert.NoDirExists(t, filepath.Join(dir, "platform-components")) + } + }) + } +} + // Helper function func CreateTestApp(commands ...*cli.Command) *cli.Command { diff --git a/src/cmd/init.go b/src/cmd/init.go index ce997db7..ed1dee4e 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -38,7 +38,6 @@ type InitFlags struct { ForceFlag bool LocalFlag bool RenovateFlag bool - EnvFileFlag string EnvPrefixFlag string BootstrapCatalogFlag string } @@ -49,7 +48,6 @@ func NewInitFlags() *InitFlags { ForceFlag: false, LocalFlag: false, RenovateFlag: true, - EnvFileFlag: ".env", EnvPrefixFlag: "KUBARA_", } } @@ -63,7 +61,10 @@ func NewInitCmd() *cli.Command { UsageText: "kubara init [--prep] [--local] [--renovate=false] [--bootstrap-catalog PATH_OR_OCI]", Description: "Initializes the kubara configuration for your GitOps repository, including environment variables, catalog options, and Renovate support for catalog updates. By default, it creates a config file and, if none exists, a renovate.json file. With --prep, it only generates the .env template for manual configuration. Combined with --local, --prep pre-fills local-evaluation defaults in .env and init writes a local-only cluster profile in config.yaml.", Action: func(c context.Context, cmd *cli.Command) error { - o, _ := flags.ToOptions(cmd) + o, err := flags.ToOptions(cmd) + if err != nil { + return err + } return o.Run() }, } @@ -74,17 +75,9 @@ func NewInitCmd() *cli.Command { } func (flags *InitFlags) ToOptions(cmd *cli.Command) (*InitOptions, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) - } - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get config file path: %w", err) - } - dotEnvFilePath, err := utils.GetFullPath(cmd.String("env-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get env file path: %w", err) + return nil, fmt.Errorf("resolve workspace: %w", err) } catalogOptions, err := catalogLoadOptionsFromCommand(cmd, flags.BootstrapCatalogFlag) if err != nil { @@ -96,9 +89,9 @@ func (flags *InitFlags) ToOptions(cmd *cli.Command) (*InitOptions, error) { force: flags.ForceFlag, local: flags.LocalFlag, renovate: flags.RenovateFlag, - cwd: cwd, - configFilePath: configFilePath, - dotEnvFilePath: dotEnvFilePath, + cwd: ws.WorkDir, + configFilePath: ws.ConfigFilePath, + dotEnvFilePath: ws.EnvFilePath, envVarPrefix: flags.EnvPrefixFlag, catalogOptions: catalogOptions, } @@ -375,6 +368,15 @@ func (o *InitOptions) ensureRenovateConfig(cs *config.ConfigStore) error { return nil } + targetDir := o.cwd + gitRoot, err := utils.FindGitRepoRoot(o.cwd) + if err != nil { + return fmt.Errorf("%w", err) + } + if gitRoot != "" { + targetDir = gitRoot + } + // https://docs.renovatebot.com/configuration-options/#locations-for-configuration-filenames renovateFiles := []string{ "renovate.json", @@ -393,7 +395,7 @@ func (o *InitOptions) ensureRenovateConfig(cs *config.ConfigStore) error { } for _, file := range renovateFiles { - path := filepath.Join(o.cwd, file) + path := filepath.Join(targetDir, file) exists, err := utils.FileExist(path) if err != nil { return fmt.Errorf("check renovate config %q: %w", path, err) @@ -412,12 +414,12 @@ func (o *InitOptions) ensureRenovateConfig(cs *config.ConfigStore) error { } } - relativeConfigPath, err := filepath.Rel(o.cwd, cs.GetFilepath()) + relativeConfigPath, err := filepath.Rel(targetDir, cs.GetFilepath()) if err != nil { - return fmt.Errorf("get config path relative to working directory: %w", err) + return fmt.Errorf("get config path relative to target directory: %w", err) } if relativeConfigPath == ".." || strings.HasPrefix(relativeConfigPath, ".."+string(filepath.Separator)) { - log.Warn().Str("configPath", cs.GetFilepath()).Str("workDir", o.cwd).Msg("Skipping Renovate config because the kubara config is outside the working directory") + log.Warn().Str("configPath", cs.GetFilepath()).Str("targetDir", targetDir).Msg("Skipping Renovate config because the kubara config is outside the target directory") return nil } fileMatchPattern := regexp.QuoteMeta(filepath.ToSlash(relativeConfigPath)) @@ -449,7 +451,7 @@ func (o *InitOptions) ensureRenovateConfig(cs *config.ConfigStore) error { return fmt.Errorf("marshal renovate config: %w", err) } - renovatePath := filepath.Join(o.cwd, "renovate.json") + renovatePath := filepath.Join(targetDir, "renovate.json") if err := os.WriteFile(renovatePath, content.Bytes(), 0o644); err != nil { return fmt.Errorf("write renovate config: %w", err) } diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index 7c164d1d..c75f7bed 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -29,7 +29,6 @@ func TestNewInitFlags(t *testing.T) { assert.False(t, flags.ForceFlag) assert.False(t, flags.LocalFlag) assert.True(t, flags.RenovateFlag) - assert.Equal(t, ".env", flags.EnvFileFlag) assert.Equal(t, "KUBARA_", flags.EnvPrefixFlag) assert.Empty(t, flags.BootstrapCatalogFlag) } @@ -53,20 +52,17 @@ func TestNewInitCmd(t *testing.T) { } func TestInitPersistsBootstrapCatalogOverride(t *testing.T) { - t.Parallel() - workDir := t.TempDir() + t.Chdir(workDir) + require.NoError(t, os.Mkdir(filepath.Join(workDir, ".git"), 0755)) bootstrapPath, generalPath, err := internaltestutil.CreateCatalogFixtures(filepath.Join(workDir, "catalogs")) require.NoError(t, err) - envPath := cmdtestutil.CreateDefaultGenerateTestEnv(t, workDir) + _ = cmdtestutil.CreateDefaultGenerateTestEnv(t, workDir) configPath := filepath.Join(workDir, "config.yaml") app := cmdtestutil.CreateTestAppWithFlags(NewGlobalFlags().CLIFlags(), NewInitCmd()) err = app.Run(context.Background(), []string{ "kubara", - "--work-dir", workDir, - "--config-file", configPath, - "--env-file", envPath, "init", "--bootstrap-catalog", bootstrapPath, "--catalog", generalPath, @@ -89,6 +85,7 @@ func TestEnsureRenovateConfig(t *testing.T) { t.Parallel() workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, ".git"), 0755)) configPath := filepath.Join(workDir, "clusters", "prod", "config.yaml") store := config.NewConfigStore(workDir, configPath, catalog.LoadOptions{}) options := &InitOptions{cwd: workDir, renovate: true} @@ -185,6 +182,7 @@ func TestEnsureRenovateConfigPreservesExistingConfig(t *testing.T) { t.Parallel() workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, ".git"), 0755)) existingPath := filepath.Join(workDir, renovateFile) require.NoError(t, os.MkdirAll(filepath.Dir(existingPath), 0o750)) require.NoError(t, os.WriteFile(existingPath, []byte("existing\n"), 0o600)) @@ -214,3 +212,87 @@ func TestRunNormalModeDoesNotCreateRenovateConfigOnValidationError(t *testing.T) require.ErrorContains(t, err, "validate env") assert.NoFileExists(t, filepath.Join(workDir, "renovate.json")) } + +func TestEnsureRenovateConfigInSubfolderWritesToTopLevelFolder(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + gitDir := filepath.Join(repoDir, ".git") + require.NoError(t, os.Mkdir(gitDir, 0755)) + + subDir := filepath.Join(repoDir, "setups", "dev") + require.NoError(t, os.MkdirAll(subDir, 0755)) + + configPath := filepath.Join(subDir, "config.yaml") + store := config.NewConfigStore(subDir, configPath, catalog.LoadOptions{}) + options := &InitOptions{cwd: subDir, renovate: true} + + require.NoError(t, options.ensureRenovateConfig(store)) + + topLevelRenovatePath := filepath.Join(repoDir, "renovate.json") + assert.FileExists(t, topLevelRenovatePath) + assert.NoFileExists(t, filepath.Join(subDir, "renovate.json")) + + content, err := os.ReadFile(topLevelRenovatePath) + require.NoError(t, err) + + var generated renovateConfig + require.NoError(t, json.Unmarshal(content, &generated)) + require.Len(t, generated.CustomManagers, 1) + assert.Equal(t, []string{`/^setups\/dev\/config\.yaml$/`}, generated.CustomManagers[0].ManagerFilePatterns) +} + +func TestEnsureRenovateConfigInSubfolderSkipsIfTopLevelAlreadyExists(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + gitDir := filepath.Join(repoDir, ".git") + require.NoError(t, os.Mkdir(gitDir, 0755)) + + subDir := filepath.Join(repoDir, "setups", "dev") + require.NoError(t, os.MkdirAll(subDir, 0755)) + + topLevelRenovatePath := filepath.Join(repoDir, "renovate.json") + existingContent := "existing renovate content\n" + require.NoError(t, os.WriteFile(topLevelRenovatePath, []byte(existingContent), 0644)) + + configPath := filepath.Join(subDir, "config.yaml") + store := config.NewConfigStore(subDir, configPath, catalog.LoadOptions{}) + options := &InitOptions{cwd: subDir, renovate: true} + + require.NoError(t, options.ensureRenovateConfig(store)) + + content, err := os.ReadFile(topLevelRenovatePath) + require.NoError(t, err) + assert.Equal(t, existingContent, string(content)) + assert.NoFileExists(t, filepath.Join(subDir, "renovate.json")) +} + +func TestInitInSubfolderWritesRenovateToGitRoot(t *testing.T) { + repoDir := t.TempDir() + gitDir := filepath.Join(repoDir, ".git") + require.NoError(t, os.Mkdir(gitDir, 0755)) + + subDir := filepath.Join(repoDir, "setups", "hub-a") + require.NoError(t, os.MkdirAll(subDir, 0755)) + t.Chdir(subDir) + + bootstrapPath, generalPath, err := internaltestutil.CreateCatalogFixtures(filepath.Join(repoDir, "catalogs")) + require.NoError(t, err) + + _ = cmdtestutil.CreateDefaultGenerateTestEnv(t, subDir) + app := cmdtestutil.CreateTestAppWithFlags(NewGlobalFlags().CLIFlags(), NewInitCmd()) + + err = app.Run(context.Background(), []string{ + "kubara", + "init", + "--bootstrap-catalog", bootstrapPath, + "--catalog", generalPath, + "--catalog-overwrite", + }) + require.NoError(t, err) + + topRenovate := filepath.Join(repoDir, "renovate.json") + assert.FileExists(t, topRenovate) + assert.NoFileExists(t, filepath.Join(subDir, "renovate.json")) +} diff --git a/src/cmd/schema.go b/src/cmd/schema.go index 0a372aa6..d88bd65c 100644 --- a/src/cmd/schema.go +++ b/src/cmd/schema.go @@ -55,18 +55,14 @@ func NewSchemaCmd() *cli.Command { } func (flags *SchemaFlags) ToOptions(cmd *cli.Command) (*SchemaOptions, error) { - cwd, err := filepath.Abs(cmd.String("work-dir")) + ws, err := ResolveWorkspace(cmd) if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) + return nil, fmt.Errorf("resolve workspace: %w", err) } - outputFilePath, err := utils.GetFullPath(flags.OutputFlag, cwd) + outputFilePath, err := utils.GetFullPath(flags.OutputFlag, ws.WorkDir) if err != nil { return nil, fmt.Errorf("get output file path: %w", err) } - configFilePath, err := utils.GetFullPath(cmd.String("config-file"), cwd) - if err != nil { - return nil, fmt.Errorf("get config file path: %w", err) - } catalogOptions, err := catalogLoadOptionsFromCommand(cmd, "") if err != nil { @@ -75,8 +71,8 @@ func (flags *SchemaFlags) ToOptions(cmd *cli.Command) (*SchemaOptions, error) { o := &SchemaOptions{ outputFilePath: outputFilePath, - cwd: cwd, - configFilePath: configFilePath, + cwd: ws.WorkDir, + configFilePath: ws.ConfigFilePath, catalogOptions: catalogOptions, } return o, nil diff --git a/src/cmd/schema_test.go b/src/cmd/schema_test.go index 56ac60fa..9a74d883 100644 --- a/src/cmd/schema_test.go +++ b/src/cmd/schema_test.go @@ -184,13 +184,9 @@ func TestSchemaCmd(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() + t.Chdir(tempDir) createSchemaTestConfig(t, tempDir) - globalFlags := []string{ - "--work-dir", tempDir, - } - tt.flags = append(globalFlags, tt.flags...) - if tt.setup != nil { tt.setup(t, tempDir) } diff --git a/src/cmd/testutil/testutil.go b/src/cmd/testutil/testutil.go index 2450fcef..e0859198 100644 --- a/src/cmd/testutil/testutil.go +++ b/src/cmd/testutil/testutil.go @@ -50,6 +50,8 @@ func GeneralCatalogPath() string { func CreateTestConfig(t *testing.T, dir string, clusters ...config.Cluster) string { t.Helper() + _ = os.Mkdir(filepath.Join(dir, ".git"), 0755) + configPath := filepath.Join(dir, "config.yaml") for i := range clusters { diff --git a/src/internal/cmd/generate/generator.go b/src/internal/cmd/generate/generator.go index 57747ed3..90b1800f 100644 --- a/src/internal/cmd/generate/generator.go +++ b/src/internal/cmd/generate/generator.go @@ -13,6 +13,7 @@ import ( "github.com/kubara-io/kubara/internal/envconfig" "github.com/kubara-io/kubara/internal/render" "github.com/kubara-io/kubara/internal/service" + "github.com/kubara-io/kubara/internal/utils" "github.com/fatih/color" "github.com/rs/zerolog/log" @@ -28,16 +29,48 @@ type Options struct { PlatformComponents string PlatformConfigs string EnvPath string + GitRelPath string } type buildContext struct { - Catalog catalog.Catalog - EnvMap envconfig.EnvMap - Clusters []config.Cluster + Catalog catalog.Catalog + EnvMap envconfig.EnvMap + Clusters []config.Cluster + GitRelPath string +} + +func applyDefaultRepoPaths(clusterMap map[string]any, gitRelPath string) { + argocdRaw, ok := clusterMap["argocd"].(map[string]any) + if !ok { + return + } + repoRaw, ok := argocdRaw["repo"].(map[string]any) + if !ok { + return + } + + componentsPath, configsPath := utils.ComputeGitOpsRepoPaths(gitRelPath) + + for _, protoKey := range []string{"https", "oci"} { + protoRaw, ok := repoRaw[protoKey].(map[string]any) + if !ok { + continue + } + if compRaw, ok := protoRaw["components"].(map[string]any); ok { + if pathVal, ok := compRaw["path"].(string); !ok || pathVal == "" { + compRaw["path"] = componentsPath + } + } + if confRaw, ok := protoRaw["configs"].(map[string]any); ok { + if pathVal, ok := confRaw["path"].(string); !ok || pathVal == "" { + confRaw["path"] = configsPath + } + } + } } // getSpokeClusters returns a list of all spoke Clusters of a given cluster list -func getSpokeClusters(clusters []config.Cluster) ([]map[string]any, error) { +func getSpokeClusters(clusters []config.Cluster, gitRelPath string) ([]map[string]any, error) { spokeMaps := make([]map[string]any, 0) for _, cluster := range clusters { if cluster.Type != config.Spoke { @@ -48,6 +81,7 @@ func getSpokeClusters(clusters []config.Cluster) ([]map[string]any, error) { if err != nil { return nil, fmt.Errorf("convert spoke %q to map: %w", cluster.Name, err) } + applyDefaultRepoPaths(spokeMap, gitRelPath) spokeMaps = append(spokeMaps, spokeMap) } return spokeMaps, nil @@ -66,13 +100,22 @@ func buildTemplateContext(cluster config.Cluster, bctx buildContext) (map[string } } + applyDefaultRepoPaths(clusterMap, bctx.GitRelPath) + + componentsPath, configsPath := utils.ComputeGitOpsRepoPaths(bctx.GitRelPath) + context := map[string]any{ "env": bctx.EnvMap, "cluster": clusterMap, "catalog": resolveCatalog(bctx.Catalog), + "workspace": map[string]any{ + "gitRelativePath": bctx.GitRelPath, + "platformComponents": componentsPath, + "platformConfigs": configsPath, + }, } if cluster.Type == config.Hub { - spokes, err := getSpokeClusters(bctx.Clusters) + spokes, err := getSpokeClusters(bctx.Clusters, bctx.GitRelPath) if err != nil { return nil, err } @@ -265,6 +308,15 @@ func (o *Options) processClusters() ([]render.TemplateResult, error) { return nil, fmt.Errorf("load env: %w", err) } + gitRelPath := o.GitRelPath + if gitRelPath == "" { + gitRoot, err := utils.FindGitRepoRoot(o.CWD) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + gitRelPath = utils.ComputeGitRelativePath(gitRoot, o.CWD) + } + for _, cluster := range cnf.Clusters { if cluster.Name != filepath.Base(cluster.Name) || cluster.Name == "." || cluster.Name == ".." { return nil, fmt.Errorf("cluster name %q must be a path-safe name", cluster.Name) @@ -275,9 +327,10 @@ func (o *Options) processClusters() ([]render.TemplateResult, error) { } tmplContext, err := buildTemplateContext(cluster, buildContext{ - Catalog: cat, - EnvMap: dotEnvMap, - Clusters: cnf.Clusters, + Catalog: cat, + EnvMap: dotEnvMap, + Clusters: cnf.Clusters, + GitRelPath: gitRelPath, }) if err != nil { return nil, fmt.Errorf("build template context for cluster %q: %w", cluster.Name, err) diff --git a/src/internal/config/factory.go b/src/internal/config/factory.go index 5cee2f9a..a705d9a0 100644 --- a/src/internal/config/factory.go +++ b/src/internal/config/factory.go @@ -42,7 +42,7 @@ func NewClusterFromEnvWithCatalog(e *envconfig.EnvMap, catalogOptions catalog.Lo return Cluster{ Name: e.ProjectName, Stage: e.ProjectStage, - Type: "", + Type: "hub", DNSName: "", SSOOrg: "", SSOTeam: "", diff --git a/src/internal/config/factory_test.go b/src/internal/config/factory_test.go index 32c6ab7e..cf89c066 100644 --- a/src/internal/config/factory_test.go +++ b/src/internal/config/factory_test.go @@ -39,7 +39,7 @@ func TestNewClusterFromEnv(t *testing.T) { expectedCluster := Cluster{ Name: "kubara-test", Stage: "dev", - Type: "", + Type: "hub", DNSName: expectedDNSName, SSOOrg: "", SSOTeam: "", diff --git a/src/internal/config/types.go b/src/internal/config/types.go index 9b6ca7b8..b4865395 100644 --- a/src/internal/config/types.go +++ b/src/internal/config/types.go @@ -134,6 +134,7 @@ type RepoType struct { type Repository struct { URL string `json:"url" yaml:"url" jsonschema:"required,title=Repository URL,description=The Git repository URL used by Argo CD. Use an HTTP(S) URL for https/github-app auth modes or an SSH URL for ssh auth mode.,minLength=1"` TargetRevision string `json:"targetRevision" yaml:"targetRevision" jsonschema:"title=Target Revision,description=The Git branch or tag to track.,minLength=1,default=main"` + Path string `json:"path,omitempty" yaml:"path,omitempty" jsonschema:"title=Repository Path,description=The path inside the Git repository."` } type HelmRepository struct { diff --git a/src/internal/utils/git.go b/src/internal/utils/git.go new file mode 100644 index 00000000..f948783f --- /dev/null +++ b/src/internal/utils/git.go @@ -0,0 +1,127 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// FindGitRepoRoot traverses upwards from startDir looking for a .git directory or file +// (supporting standard repositories, worktrees, and git submodules). +// If no git repository root is found, it returns an empty string and an error. +func FindGitRepoRoot(startDir string) (string, error) { + absDir, err := filepath.Abs(startDir) + if err != nil { + return "", fmt.Errorf("resolve absolute path for %q: %w", startDir, err) + } + + current := absDir + for { + gitPath := filepath.Join(current, ".git") + if _, err := os.Stat(gitPath); err == nil { + return current, nil + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root + break + } + current = parent + } + + return "", fmt.Errorf("not inside of a git repository") +} + +// ComputeGitRelativePath returns the forward-slash relative path from gitRoot to workspaceDir. +// If gitRoot is empty, workspaceDir is outside gitRoot, or workspaceDir equals gitRoot, it returns "". +func ComputeGitRelativePath(gitRoot, workspaceDir string) string { + if gitRoot == "" || workspaceDir == "" { + return "" + } + + absGitRoot, err := filepath.Abs(gitRoot) + if err != nil { + return "" + } + absWorkspace, err := filepath.Abs(workspaceDir) + if err != nil { + return "" + } + + rel, err := filepath.Rel(absGitRoot, absWorkspace) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return "" + } + + return filepath.ToSlash(filepath.Clean(rel)) +} + +// ComputeGitOpsRepoPaths computes the git repository relative paths for components and configs. +// When gitRelPath is empty, standard root paths are returned. +func ComputeGitOpsRepoPaths(gitRelPath string) (componentsPath, configsPath string) { + if gitRelPath == "" { + return "platform-components/helm", "platform-configs" + } + componentsPath = filepath.ToSlash(filepath.Clean(filepath.Join(gitRelPath, "platform-components", "helm"))) + configsPath = filepath.ToSlash(filepath.Clean(filepath.Join(gitRelPath, "platform-configs"))) + return componentsPath, configsPath +} + +// FindGitConfigPath returns the absolute path to .git/config for the repository containing startDir. +// If startDir is not in a git repository or the config file does not exist, it returns an empty string and no error. +func FindGitConfigPath(startDir string) (string, error) { + if startDir == "" { + return "", nil + } + gitRoot, err := FindGitRepoRoot(startDir) + if err != nil || gitRoot == "" { + return "", err + } + + gitPath := filepath.Join(gitRoot, ".git") + fi, err := os.Stat(gitPath) + if err != nil { + return "", nil + } + + if fi.IsDir() { + configPath := filepath.Join(gitPath, "config") + if _, err := os.Stat(configPath); err == nil { + return configPath, nil + } + return "", nil + } + + // .git is a file (e.g. worktree or submodule) + data, err := os.ReadFile(gitPath) + if err != nil { + return "", err + } + + line := strings.TrimSpace(string(data)) + if strings.HasPrefix(line, "gitdir:") { + gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(gitRoot, gitDir) + } + configPath := filepath.Join(gitDir, "config") + if _, err := os.Stat(configPath); err == nil { + return configPath, nil + } + commondirPath := filepath.Join(gitDir, "commondir") + if cdata, err := os.ReadFile(commondirPath); err == nil { + commonDir := strings.TrimSpace(string(cdata)) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + commonConfig := filepath.Join(commonDir, "config") + if _, err := os.Stat(commonConfig); err == nil { + return commonConfig, nil + } + } + } + + return "", nil +} diff --git a/src/internal/utils/git_test.go b/src/internal/utils/git_test.go new file mode 100644 index 00000000..876e6fe6 --- /dev/null +++ b/src/internal/utils/git_test.go @@ -0,0 +1,148 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindGitRepoRoot(t *testing.T) { + // 1. Directory with .git directory + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + subDir := filepath.Join(tempDir, "setups", "dev-fleet") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("failed to create setups dir: %v", err) + } + + root, err := FindGitRepoRoot(subDir) + if err != nil { + t.Fatalf("FindGitRepoRoot failed: %v", err) + } + if root != tempDir { + t.Errorf("expected root %q, got %q", tempDir, root) + } + + // 2. Directory with .git file (submodule / worktree) + tempDir2 := t.TempDir() + gitFile := filepath.Join(tempDir2, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: /some/path\n"), 0644); err != nil { + t.Fatalf("failed to write .git file: %v", err) + } + + nestedDir := filepath.Join(tempDir2, "a", "b", "c") + if err := os.MkdirAll(nestedDir, 0755); err != nil { + t.Fatalf("failed to create nested dir: %v", err) + } + + root2, err := FindGitRepoRoot(nestedDir) + if err != nil { + t.Fatalf("FindGitRepoRoot failed: %v", err) + } + if root2 != tempDir2 { + t.Errorf("expected root %q, got %q", tempDir2, root2) + } + + // 3. No .git found (tempdir without .git) + tempDir3 := t.TempDir() + nonGitDir := filepath.Join(tempDir3, "no-git", "sub") + if err := os.MkdirAll(nonGitDir, 0755); err != nil { + t.Fatalf("failed to create non-git dir: %v", err) + } + + root3, err := FindGitRepoRoot(nonGitDir) + if err == nil { + t.Fatalf("expected error from FindGitRepoRoot on non-git dir, got nil") + } + if root3 != "" { + t.Errorf("expected empty root, got %q", root3) + } +} + +func TestComputeGitRelativePath(t *testing.T) { + tests := []struct { + name string + gitRoot string + workspaceDir string + expected string + }{ + { + name: "Empty git root", + gitRoot: "", + workspaceDir: "/repo/setups/dev", + expected: "", + }, + { + name: "Empty workspace", + gitRoot: "/repo", + workspaceDir: "", + expected: "", + }, + { + name: "Same directory as git root", + gitRoot: "/repo", + workspaceDir: "/repo", + expected: "", + }, + { + name: "Subdirectory", + gitRoot: "/repo", + workspaceDir: "/repo/setups/dev-fleet", + expected: "setups/dev-fleet", + }, + { + name: "Deep subdirectory", + gitRoot: "/repo", + workspaceDir: "/repo/environments/eu/prod", + expected: "environments/eu/prod", + }, + { + name: "Outside git root", + gitRoot: "/repo/dir", + workspaceDir: "/other/dir", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rel := ComputeGitRelativePath(tc.gitRoot, tc.workspaceDir) + if rel != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, rel) + } + }) + } +} + +func TestComputeGitOpsRepoPaths(t *testing.T) { + tests := []struct { + gitRelPath string + wantComponents string + wantConfigs string + }{ + { + gitRelPath: "", + wantComponents: "platform-components/helm", + wantConfigs: "platform-configs", + }, + { + gitRelPath: "setups/dev-fleet", + wantComponents: "setups/dev-fleet/platform-components/helm", + wantConfigs: "setups/dev-fleet/platform-configs", + }, + } + + for _, tc := range tests { + comp, conf := ComputeGitOpsRepoPaths(tc.gitRelPath) + if comp != tc.wantComponents { + t.Errorf("expected components %q, got %q", tc.wantComponents, comp) + } + if conf != tc.wantConfigs { + t.Errorf("expected configs %q, got %q", tc.wantConfigs, conf) + } + } +} diff --git a/src/internal/utils/workspace.go b/src/internal/utils/workspace.go new file mode 100644 index 00000000..454f6ba3 --- /dev/null +++ b/src/internal/utils/workspace.go @@ -0,0 +1,118 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" +) + +// WorkspacePaths represents the resolved filesystem and git paths for a Kubara workspace. +type WorkspacePaths struct { + WorkDir string + ConfigFilePath string + EnvFilePath string + GitRoot string + GitRelPath string +} + +// WorkspaceOptions contains inputs for workspace resolution. +type WorkspaceOptions struct { + WorkDir string + Hub string + HubSet bool +} + +// ResolveWorkspace resolves the working directory, config file path, env file path, +// and git-relative paths based on the provided options. +func ResolveWorkspace(opts WorkspaceOptions) (*WorkspacePaths, error) { + rawWorkDir := opts.WorkDir + if rawWorkDir == "" { + rawWorkDir = "." + } + cwd, err := filepath.Abs(rawWorkDir) + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + + var resolvedWorkDir string + + if opts.HubSet { + if strings.TrimSpace(opts.Hub) == "" { + return nil, fmt.Errorf("hub path cannot be empty") + } + if hasParentDirTraversal(opts.Hub) { + return nil, fmt.Errorf("hub path %q cannot contain '..'", opts.Hub) + } + if filepath.IsAbs(opts.Hub) { + resolvedWorkDir = filepath.Clean(opts.Hub) + } else { + resolvedWorkDir = filepath.Join(cwd, opts.Hub) + } + + rel, err := filepath.Rel(cwd, resolvedWorkDir) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("hub path %q resolves outside current working directory", opts.Hub) + } + if rel == "." { + return nil, fmt.Errorf("hub path %q must resolve to a directory below current working directory", opts.Hub) + } + } else { + resolvedWorkDir = cwd + } + + resolvedConfigFile, err := GetFullPath("config.yaml", resolvedWorkDir) + if err != nil { + return nil, fmt.Errorf("get config file path: %w", err) + } + + gitRoot, err := FindGitRepoRoot(resolvedWorkDir) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + gitRelPath := ComputeGitRelativePath(gitRoot, resolvedWorkDir) + + resolvedEnvFile := filepath.Join(resolvedWorkDir, ".env") + if _, err := os.Stat(resolvedEnvFile); os.IsNotExist(err) && gitRoot != "" { + rootEnv := filepath.Join(gitRoot, ".env") + if _, err := os.Stat(rootEnv); err == nil { + resolvedEnvFile = rootEnv + } + } + + return &WorkspacePaths{ + WorkDir: resolvedWorkDir, + ConfigFilePath: resolvedConfigFile, + EnvFilePath: resolvedEnvFile, + GitRoot: gitRoot, + GitRelPath: gitRelPath, + }, nil +} + +// ResolveWorkspaceFromCommand extracts options from a cli.Command and resolves workspace paths. +func ResolveWorkspaceFromCommand(cmd *cli.Command) (*WorkspacePaths, error) { + opts := WorkspaceOptions{} + if cmd.IsSet("hub") { + // When "hub" is configured as a StringSliceFlag (e.g. generate subcommand) + if hubs := cmd.StringSlice("hub"); len(hubs) > 0 { + opts.Hub = hubs[0] + opts.HubSet = true + } else if hubStr := cmd.String("hub"); hubStr != "" { + opts.Hub = hubStr + opts.HubSet = true + } + } + return ResolveWorkspace(opts) +} + +func hasParentDirTraversal(p string) bool { + cleaned := filepath.ToSlash(p) + for _, part := range strings.Split(cleaned, "/") { + if part == ".." { + return true + } + } + return false +} diff --git a/src/internal/utils/workspace_test.go b/src/internal/utils/workspace_test.go new file mode 100644 index 00000000..b028895f --- /dev/null +++ b/src/internal/utils/workspace_test.go @@ -0,0 +1,221 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveWorkspace_Default(t *testing.T) { + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git: %v", err) + } + + configFile := filepath.Join(tempDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("version: v1alpha4\n"), 0644); err != nil { + t.Fatalf("failed to write config.yaml: %v", err) + } + + ws, err := ResolveWorkspace(WorkspaceOptions{ + WorkDir: tempDir, + }) + if err != nil { + t.Fatalf("ResolveWorkspace failed: %v", err) + } + + if ws.WorkDir != tempDir { + t.Errorf("expected WorkDir to be %q, got %q", tempDir, ws.WorkDir) + } + if ws.ConfigFilePath != configFile { + t.Errorf("expected ConfigFilePath to be %q, got %q", configFile, ws.ConfigFilePath) + } + if ws.GitRoot != tempDir { + t.Errorf("expected GitRoot to be %q, got %q", tempDir, ws.GitRoot) + } + if ws.GitRelPath != "" { + t.Errorf("expected GitRelPath to be empty, got %q", ws.GitRelPath) + } +} + +func TestResolveWorkspace_HubFlag(t *testing.T) { + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git: %v", err) + } + + setupDir := filepath.Join(tempDir, "prod") + if err := os.MkdirAll(setupDir, 0755); err != nil { + t.Fatalf("failed to create prod: %v", err) + } + + configFile := filepath.Join(setupDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("version: v1alpha4\n"), 0644); err != nil { + t.Fatalf("failed to write config.yaml: %v", err) + } + + // Pass Hub="prod" from tempDir (repo root) + ws, err := ResolveWorkspace(WorkspaceOptions{ + WorkDir: tempDir, + Hub: "prod", + HubSet: true, + }) + if err != nil { + t.Fatalf("ResolveWorkspace failed: %v", err) + } + + if ws.WorkDir != setupDir { + t.Errorf("expected WorkDir to be %q, got %q", setupDir, ws.WorkDir) + } + if ws.ConfigFilePath != configFile { + t.Errorf("expected ConfigFilePath to be %q, got %q", configFile, ws.ConfigFilePath) + } + if ws.GitRoot != tempDir { + t.Errorf("expected GitRoot to be %q, got %q", tempDir, ws.GitRoot) + } + if ws.GitRelPath != "prod" { + t.Errorf("expected GitRelPath to be %q, got %q", "prod", ws.GitRelPath) + } +} + +func TestResolveWorkspace_EnvFallback(t *testing.T) { + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git: %v", err) + } + + rootEnv := filepath.Join(tempDir, ".env") + if err := os.WriteFile(rootEnv, []byte("KUBARA_ROOT=1\n"), 0644); err != nil { + t.Fatalf("failed to write root .env: %v", err) + } + + setupDir := filepath.Join(tempDir, "setups", "staging") + if err := os.MkdirAll(setupDir, 0755); err != nil { + t.Fatalf("failed to create setups/staging: %v", err) + } + + // 1. When workspace doesn't have .env, fall back to root .env + ws, err := ResolveWorkspace(WorkspaceOptions{ + WorkDir: setupDir, + }) + if err != nil { + t.Fatalf("ResolveWorkspace failed: %v", err) + } + if ws.EnvFilePath != rootEnv { + t.Errorf("expected EnvFilePath to fall back to root %q, got %q", rootEnv, ws.EnvFilePath) + } + + // 2. When workspace HAS .env, prefer workspace .env + wsEnv := filepath.Join(setupDir, ".env") + if err := os.WriteFile(wsEnv, []byte("KUBARA_STAGING=1\n"), 0644); err != nil { + t.Fatalf("failed to write workspace .env: %v", err) + } + + ws2, err := ResolveWorkspace(WorkspaceOptions{ + WorkDir: setupDir, + }) + if err != nil { + t.Fatalf("ResolveWorkspace failed: %v", err) + } + if ws2.EnvFilePath != wsEnv { + t.Errorf("expected EnvFilePath to prefer workspace %q, got %q", wsEnv, ws2.EnvFilePath) + } +} + +func TestResolveWorkspace_HubPathValidation(t *testing.T) { + tempDir := t.TempDir() + gitDir := filepath.Join(tempDir, ".git") + require.NoError(t, os.Mkdir(gitDir, 0755)) + + validSubdir := filepath.Join(tempDir, "setups", "dev") + require.NoError(t, os.MkdirAll(validSubdir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(validSubdir, "config.yaml"), []byte("version: v1alpha4\n"), 0644)) + + validBetaSubdir := filepath.Join(tempDir, "setups", "v1.0..beta") + require.NoError(t, os.MkdirAll(validBetaSubdir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(validBetaSubdir, "config.yaml"), []byte("version: v1alpha4\n"), 0644)) + + outsideDir := t.TempDir() + + tests := []struct { + name string + hub string + expectedDir string + wantErr bool + errContains string + }{ + { + name: "valid relative subpath", + hub: "setups/dev", + expectedDir: validSubdir, + wantErr: false, + }, + { + name: "valid subpath containing double dots in directory name", + hub: "setups/v1.0..beta", + expectedDir: validBetaSubdir, + wantErr: false, + }, + { + name: "empty hub path returns error", + hub: " ", + wantErr: true, + errContains: "hub path cannot be empty", + }, + { + name: "parent directory traversal with .. prefix", + hub: "../outside", + wantErr: true, + errContains: "cannot contain '..'", + }, + { + name: "internal directory traversal containing ..", + hub: "setups/../dev", + wantErr: true, + errContains: "cannot contain '..'", + }, + { + name: "just ..", + hub: "..", + wantErr: true, + errContains: "cannot contain '..'", + }, + { + name: "absolute path outside CWD", + hub: outsideDir, + wantErr: true, + errContains: "resolves outside current working directory", + }, + { + name: "current directory dot", + hub: ".", + wantErr: true, + errContains: "must resolve to a directory below current working directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ws, err := ResolveWorkspace(WorkspaceOptions{ + WorkDir: tempDir, + Hub: tt.hub, + HubSet: true, + }) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedDir, ws.WorkDir) + }) + } +}