diff --git a/CHANGELOG.md b/CHANGELOG.md index d458f1e..0c305ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to zcp will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.9] - 2026-04-14 + +### Added + +- **Environment variable overrides**: `ZCP_PROJECT`, `ZCP_REGION`, `ZCP_CLOUD_PROVIDER`, `ZCP_OUTPUT`, `ZCP_DEBUG` — reduces repetitive flags in CI/CD and scripting +- **Zero-config mode**: CLI can now operate with only `ZCP_BEARER_TOKEN` and `ZCP_API_URL` env vars — no config file or profile required +- **`ZCP_PROFILE` env var**: Selects the active profile without `--profile` flag +- **`ZCP_BEARER_TOKEN` env var**: Overrides profile credentials at runtime +- **`ZCP_API_URL` env var**: Overrides the API base URL at runtime +- **Env var tests**: 14 new tests covering all resolution helpers and config env overrides + +### Fixed + +- **Kubernetes create**: Restored missing `--billing-cycle` validation (was accidentally removed) +- **Kubernetes create**: Fixed resolve order — `resolveRegion/resolveProject/resolveCloudProvider` now called before validation checks so env vars are applied correctly +- **All create commands**: Consistent resolve order — env var resolution always runs before required-field validation across all 18 command files + +### Changed + +- **`config.ResolveProfile`**: Now checks `ZCP_PROFILE` env var before falling back to `active_profile` in config file +- **`config.ActiveAPIURL`**: Now checks `ZCP_API_URL` env var before falling back to profile URL +- **Documentation**: Updated `docs/configuration.md` and `README.md` with all new environment variables and CI/CD usage examples + +**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.8...0.0.9 + +--- + ## [0.0.8] - 2026-04-09 ### Fixed diff --git a/README.md b/README.md index 8c37707..af98b44 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,20 @@ zcp profile rename staging prod zcp profile delete old-profile ``` +### Environment Variables + +You can override configuration and flags using environment variables: + +- `ZCP_BEARER_TOKEN`: Overrides profile credentials. +- `ZCP_API_URL`: Overrides the API base URL. +- `ZCP_PROJECT`: Sets the default project slug. +- `ZCP_REGION`: Sets the default region slug. +- `ZCP_CLOUD_PROVIDER`: Sets the default cloud provider. +- `ZCP_OUTPUT`: Sets the default output format (`json`, `yaml`, `table`). +- `ZCP_DEBUG`: Set to `true` to enable verbose debug output. + +See [docs/configuration.md](docs/configuration.md) for the full list and usage examples. + --- ## Commands Reference @@ -183,9 +197,9 @@ zcp instance get # Create — use --wait to block until the instance is Running zcp instance create \ --name my-vm \ - --cloud-provider nimbo \ + --cloud-provider zcp \ --project my-project \ - --region noida \ + --region yow-1 \ --template ubuntu-22f \ --plan bp-4vc-8gb \ --billing-cycle hourly \ @@ -248,8 +262,8 @@ zcp volume list zcp volume create \ --name my-disk \ --project my-project \ - --cloud-provider nimbo \ - --region noida \ + --cloud-provider zcp \ + --region yow-1 \ --billing-cycle hourly \ --storage-category nvme \ --plan 50-gb-2 @@ -263,8 +277,8 @@ zcp snapshot create \ --volume \ --name my-snapshot \ --plan snapshot-per-gb \ - --cloud-provider nimbo \ - --region noida \ + --cloud-provider zcp \ + --region yow-1 \ --billing-cycle hourly \ --project my-project zcp snapshot revert --volume @@ -277,8 +291,8 @@ zcp vm-snapshot create \ --plan basic \ --billing-cycle hourly \ --project my-project \ - --cloud-provider nimbo \ - --region noida + --cloud-provider zcp \ + --region yow-1 zcp vm-snapshot revert ``` @@ -288,11 +302,11 @@ zcp vm-snapshot revert # Networks zcp network list zcp network categories -zcp network create --name my-net --category --cloud-provider nimbo --region noida --project default-124 +zcp network create --name my-net --category --cloud-provider zcp --region yow-1 --project my-project zcp network update --name "New Name" # VPC tier networks -zcp network create --name public-tier --cloud-provider nimbo --region noida --project default-124 \ +zcp network create --name public-tier --cloud-provider zcp --region yow-1 --project my-project \ --vpc --type Vpc --gateway 10.1.1.1 --netmask 255.255.255.0 --acl-id # Public IP addresses @@ -327,9 +341,9 @@ zcp portforward create \ zcp vpc list zcp vpc create \ --name my-vpc \ - --cloud-provider nimbo \ - --region noida \ - --project default-124 \ + --cloud-provider zcp \ + --region yow-1 \ + --project my-project \ --plan vpc-1 \ --network-address 10.1.0.1 \ --size 16 \ @@ -373,7 +387,7 @@ zcp dns list zcp dns show # Create a domain -zcp dns create --name example.com --project my-project --cloud-provider nimbo --region noida --dns-provider powerdns +zcp dns create --name example.com --project my-project --cloud-provider zcp --region yow-1 --dns-provider dns-provider # Create a record zcp dns record-create --domain --name www --type A --content 192.0.2.1 @@ -399,7 +413,7 @@ zcp backup delete ```bash zcp autoscale list zcp autoscale get -zcp autoscale create --name my-policy --min 1 --max 5 --cloud-provider nimbo --region noida --project default-124 +zcp autoscale create --name my-policy --min 1 --max 5 --cloud-provider zcp --region yow-1 --project my-project zcp autoscale delete ``` @@ -438,9 +452,9 @@ zcp kubernetes create \ --name my-cluster \ --version v1.28.4 \ --plan k8s-plan-1 \ - --region noida \ - --project default-59 \ - --cloud-provider nimbo \ + --region yow-1 \ + --project my-project \ + --cloud-provider zcp \ --billing-cycle monthly \ --workers 3 \ --ssh-key mykey @@ -450,9 +464,9 @@ zcp kubernetes create \ --name ha-cluster \ --version v1.28.4 \ --plan k8s-plan-1 \ - --region noida \ - --project default-59 \ - --cloud-provider nimbo \ + --region yow-1 \ + --project my-project \ + --cloud-provider zcp \ --billing-cycle monthly \ --workers 3 \ --control-nodes 3 \ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4aeb003..1456762 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,60 +1,53 @@ -# zcp 0.0.8 Release Notes +# zcp 0.0.9 Release Notes ## What's New -### VPC create fixed +### Environment variable support -VPC creation now works with the correct payload structure: +All create commands now respect environment variables for the three most commonly repeated flags. Set them once and every command picks them up: ```bash -zcp vpc create \ - --name my-vpc \ - --cloud-provider nimbo \ - --region noida \ - --project default-124 \ - --plan vpc-1 \ - --network-address 10.1.0.1 \ - --size 16 \ - --billing-cycle hourly \ - --storage-category nvme -``` - -Key: `--network-address` is just the IP (not CIDR notation), `--size` is the mask separately. - -### ACL list creation fixed +export ZCP_CLOUD_PROVIDER=zcp +export ZCP_REGION=yow-1 +export ZCP_PROJECT=my-project -`zcp vpc acl-create` and `zcp acl create` now correctly create ACL lists: +# Before: every command needed all 3 flags +zcp instance create --name my-vm --cloud-provider zcp --region yow-1 --project my-project --template ubuntu-22f --plan bp-4vc-8gb --billing-cycle hourly --storage-category nvme --blockstorage-plan 50-gb-2 -```bash -zcp vpc acl-create my-vpc --name allow-web --description "Allow HTTP" -zcp acl create my-vpc --name private-acl --description "Deny all inbound" +# Now: just the resource-specific flags +zcp instance create --name my-vm --template ubuntu-22f --plan bp-4vc-8gb --billing-cycle hourly --storage-category nvme --blockstorage-plan 50-gb-2 ``` -### Create commands gain required flags - -`--cloud-provider`, `--region`, `--project` added to: network, vpc, virtualrouter, dns, vpn, autoscale create commands. +Works across all create commands: instance, volume, vpc, network, kubernetes, dns, loadbalancer, autoscale, snapshot, vm-snapshot, vm-backup, virtual-router, vpn, iso, affinity-group, template, backup. -### Volume Size type fix +### Zero-config mode -Volume list no longer fails when the API returns size as a number. +The CLI can now run with just environment variables — no config file needed: -### Roadmap published - -See `docs/roadmap.md` for what's working, what's coming, and what's blocked on the platform. +```bash +export ZCP_BEARER_TOKEN=your-token +export ZCP_API_URL=https://api.zcp.zsoftly.ca +zcp region list +``` ---- +### All new environment variables -## Known limitations (blocked on platform) +| Variable | Overrides | Example | +| -------------------- | ----------------------------------------- | -------------------------------- | +| `ZCP_BEARER_TOKEN` | Profile `bearer_token` | `export ZCP_BEARER_TOKEN=abc123` | +| `ZCP_API_URL` | Profile `api_url` | `export ZCP_API_URL=https://...` | +| `ZCP_PROFILE` | Active profile (when `--profile` not set) | `export ZCP_PROFILE=staging` | +| `ZCP_PROJECT` | `--project` flag | `export ZCP_PROJECT=my-project` | +| `ZCP_REGION` | `--region` flag | `export ZCP_REGION=yow-1` | +| `ZCP_CLOUD_PROVIDER` | `--cloud-provider` flag | `export ZCP_CLOUD_PROVIDER=zcp` | +| `ZCP_OUTPUT` | `--output` flag | `export ZCP_OUTPUT=json` | +| `ZCP_DEBUG` | `--debug` flag | `export ZCP_DEBUG=true` | -These require API changes from the STKCNSL team: +Precedence: CLI flag > environment variable > profile config > default. -- **No DELETE endpoints** for VPCs, networks, virtual routers, IP addresses, or ACL lists -- **No ACL rule CRUD** — can create ACL lists but not rules inside them -- **Network create (isolated)** — `networkofferingid` not resolvable for nimbo/noida -- **DNS create** — needs admin-side `cloud_provider_setup` provisioning -- **billing cancel-service for VPCs** — returns "service not found" +### Bug fix: Kubernetes create -See `docs/roadmap.md` for full details. +`--billing-cycle` validation was accidentally removed in v0.0.8. Restored — the API requires it (confirmed via Postman collection). --- @@ -72,4 +65,4 @@ curl -fsSL https://github.com/zsoftly/zcp-cli/releases/latest/download/install.s irm https://github.com/zsoftly/zcp-cli/releases/latest/download/install.ps1 | iex ``` -**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.7...0.0.8 +**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.8...0.0.9 diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index fdde9b5..82faf98 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -1,7 +1,7 @@ # ZCP CLI Command Taxonomy (v0.0.6) **CLI name**: `zcp` -**Base URL**: `https://portal.webberstop.com/backend/api` +**Base URL**: `https://api.zcp.zsoftly.ca` **Authentication**: Bearer token (`--bearer-token` during profile add) --- @@ -351,14 +351,14 @@ Each API request sends the token as an `Authorization: Bearer ` header. ## Identifier Conventions v0.0.6 uses **slug-based identifiers** for most resources. Slugs are human-readable -strings assigned by the API (e.g., `my-vm-123`, `root-4153`, `example-com-1`). +strings assigned by the API (e.g., `my-vm-123`, `root-1234`, `example-com-1`). | Context | Flag / Argument | Example | | --------------- | --------------------------------- | ---------------------------------------- | | VM instance | positional `` or `--vm` | `zcp instance get my-vm-123` | -| Volume | `--volume` | `zcp snapshot create --volume root-4153` | +| Volume | `--volume` | `zcp snapshot create --volume root-1234` | | DNS domain | positional `` or `--domain` | `zcp dns show example-com-1` | -| Project | `--project` | `--project default-60` | +| Project | `--project` | `--project my-project` | | Region | `--region` | `--region yow-1` | | VPC | `--vpc` | `zcp ip list --vpc my-vpc` | | IP | `--ip` | `zcp firewall list --ip my-ip-slug` | diff --git a/docs/configuration.md b/docs/configuration.md index b995b12..fa172f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,22 +71,33 @@ https://api.zcp.zsoftly.ca ## Environment Variable Overrides -The following environment variables are evaluated at runtime and take precedence over the corresponding config file values. - -| Variable | Overrides | Description | -| ------------------ | ----------------------------------- | ------------------------------------------------- | -| `ZCP_PROFILE` | `--profile` flag / active profile | Profile name to use for the current invocation. | -| `ZCP_BEARER_TOKEN` | Profile `bearer_token` | Bearer token, bypassing the config file entirely. | -| `ZCP_API_URL` | Profile `api_url` / default URL | API base URL override. | -| `XDG_CONFIG_HOME` | Config file directory (Linux/macOS) | Overrides the base directory for the config file. | - -Environment variables are useful for CI/CD pipelines where you do not want credentials stored in a file on disk. +The following environment variables are evaluated at runtime and take precedence over the corresponding config file values and global flags. + +| Variable | Overrides | Description | +| -------------------- | ----------------------------------- | ------------------------------------------------- | +| `ZCP_PROFILE` | `--profile` flag / active profile | Profile name to use for the current invocation. | +| `ZCP_BEARER_TOKEN` | Profile `bearer_token` | Bearer token, bypassing the config file entirely. | +| `ZCP_API_URL` | Profile `api_url` / default URL | API base URL override. | +| `ZCP_PROJECT` | `--project` flag | Default project slug for all resource commands. | +| `ZCP_REGION` | `--region` flag | Default region slug for all resource commands. | +| `ZCP_CLOUD_PROVIDER` | `--cloud-provider` flag | Default cloud provider slug (e.g., `zcp`). | +| `ZCP_OUTPUT` | `--output` / `-o` flag | Default output format (`table`, `json`, `yaml`). | +| `ZCP_DEBUG` | `--debug` flag | Set to `true` to enable debug output (stderr). | +| `XDG_CONFIG_HOME` | Config file directory (Linux/macOS) | Overrides the base directory for the config file. | + +Environment variables are useful for CI/CD pipelines and scripting where you do not want to pass repetitive flags or store credentials in a file on disk. Example usage in a pipeline: ```bash export ZCP_BEARER_TOKEN=ci-bearer-token -zcp region list --output json +export ZCP_PROJECT=prod-project +export ZCP_REGION=yow-1 +export ZCP_CLOUD_PROVIDER=zcp +export ZCP_OUTPUT=json + +# Create a volume without passing repetitive flags +zcp volume create --name my-disk --plan 50-gb-2 --billing-cycle hourly ``` --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 61b2cd5..c955f49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,7 @@ Features planned, in progress, or blocked on platform support. - [ ] `portforward create` — add `--public-end-port` and `--private-end-port` flags (API requires them) - [ ] `instance change-hostname` — fix request body field name (`vm_label` instead of `label`) - [ ] `region` command — add `use` subcommand to set default region in profile -- [ ] Default `--cloud-provider`, `--region`, `--project` from profile config to reduce flag repetition +- [x] Default `--cloud-provider`, `--region`, `--project` via `ZCP_CLOUD_PROVIDER`, `ZCP_REGION`, `ZCP_PROJECT` env vars (v0.0.9) ### Blocked on STKCNSL platform @@ -50,11 +50,11 @@ The UI has "Add Rule" with Number, CIDR, Action, Protocol, Traffic Type fields, - [ ] `DELETE /vpcs/{slug}/network-acl-list/{acl_id}/rules/{rule_id}` — delete ACL rule - [ ] `GET /vpcs/{slug}/network-acl-list/{acl_id}/rules` — list ACL rules -#### Network create (isolated) — noida region +#### Network create (isolated) — target region -`POST /networks` returns `missing parameter networkofferingid` for the nimbo/noida region. The API doesn't expose the network offering field. Likely a region configuration issue. +`POST /networks` returns `missing parameter networkofferingid` for the the target region region. The API doesn't expose the network offering field. Likely a region configuration issue. -- [ ] Network offering mapping for nimbo/noida +- [ ] Network offering mapping for the target region #### DNS provisioning diff --git a/internal/commands/affinitygroup.go b/internal/commands/affinitygroup.go index 83554d0..a6498ca 100644 --- a/internal/commands/affinitygroup.go +++ b/internal/commands/affinitygroup.go @@ -80,7 +80,7 @@ func newAffinityGroupCreateCmd() *cobra.Command { Use: "create", Short: "Create an affinity group", Example: ` zcp affinity-group create --name my-group --type "host affinity" \ - --cloud-provider nimbo --project default-1 --region yow-1`, + --cloud-provider zcp --project my-project --region yow-1`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -88,12 +88,15 @@ func newAffinityGroupCreateCmd() *cobra.Command { if groupType == "" { return fmt.Errorf("--type is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } diff --git a/internal/commands/autoscale.go b/internal/commands/autoscale.go index ecb1cc3..64f81e0 100644 --- a/internal/commands/autoscale.go +++ b/internal/commands/autoscale.go @@ -117,12 +117,15 @@ func newAutoscaleCreateCmd() *cobra.Command { if zoneSlug == "" { return fmt.Errorf("--zone is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/backup.go b/internal/commands/backup.go index ad8b21f..929c315 100644 --- a/internal/commands/backup.go +++ b/internal/commands/backup.go @@ -65,8 +65,8 @@ func newBackupCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a block storage backup", - Example: ` zcp backup create --volume root-4153 --interval dailyAt --at 1 --immediate 1 --cloud-provider nimbo --region noida --billing-cycle hourly --plan backup-1 --project default-73 - zcp backup create --volume root-4153 --interval dailyAt --at 1 --immediate 0 --cloud-provider nimbo --region noida --billing-cycle hourly --plan backup-1 --project default-73`, + Example: ` zcp backup create --volume root-1234 --interval dailyAt --at 1 --immediate 1 --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-1 --project my-project + zcp backup create --volume root-1234 --interval dailyAt --at 1 --immediate 0 --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-1 --project my-project`, RunE: func(cmd *cobra.Command, args []string) error { if blockstorageSlug == "" { return fmt.Errorf("--volume is required") @@ -74,9 +74,11 @@ func newBackupCreateCmd() *cobra.Command { if interval == "" { return fmt.Errorf("--interval is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } @@ -86,6 +88,7 @@ func newBackupCreateCmd() *cobra.Command { if plan == "" { return fmt.Errorf("--plan is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/billing.go b/internal/commands/billing.go index 885b610..c3b0ec6 100644 --- a/internal/commands/billing.go +++ b/internal/commands/billing.go @@ -637,7 +637,7 @@ func newBillingCancelServiceCmd() *cobra.Command { Use: "cancel-service ", Short: "Submit a cancellation request for a service", Example: ` zcp billing cancel-service demo-prj-vm --service "Virtual Machine" --reason not_needed_anymore - zcp billing cancel-service root-4153 --service "Block Storage" --reason not_needed_anymore --type Immediate`, + zcp billing cancel-service my-volume --service "Block Storage" --reason not_needed_anymore --type Immediate`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if serviceName == "" { diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index afcc058..6726e8e 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -2,6 +2,7 @@ package commands import ( "bytes" + "os" "strings" "testing" @@ -38,6 +39,118 @@ func execCmd(t *testing.T, cmd *cobra.Command, args ...string) (stdout, stderr s return outBuf.String(), errBuf.String(), err } +// ─── Environment variable resolution ──────────────────────────────────────── + +func TestResolveProjectFlagTakesPrecedence(t *testing.T) { + os.Setenv("ZCP_PROJECT", "env-project") + defer os.Unsetenv("ZCP_PROJECT") + + result := resolveProject("flag-project") + if result != "flag-project" { + t.Errorf("resolveProject = %q, want %q", result, "flag-project") + } +} + +func TestResolveProjectFallsBackToEnv(t *testing.T) { + os.Setenv("ZCP_PROJECT", "env-project") + defer os.Unsetenv("ZCP_PROJECT") + + result := resolveProject("") + if result != "env-project" { + t.Errorf("resolveProject = %q, want %q", result, "env-project") + } +} + +func TestResolveProjectEmptyWhenNeitherSet(t *testing.T) { + os.Unsetenv("ZCP_PROJECT") + + result := resolveProject("") + if result != "" { + t.Errorf("resolveProject = %q, want empty", result) + } +} + +func TestResolveRegionFlagTakesPrecedence(t *testing.T) { + os.Setenv("ZCP_REGION", "env-region") + defer os.Unsetenv("ZCP_REGION") + + result := resolveRegion("flag-region") + if result != "flag-region" { + t.Errorf("resolveRegion = %q, want %q", result, "flag-region") + } +} + +func TestResolveRegionFallsBackToEnv(t *testing.T) { + os.Setenv("ZCP_REGION", "env-region") + defer os.Unsetenv("ZCP_REGION") + + result := resolveRegion("") + if result != "env-region" { + t.Errorf("resolveRegion = %q, want %q", result, "env-region") + } +} + +func TestResolveRegionEmptyWhenNeitherSet(t *testing.T) { + os.Unsetenv("ZCP_REGION") + + result := resolveRegion("") + if result != "" { + t.Errorf("resolveRegion = %q, want empty", result) + } +} + +func TestResolveCloudProviderFlagTakesPrecedence(t *testing.T) { + os.Setenv("ZCP_CLOUD_PROVIDER", "env-cp") + defer os.Unsetenv("ZCP_CLOUD_PROVIDER") + + result := resolveCloudProvider("flag-cp") + if result != "flag-cp" { + t.Errorf("resolveCloudProvider = %q, want %q", result, "flag-cp") + } +} + +func TestResolveCloudProviderFallsBackToEnv(t *testing.T) { + os.Setenv("ZCP_CLOUD_PROVIDER", "env-cp") + defer os.Unsetenv("ZCP_CLOUD_PROVIDER") + + result := resolveCloudProvider("") + if result != "env-cp" { + t.Errorf("resolveCloudProvider = %q, want %q", result, "env-cp") + } +} + +func TestResolveCloudProviderEmptyWhenNeitherSet(t *testing.T) { + os.Unsetenv("ZCP_CLOUD_PROVIDER") + + result := resolveCloudProvider("") + if result != "" { + t.Errorf("resolveCloudProvider = %q, want empty", result) + } +} + +// ─── Kubernetes billing-cycle validation ──────────────────────────────────── + +func TestK8sCreateRequiresBillingCycle(t *testing.T) { + cmd := NewKubernetesCmd() + root := newTestRoot() + root.AddCommand(cmd) + + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"kubernetes", "create", + "--name", "test", "--version", "v1.28.4", "--plan", "k8s-1", + "--cloud-provider", "nimbo", "--region", "noida", "--project", "default", + "--workers", "1", "--ssh-key", "mykey"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error when --billing-cycle is missing") + } + if !strings.Contains(err.Error(), "--billing-cycle is required") { + t.Errorf("error = %q, want '--billing-cycle is required'", err) + } +} + // ─── Finding 4: company rejects all-empty, sends only changed fields ──────── func TestCompanyRejectsNoFlags(t *testing.T) { diff --git a/internal/commands/dns.go b/internal/commands/dns.go index c10fe5d..408ef80 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -141,18 +141,21 @@ func newDNSCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a DNS domain", - Example: ` zcp dns create --name example.com --project default-60 --dns-provider powerdns --cloud-provider --region - zcp dns create --name example.com --project default-60 --cloud-provider --region `, + Example: ` zcp dns create --name example.com --project my-project --dns-provider dns-provider --cloud-provider --region + zcp dns create --name example.com --project my-project --cloud-provider --region `, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } @@ -169,7 +172,7 @@ func newDNSCreateCmd() *cobra.Command { }, } cmd.Flags().StringVar(&name, "name", "", "Domain name (required, e.g. example.com)") - cmd.Flags().StringVar(&project, "project", "", "Project slug (required, e.g. default-60)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required, e.g. my-project)") cmd.Flags().StringVar(&dnsProvider, "dns-provider", "powerdns", "DNS provider (default: powerdns)") cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 140a816..9df6e25 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -4,6 +4,7 @@ package commands import ( "fmt" "os" + "strings" "time" "github.com/spf13/cobra" @@ -27,6 +28,14 @@ func buildClientAndPrinter(cmd *cobra.Command) (*config.Profile, *httpclient.Cli noColor, _ := cmd.Root().PersistentFlags().GetBool("no-color") pager, _ := cmd.Root().PersistentFlags().GetBool("pager") + // Apply environment variable overrides for global flags + if envOutput := os.Getenv("ZCP_OUTPUT"); envOutput != "" { + outputFmt = envOutput + } + if v := strings.ToLower(os.Getenv("ZCP_DEBUG")); v == "true" || v == "1" || v == "yes" { + debugFlag = true + } + cfg, err := config.Load() if err != nil { return nil, nil, nil, fmt.Errorf("loading config: %w", err) @@ -65,6 +74,30 @@ func resolveZone(profile *config.Profile, flagZone string) string { return "" } +// resolveProject returns flagProject if set, otherwise the ZCP_PROJECT env var. +func resolveProject(flagProject string) string { + if flagProject != "" { + return flagProject + } + return os.Getenv("ZCP_PROJECT") +} + +// resolveRegion returns flagRegion if set, otherwise the ZCP_REGION env var. +func resolveRegion(flagRegion string) string { + if flagRegion != "" { + return flagRegion + } + return os.Getenv("ZCP_REGION") +} + +// resolveCloudProvider returns flagCloudProvider if set, otherwise the ZCP_CLOUD_PROVIDER env var. +func resolveCloudProvider(flagCloudProvider string) string { + if flagCloudProvider != "" { + return flagCloudProvider + } + return os.Getenv("ZCP_CLOUD_PROVIDER") +} + // errNoZone is the standard error shown when --zone is missing and no default is set. func errNoZone() error { return fmt.Errorf("--zone is required (or set a default: zcp zone use )") diff --git a/internal/commands/instance.go b/internal/commands/instance.go index d78a6b1..00177f9 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -197,18 +197,21 @@ func newInstanceCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new virtual machine", - Example: ` zcp instance create --name my-vm --cloud-provider nimbo --project default --region noida --template ubuntu-22f --plan box2cm2 --billing-cycle hourly - zcp instance create --name my-vm --cloud-provider nimbo --project default --region noida --template ubuntu-22f --plan box2cm2 --billing-cycle hourly --wait`, + Example: ` zcp instance create --name my-vm --cloud-provider zcp --project default --region yow-1 --template ubuntu-22f --plan compute-4vcpu-8gb --billing-cycle hourly + zcp instance create --name my-vm --cloud-provider zcp --project default --region yow-1 --template ubuntu-22f --plan compute-4vcpu-8gb --billing-cycle hourly --wait`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } @@ -965,19 +968,22 @@ func newInstancePurchaseAddonCmd() *cobra.Command { cmd := &cobra.Command{ Use: "purchase-addon", Short: "Purchase an addon for a virtual machine", - Example: ` zcp instance purchase-addon --vm my-vm --project default --region noida --cloud-provider nimbo --addon-slug remote-desktop-license --addon-category microsoft-spla-licenses --addon-id --billing-cycle hourly`, + Example: ` zcp instance purchase-addon --vm my-vm --project default --region yow-1 --cloud-provider zcp --addon-slug remote-desktop-license --addon-category microsoft-spla-licenses --addon-id --billing-cycle hourly`, RunE: func(cmd *cobra.Command, args []string) error { if vmSlug == "" { return fmt.Errorf("--vm is required") } - if project == "" { - return fmt.Errorf("--project is required") + cloudProvider = resolveCloudProvider(cloudProvider) + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } - if cloudProvider == "" { - return fmt.Errorf("--cloud-provider is required") + project = resolveProject(project) + if project == "" { + return fmt.Errorf("--project is required") } if addonSlug == "" { return fmt.Errorf("--addon-slug is required") diff --git a/internal/commands/iso.go b/internal/commands/iso.go index 8d62504..eb41847 100644 --- a/internal/commands/iso.go +++ b/internal/commands/iso.go @@ -92,7 +92,7 @@ func newISOCreateCmd() *cobra.Command { Use: "create", Short: "Create (register) an ISO image", Example: ` zcp iso create --name my-iso --url https://example.com/my.iso \ - --cloud-provider nimbo --project default-1 --region yow-1 \ + --cloud-provider zcp --project my-project --region yow-1 \ --os-type-id --image-type "Operating System" \ --os ubuntu --os-version "22.04 LTS" --billing-cycle hourly`, RunE: func(cmd *cobra.Command, args []string) error { @@ -102,12 +102,15 @@ func newISOCreateCmd() *cobra.Command { if isoURL == "" { return fmt.Errorf("--url is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index d8b638a..3c8ce97 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -99,8 +99,8 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new Kubernetes cluster", - Example: ` zcp kubernetes create --name my-cluster --version v1.28.4 --plan k8s-plan-1 --region noida --project default-59 --cloud-provider nimbo --billing-cycle monthly --workers 3 --ssh-key mykey - zcp kubernetes create --name ha-cluster --version v1.28.4 --plan k8s-plan-1 --region noida --project default-59 --cloud-provider nimbo --billing-cycle monthly --workers 3 --control-nodes 3 --ha --ssh-key mykey`, + Example: ` zcp kubernetes create --name my-cluster --version v1.28.4 --plan k8s-plan-1 --region yow-1 --project my-project --cloud-provider zcp --billing-cycle monthly --workers 3 --ssh-key mykey + zcp kubernetes create --name ha-cluster --version v1.28.4 --plan k8s-plan-1 --region yow-1 --project my-project --cloud-provider zcp --billing-cycle monthly --workers 3 --control-nodes 3 --ha --ssh-key mykey`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -111,18 +111,22 @@ func newK8sClusterCreateCmd() *cobra.Command { if plan == "" { return fmt.Errorf("--plan is required") } + cloudProvider = resolveCloudProvider(cloudProvider) + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } - if cloudProvider == "" { - return fmt.Errorf("--cloud-provider is required") - } if billingCycle == "" { return fmt.Errorf("--billing-cycle is required") } + if nodeSize < 1 { return fmt.Errorf("--workers must be >= 1") } diff --git a/internal/commands/loadbalancer.go b/internal/commands/loadbalancer.go index 0072e3d..8a46cd1 100644 --- a/internal/commands/loadbalancer.go +++ b/internal/commands/loadbalancer.go @@ -103,18 +103,21 @@ func newLBCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new load balancer", - Example: ` zcp loadbalancer create --name my-lb --cloud-provider nimbo --project default-33 --region ixg-belagavi --network d-net-test --plan load-balancer --billing-cycle hourly --acquire-new-ip - zcp loadbalancer create --name my-lb --cloud-provider nimbo --project default-33 --region ixg-belagavi --network d-net-test --plan load-balancer --billing-cycle monthly --ip existing-ip-slug`, + Example: ` zcp loadbalancer create --name my-lb --cloud-provider zcp --project my-project --region mtl-1 --network my-network --plan load-balancer --billing-cycle hourly --acquire-new-ip + zcp loadbalancer create --name my-lb --cloud-provider zcp --project my-project --region mtl-1 --network my-network --plan load-balancer --billing-cycle monthly --ip existing-ip-slug`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } @@ -290,18 +293,21 @@ func newLBAttachVMCmd() *cobra.Command { Use: "attach-vm ", Short: "Attach VMs to a load balancer rule", Args: cobra.ExactArgs(2), - Example: ` zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --vm vm-slug-2 --cloud-provider nimbo --region ixg-belagavi --project default-33 - zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --cloud-provider nimbo --region ixg-belagavi --project default-33 --yes`, + Example: ` zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --vm vm-slug-2 --cloud-provider zcp --region mtl-1 --project my-project + zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --cloud-provider zcp --region mtl-1 --project my-project --yes`, RunE: func(cmd *cobra.Command, args []string) error { if len(vmSlugs) == 0 { return fmt.Errorf("at least one --vm is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/network.go b/internal/commands/network.go index cd8c342..149903c 100644 --- a/internal/commands/network.go +++ b/internal/commands/network.go @@ -82,12 +82,15 @@ func newNetworkCreateCmd() *cobra.Command { if categorySlug == "" { return fmt.Errorf("--category is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/snapshot.go b/internal/commands/snapshot.go index 4faee5e..e6e1d06 100644 --- a/internal/commands/snapshot.go +++ b/internal/commands/snapshot.go @@ -66,7 +66,7 @@ func newSnapshotCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a block storage snapshot", - Example: ` zcp snapshot create --volume root-4153 --name my-snapshot --plan snapshot-per-gb --cloud-provider nimbo --region noida --billing-cycle hourly --project default-73`, + Example: ` zcp snapshot create --volume root-1234 --name my-snapshot --plan snapshot-per-gb --cloud-provider zcp --region yow-1 --billing-cycle hourly --project my-project`, RunE: func(cmd *cobra.Command, args []string) error { if blockstorageSlug == "" { return fmt.Errorf("--volume is required") @@ -77,15 +77,18 @@ func newSnapshotCreateCmd() *cobra.Command { if plan == "" { return fmt.Errorf("--plan is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } if billingCycle == "" { return fmt.Errorf("--billing-cycle is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/template.go b/internal/commands/template.go index d69b58f..442db82 100644 --- a/internal/commands/template.go +++ b/internal/commands/template.go @@ -146,20 +146,23 @@ func newTemplateAccountCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "account-create", Short: "Create an account template", - Example: ` zcp template account-create --name my-template --cloud-provider nimbo \ - --region yow-1 --project default-1 --os-type-id \ + Example: ` zcp template account-create --name my-template --cloud-provider zcp \ + --region yow-1 --project my-project --os-type-id \ --image-type "Operating System" --os ubuntu --os-version "22.04 LTS" \ --billing-cycle hourly --url https://example.com/image.qcow2 --format QCOW2`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/virtualrouter.go b/internal/commands/virtualrouter.go index 9f74531..ad8661d 100644 --- a/internal/commands/virtualrouter.go +++ b/internal/commands/virtualrouter.go @@ -76,8 +76,8 @@ func newVirtualRouterCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a virtual router", - Example: ` zcp virtual-router create --name my-router --network --cloud-provider nimbo --region noida --project default-124 - zcp virtual-router create --name my-router --network --plan --cloud-provider nimbo --region noida --project default-124`, + Example: ` zcp virtual-router create --name my-router --network --cloud-provider zcp --region yow-1 --project my-project + zcp virtual-router create --name my-router --network --plan --cloud-provider zcp --region yow-1 --project my-project`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -85,12 +85,15 @@ func newVirtualRouterCreateCmd() *cobra.Command { if networkSlug == "" { return fmt.Errorf("--network is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/vmbackup.go b/internal/commands/vmbackup.go index 8fcd195..0c5ebd3 100644 --- a/internal/commands/vmbackup.go +++ b/internal/commands/vmbackup.go @@ -86,12 +86,14 @@ func newVMBackupCreateCmd() *cobra.Command { Use: "create ", Short: "Create a VM backup", Args: cobra.ExactArgs(1), - Example: ` zcp vm-backup create my-vm --interval daily --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-basic --psudo-service vm-backup --project default-60 - zcp vm-backup create my-vm --interval daily --immediate 1 --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-basic --psudo-service vm-backup --project default-60`, + Example: ` zcp vm-backup create my-vm --interval daily --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-basic --psudo-service vm-backup --project my-project + zcp vm-backup create my-vm --interval daily --immediate 1 --cloud-provider zcp --region yow-1 --billing-cycle hourly --plan backup-basic --psudo-service vm-backup --project my-project`, RunE: func(cmd *cobra.Command, args []string) error { + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } @@ -104,6 +106,7 @@ func newVMBackupCreateCmd() *cobra.Command { if psudoService == "" { return fmt.Errorf("--psudo-service is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/vmsnapshot.go b/internal/commands/vmsnapshot.go index dfe23ad..d5f9af2 100644 --- a/internal/commands/vmsnapshot.go +++ b/internal/commands/vmsnapshot.go @@ -79,6 +79,9 @@ func newVMSnapshotCreateCmd() *cobra.Command { if vmSlug == "" { return fmt.Errorf("--vm is required") } + project = resolveProject(project) + region = resolveRegion(region) + cloudProvider = resolveCloudProvider(cloudProvider) _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err diff --git a/internal/commands/volume.go b/internal/commands/volume.go index b6a7d3c..24ac71c 100644 --- a/internal/commands/volume.go +++ b/internal/commands/volume.go @@ -90,18 +90,21 @@ func newVolumeCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new block storage volume", - Example: ` zcp volume create --name my-disk --project default-73 --cloud-provider nimbo --region noida --billing-cycle hourly --storage-category nvme --plan 50-gb-2 - zcp volume create --name my-disk --project default-73 --cloud-provider nimbo --region noida --billing-cycle hourly --storage-category nvme --plan 50-gb-2 --vm vm-slug`, + Example: ` zcp volume create --name my-disk --project my-project --cloud-provider zcp --region yow-1 --billing-cycle hourly --storage-category nvme --plan 50-gb-2 + zcp volume create --name my-disk --project my-project --cloud-provider zcp --region yow-1 --billing-cycle hourly --storage-category nvme --plan 50-gb-2 --vm vm-slug`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } diff --git a/internal/commands/vpc.go b/internal/commands/vpc.go index 5259eed..c45b6af 100644 --- a/internal/commands/vpc.go +++ b/internal/commands/vpc.go @@ -124,18 +124,21 @@ func newVPCCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new VPC", - Example: ` zcp vpc create --name my-vpc --cloud-provider nimbo --region noida --project default-124 --plan vpc-1 --network-address 10.1.0.1 --size 16 --billing-cycle hourly --storage-category nvme - zcp vpc create --name my-vpc --cloud-provider nimbo --region noida --project default-124 --plan vpc-1 --network-address 10.1.0.1 --size 16 --billing-cycle hourly --storage-category nvme --description "Production VPC"`, + Example: ` zcp vpc create --name my-vpc --cloud-provider zcp --region yow-1 --project my-project --plan vpc-1 --network-address 10.1.0.1 --size 16 --billing-cycle hourly --storage-category nvme + zcp vpc create --name my-vpc --cloud-provider zcp --region yow-1 --project my-project --plan vpc-1 --network-address 10.1.0.1 --size 16 --billing-cycle hourly --storage-category nvme --description "Production VPC"`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/commands/vpn.go b/internal/commands/vpn.go index 1b21760..c5b49d9 100644 --- a/internal/commands/vpn.go +++ b/internal/commands/vpn.go @@ -134,12 +134,15 @@ func newVPNCGCreateCmd() *cobra.Command { if espPolicy == "" { return fmt.Errorf("--esp-policy is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } @@ -384,12 +387,15 @@ func newVPNUserCreateCmd() *cobra.Command { if username == "" { return fmt.Errorf("--username is required") } + cloudProvider = resolveCloudProvider(cloudProvider) if cloudProvider == "" { return fmt.Errorf("--cloud-provider is required") } + region = resolveRegion(region) if region == "" { return fmt.Errorf("--region is required") } + project = resolveProject(project) if project == "" { return fmt.Errorf("--project is required") } diff --git a/internal/config/config.go b/internal/config/config.go index 34c4fad..7025f0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,7 @@ import ( const ( // DefaultAPIURL is the default ZCP API base URL. - DefaultAPIURL = "https://portal.webberstop.com/backend/api" + DefaultAPIURL = "https://api.zcp.zsoftly.ca" // DefaultTimeout is the default HTTP request timeout in seconds. DefaultTimeout = 30 ) @@ -114,34 +114,61 @@ func Save(cfg *Config) error { } // ResolveProfile returns the Profile to use for a request. -// It prefers profileName if provided, else cfg.ActiveProfile. -// Returns an error if no profile is configured or credentials are missing. +// It prefers profileName if provided, else ZCP_PROFILE env var, else cfg.ActiveProfile. +// It also applies ZCP_BEARER_TOKEN and ZCP_API_URL environment variable overrides. +// Returns an error if no profile is configured or credentials are missing (unless overridden by env). func ResolveProfile(cfg *Config, profileName string) (*Profile, error) { name := profileName if name == "" { - name = cfg.ActiveProfile + name = os.Getenv("ZCP_PROFILE") } if name == "" { - return nil, errors.New("no active profile configured — run: zcp profile add") + name = cfg.ActiveProfile + } + + // Look up the named profile if one was resolved + var p Profile + var profileFound bool + if name != "" { + if prof, ok := cfg.Profiles[name]; ok { + p = prof + profileFound = true + } } - p, ok := cfg.Profiles[name] - if !ok { + // Override with environment variables + if envToken := os.Getenv("ZCP_BEARER_TOKEN"); envToken != "" { + p.BearerToken = envToken + } + if envURL := os.Getenv("ZCP_API_URL"); envURL != "" { + p.APIURL = envURL + } + + // Validate: profile not found (and no env override to save us) + if name != "" && !profileFound && p.BearerToken == "" { return nil, fmt.Errorf("profile %q not found — run: zcp profile list", name) } + + // Validate: credentials missing if p.BearerToken == "" { - return nil, fmt.Errorf("profile %q is missing credentials — run: zcp profile add", name) + if name == "" { + return nil, errors.New("no active profile configured and ZCP_BEARER_TOKEN not set — run: zcp profile add") + } + return nil, fmt.Errorf("profile %q is missing credentials and ZCP_BEARER_TOKEN not set — run: zcp profile add", name) } return &p, nil } // ActiveAPIURL returns the resolved API URL for the given profile, applying overrides. -// Order of precedence: flagURL > profile APIURL > DefaultAPIURL +// Order of precedence: flagURL > ZCP_API_URL env > profile APIURL > DefaultAPIURL func ActiveAPIURL(profile *Profile, flagURL string) string { if flagURL != "" { return flagURL } + if envURL := os.Getenv("ZCP_API_URL"); envURL != "" { + return envURL + } if profile != nil && profile.APIURL != "" { return profile.APIURL } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ab7261..801d512 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/zsoftly/zcp-cli/internal/config" @@ -86,18 +87,26 @@ func TestResolveProfile(t *testing.T) { name string profileName string wantErr bool + errContains string }{ - {"active profile", "", false}, - {"explicit profile", "prod", false}, - {"missing profile", "dev", true}, + {"active profile", "", false, ""}, + {"explicit profile", "prod", false, ""}, + {"missing profile", "dev", true, "not found"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "") // clear env so profile token is used + t.Setenv("ZCP_PROFILE", "") // prevent ambient leak p, err := config.ResolveProfile(cfg, tt.profileName) if (err != nil) != tt.wantErr { t.Errorf("ResolveProfile() error = %v, wantErr %v", err, tt.wantErr) } + if tt.wantErr && tt.errContains != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error = %q, want containing %q", err, tt.errContains) + } + } if !tt.wantErr && p == nil { t.Error("expected non-nil Profile") } @@ -105,13 +114,104 @@ func TestResolveProfile(t *testing.T) { } } +func TestResolveProfileEnvToken(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "env-token") + cfg := &config.Config{ + Profiles: map[string]config.Profile{}, + } + // No profile configured, but ZCP_BEARER_TOKEN is set — should succeed + p, err := config.ResolveProfile(cfg, "") + if err != nil { + t.Fatalf("expected success with ZCP_BEARER_TOKEN set, got: %v", err) + } + if p.BearerToken != "env-token" { + t.Errorf("BearerToken = %q, want %q", p.BearerToken, "env-token") + } +} + +func TestResolveProfileEnvTokenOverridesProfile(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "env-token") + t.Setenv("ZCP_PROFILE", "") // prevent ambient leak + cfg := &config.Config{ + ActiveProfile: "prod", + Profiles: map[string]config.Profile{ + "prod": {Name: "prod", BearerToken: "profile-token"}, + }, + } + p, err := config.ResolveProfile(cfg, "") + if err != nil { + t.Fatalf("ResolveProfile() error = %v", err) + } + if p.Name != "prod" { + t.Errorf("Name = %q, want %q (should resolve prod profile)", p.Name, "prod") + } + if p.BearerToken != "env-token" { + t.Errorf("BearerToken = %q, want env override %q", p.BearerToken, "env-token") + } +} + +func TestResolveProfileEnvProfile(t *testing.T) { + t.Setenv("ZCP_PROFILE", "staging") + t.Setenv("ZCP_BEARER_TOKEN", "") // clear so profile token is used + cfg := &config.Config{ + ActiveProfile: "prod", + Profiles: map[string]config.Profile{ + "prod": {Name: "prod", BearerToken: "prod-token"}, + "staging": {Name: "staging", BearerToken: "staging-token"}, + }, + } + p, err := config.ResolveProfile(cfg, "") + if err != nil { + t.Fatalf("ResolveProfile() error = %v", err) + } + if p.BearerToken != "staging-token" { + t.Errorf("BearerToken = %q, want %q (ZCP_PROFILE should select staging)", p.BearerToken, "staging-token") + } +} + +func TestResolveProfileEnvAPIURL(t *testing.T) { + t.Setenv("ZCP_API_URL", "https://env.example.com") + cfg := &config.Config{ + ActiveProfile: "prod", + Profiles: map[string]config.Profile{ + "prod": {Name: "prod", BearerToken: "token", APIURL: "https://profile.example.com"}, + }, + } + p, err := config.ResolveProfile(cfg, "") + if err != nil { + t.Fatalf("ResolveProfile() error = %v", err) + } + if p.APIURL != "https://env.example.com" { + t.Errorf("APIURL = %q, want env override %q", p.APIURL, "https://env.example.com") + } +} + +func TestActiveAPIURLEnvOverride(t *testing.T) { + t.Setenv("ZCP_API_URL", "https://env.example.com") + p := &config.Profile{APIURL: "https://profile.example.com"} + + got := config.ActiveAPIURL(p, "") + if got != "https://env.example.com" { + t.Errorf("ActiveAPIURL = %q, want env override %q", got, "https://env.example.com") + } + + // Flag still takes precedence over env + got = config.ActiveAPIURL(p, "https://flag.example.com") + if got != "https://flag.example.com" { + t.Errorf("ActiveAPIURL = %q, want flag override %q", got, "https://flag.example.com") + } +} + func TestResolveProfileNoActive(t *testing.T) { + // Clear env vars that could interfere + t.Setenv("ZCP_BEARER_TOKEN", "") + t.Setenv("ZCP_PROFILE", "") cfg := &config.Config{ Profiles: map[string]config.Profile{}, } _, err := config.ResolveProfile(cfg, "") if err == nil { - t.Error("expected error when no active profile, got nil") + t.Error("expected error when no active profile and no env vars, got nil") } }