From 360a3d6d17aa091407d7a9da6f2439db8c2caf5b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Dec 2025 14:19:40 +0000 Subject: [PATCH] Add --app-scope option for variables --- go-tests/go.mod | 2 +- go-tests/go.sum | 4 +- go-tests/variable_write_test.go | 140 +++++++++++++++---- src/Command/Variable/VariableCommandBase.php | 52 +++++++ 4 files changed, 171 insertions(+), 27 deletions(-) diff --git a/go-tests/go.mod b/go-tests/go.mod index ea1c082934..63938d932c 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -4,7 +4,7 @@ go 1.25 require ( github.com/go-chi/chi/v5 v5.2.3 - github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba + github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.45.0 ) diff --git a/go-tests/go.sum b/go-tests/go.sum index acfafbafbf..71edeabd14 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -5,8 +5,8 @@ github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hH github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba h1:3WGCXeQ5sGhK+0LBQLb1SGDTBdkqrQUoJJ4yBk8aytY= -github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba/go.mod h1:BIRcjGc1Hikr4axLI3BEjEon4Iuo1AKIkN1L5ILQGmE= +github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95 h1:8dNJJFWRammRQGOTZ/qT+pObpqkCxs+6aGNngpixJNs= +github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95/go.mod h1:2V6SWpEDjZsO51upyha6DHVifnSLT5UAaUWaX7jnN6Y= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/go-tests/variable_write_test.go b/go-tests/variable_write_test.go index 27e64a6eaf..c735c67002 100644 --- a/go-tests/variable_write_test.go +++ b/go-tests/variable_write_test.go @@ -8,29 +8,54 @@ import ( "github.com/stretchr/testify/assert" ) -func TestVariableCreate(t *testing.T) { +// variableTestSetup holds common test infrastructure for variable tests. +type variableTestSetup struct { + authServer *httptest.Server + apiServer *httptest.Server + apiHandler *mockapi.Handler + projectID string + mainEnv *mockapi.Environment + factory *cmdFactory +} + +// setupVariableTest creates the common test infrastructure for variable tests. +func setupVariableTest(t *testing.T) *variableTestSetup { authServer := mockapi.NewAuthServer(t) - defer authServer.Close() + t.Cleanup(authServer.Close) apiHandler := mockapi.NewHandler(t) apiServer := httptest.NewServer(apiHandler) - defer apiServer.Close() + t.Cleanup(apiServer.Close) projectID := mockapi.ProjectID() apiHandler.SetProjects([]*mockapi.Project{{ ID: projectID, - Links: mockapi.MakeHALLinks("self=/projects/"+projectID, - "environments=/projects/"+projectID+"/environments"), + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), DefaultBranch: "main", }}) - main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} - main.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} - envs := []*mockapi.Environment{main} - apiHandler.SetEnvironments(envs) - apiHandler.SetProjectVariables(projectID, []*mockapi.Variable{ + mainEnv := makeEnv(projectID, "main", "production", "active", nil) + mainEnv.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} + mainEnv.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} + + return &variableTestSetup{ + authServer: authServer, + apiServer: apiServer, + apiHandler: apiHandler, + projectID: projectID, + mainEnv: mainEnv, + factory: newCommandFactory(t, apiServer.URL, authServer.URL), + } +} + +func TestVariableCreate(t *testing.T) { + s := setupVariableTest(t) + s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv}) + s.apiHandler.SetProjectVariables(s.projectID, []*mockapi.Variable{ { Name: "existing", IsSensitive: true, @@ -38,31 +63,98 @@ func TestVariableCreate(t *testing.T) { }, }) - f := newCommandFactory(t, apiServer.URL, authServer.URL) + f, p := s.factory, s.projectID - _, stdErr, err := f.RunCombinedOutput("var:create", "-p", projectID, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value") + _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value") assert.NoError(t, err) assert.Contains(t, stdErr, "Creating variable env:TEST on the environment main") - assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value")) + assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value")) - _, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value") + _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value") assert.NoError(t, err) - assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+projectID) + assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+p) - assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "p")) - assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "e")) + assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "p")) + assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "e")) - _, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "existing", "-l", "p", "--value", "test") + _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "existing", "-l", "p", "--value", "test") assert.Error(t, err) assert.Contains(t, stdErr, "The variable already exists") - _, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value2") + _, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value2") + assert.NoError(t, err) + assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "value")) + + assertTrimmed(t, "true", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime")) + _, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--visible-runtime", "false") + assert.NoError(t, err) + assertTrimmed(t, "false", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime")) +} + +func TestVariableCreateWithAppScope(t *testing.T) { + s := setupVariableTest(t) + + // Set up deployment with app names for validation. + s.mainEnv.SetCurrentDeployment(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app1": {Name: "app1", Type: "golang:1.23"}, + "app2": {Name: "app2", Type: "php:8.3"}, + }, + Routes: make(map[string]any), + Links: mockapi.MakeHALLinks("self=/projects/" + s.projectID + "/environments/main/deployment/current"), + }) + s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv}) + + f, p := s.factory, s.projectID + + // Test creating project-level variable with single app-scope. + _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p", + "env:SCOPED", "--value", "val", "--app-scope", "app1") + assert.NoError(t, err) + assert.Contains(t, stdErr, "Creating variable env:SCOPED") + + // Verify application_scope was set. + out := f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope") + assert.Contains(t, out, "app1") + + // Test creating variable with multiple app scopes. + _, _, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p", + "env:MULTI", "--value", "val", "--app-scope", "app1", "--app-scope", "app2") assert.NoError(t, err) - assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "value")) - assertTrimmed(t, "true", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime")) - _, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--visible-runtime", "false") + out = f.Run("var:get", "-p", p, "-l", "p", "env:MULTI", "-P", "application_scope") + assert.Contains(t, out, "app1") + assert.Contains(t, out, "app2") + + // Test validation rejects invalid app names (when deployment exists). + _, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p", + "env:BAD", "--value", "val", "--app-scope", "nonexistent") + assert.Error(t, err) + assert.Contains(t, stdErr, "was not found") + + // Test updating app-scope. + _, _, err = f.RunCombinedOutput("var:update", "-p", p, "-l", "p", + "env:SCOPED", "--app-scope", "app2") assert.NoError(t, err) - assertTrimmed(t, "false", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime")) + + out = f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope") + assert.Contains(t, out, "app2") +} + +func TestVariableCreateWithAppScopeNoDeployment(t *testing.T) { + // Uses an environment without a deployment, so app-scope validation is skipped. + s := setupVariableTest(t) + s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv}) + + f, p := s.factory, s.projectID + + // Without a deployment, any app-scope value should be accepted. + _, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p", + "env:ANY_APP", "--value", "val", "--app-scope", "anyapp") + assert.NoError(t, err) + assert.Contains(t, stdErr, "Creating variable env:ANY_APP") + + out := f.Run("var:get", "-p", p, "-l", "p", "env:ANY_APP", "-P", "application_scope") + assert.Contains(t, out, "anyapp") } diff --git a/src/Command/Variable/VariableCommandBase.php b/src/Command/Variable/VariableCommandBase.php index d2f53b3f10..9d10a8f618 100644 --- a/src/Command/Variable/VariableCommandBase.php +++ b/src/Command/Variable/VariableCommandBase.php @@ -4,9 +4,12 @@ use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Console\AdaptiveTableCell; +use Platformsh\Client\Model\Environment; +use Platformsh\Client\Model\Project; use Platformsh\Client\Model\ProjectLevelVariable; use Platformsh\Client\Model\Resource as ApiResource; use Platformsh\Client\Model\Variable as EnvironmentLevelVariable; +use Platformsh\ConsoleForm\Field\ArrayField; use Platformsh\ConsoleForm\Field\BooleanField; use Platformsh\ConsoleForm\Field\Field; use Platformsh\ConsoleForm\Field\OptionsField; @@ -182,6 +185,31 @@ protected function getFields() return null; }, ]); + $fields['application_scope'] = new ArrayField('Application scope', [ + 'optionName' => 'app-scope', + 'description' => 'A list of application names to which this variable will apply.', + 'questionLine' => 'To which applications should this variable apply?', + 'default' => [], + 'required' => false, + 'avoidQuestion' => true, + 'validator' => function ($values) { + $appNames = $this->listApps($this->getSelectedProject(), $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment() : null); + if ($appNames === false) { + // No app names available: skip validation. + return true; + } + foreach ($values as $value) { + if (!in_array($value, $appNames, true)) { + throw new InvalidArgumentException(sprintf( + 'The app "%s" was not found. Valid app names are: %s', + $value, + implode(', ', $appNames) + )); + } + } + return true; + }, + ]); $fields['name'] = new Field('Name', [ 'description' => 'The variable name', 'validators' => [ @@ -277,4 +305,28 @@ private function getPrefixOptions($name) 'env:' => 'env: The variable will be exposed directly, e.g. as $' . strtoupper($name) . '.', ]; } + + /** + * List application names for validating application_scope values. + * + * @param Project $project + * @param Environment|null $environment If not provided, the project's default environment will be used. + * + * @return string[]|false + */ + private function listApps(Project $project, Environment $environment = null) { + if (!$environment) { + if ($project->default_branch !== '') { + $environment = $this->api()->getEnvironment($project->default_branch, $project); + } + if (!$environment) { + return false; + } + } + $deployment = $this->api()->getCurrentDeployment($environment, false, false); + if (!$deployment) { + return false; + } + return array_keys($deployment->webapps); + } }