diff --git a/actions/runner.go b/actions/runner.go index f040b6b3..2c4a8e83 100644 --- a/actions/runner.go +++ b/actions/runner.go @@ -361,6 +361,12 @@ func extraEnvForProject(cfg *config.GitteConfig, st *state.GitteState, projName continue } + // A gate applies only within its configured scope. A per-machine override may + // narrow that set further, but must never broaden it — so always require the + // config scope to match, then additionally the override when one is set. + if !ProjectMatchesScopeByName(projName, proj, gate.Scope) { + continue + } if fs.OverrideScope != nil { host, path, _, err := config.ParseRemoteURL(proj.Remote) if err != nil { @@ -369,8 +375,6 @@ func extraEnvForProject(cfg *config.GitteConfig, st *state.GitteState, projName if !features.ProjectMatchesOverrideScope(projName, host, path, fs.OverrideScope) { continue } - } else if !ProjectMatchesScopeByName(projName, proj, gate.Scope) { - continue } for k, v := range gate.Effects.Env { diff --git a/actions/runner_features_test.go b/actions/runner_features_test.go new file mode 100644 index 00000000..7f564818 --- /dev/null +++ b/actions/runner_features_test.go @@ -0,0 +1,60 @@ +package actions + +import ( + "testing" + + "github.com/cego/gitte/config" + "github.com/cego/gitte/state" +) + +// TestExtraEnvForProject_OverrideCannotBroadenConfigScope guards against a scoped +// `features disable` (or the TUI scope editor) reconstructing an OverrideScope that +// matches projects outside the gate's configured scope. The override may only narrow +// the config scope, never broaden it. +func TestExtraEnvForProject_OverrideCannotBroadenConfigScope(t *testing.T) { + cfg := &config.GitteConfig{ + FeatureGates: map[string]config.FeatureGate{ + "hot": { + Scope: config.FeatureScope{Projects: []string{"svc-a", "svc-b"}}, + Effects: config.FeatureEffects{Env: map[string]string{"FOO": "bar"}}, + }, + }, + Projects: map[string]config.ProjectConfig{ + "svc-a": {Remote: "git@gitlab.example.com:myorg/services/svc-a.git"}, + "svc-b": {Remote: "git@gitlab.example.com:myorg/services/svc-b.git"}, + "other": {Remote: "git@gitlab.example.com:myorg/tools/other.git"}, + }, + } + + // Simulates the state after `disable hot --project svc-a`: the reconstruction + // collapses the remaining projects into the top-level "myorg" group excluding + // svc-a — a group that also covers myorg/tools/other, which was never in scope. + st := &state.GitteState{ + Features: map[string]state.FeatureState{ + "hot": { + Enabled: true, + OverrideScope: &state.ScopeOverride{ + GitlabGroups: []state.ScopeOverrideGroup{ + {Host: "gitlab.example.com", Group: "myorg", ExcludeProjects: []string{"svc-a"}}, + }, + }, + }, + }, + } + + cases := []struct { + proj string + wantEnv bool + }{ + {"svc-b", true}, // still in scope and not excluded + {"svc-a", false}, // in config scope but excluded by the override + {"other", false}, // NEVER in config scope — must not be broadened in + } + + for _, tc := range cases { + env := extraEnvForProject(cfg, st, tc.proj, cfg.Projects[tc.proj]) + if got := env["FOO"] == "bar"; got != tc.wantEnv { + t.Errorf("%s: got env=%v, want %v (env=%v)", tc.proj, got, tc.wantEnv, env) + } + } +} diff --git a/cmd/features.go b/cmd/features.go index 6cf0294b..40462f01 100644 --- a/cmd/features.go +++ b/cmd/features.go @@ -81,25 +81,16 @@ func newFeaturesEnableCmd() *cobra.Command { return fmt.Errorf("unknown feature gate: %q", gateName) } + if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 { + return fmt.Errorf("--exclude requires --gitlab-group or --github-org") + } + fs := state.FeatureState{Enabled: true} if len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0 { - override := &state.ScopeOverride{Projects: projects} - for _, g := range gitlabGroups { - host, group, ok := strings.Cut(g, "/") - if !ok { - return fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g) - } - entry := state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes} - override.GitlabGroups = append(override.GitlabGroups, entry) - } - for _, o := range githubOrgs { - host, org, ok := strings.Cut(o, "/") - if !ok { - return fmt.Errorf("invalid --github-org format %q, expected host/org", o) - } - entry := state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes} - override.GithubOrgs = append(override.GithubOrgs, entry) + override, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes) + if err != nil { + return err } fs.OverrideScope = override } @@ -122,27 +113,128 @@ func newFeaturesEnableCmd() *cobra.Command { } func newFeaturesDisableCmd() *cobra.Command { - return &cobra.Command{ + var ( + projects []string + gitlabGroups []string + githubOrgs []string + excludes []string + ) + + cmd := &cobra.Command{ Use: "disable ", - Short: "Disable a feature gate", + Short: "Disable a feature gate, or (with scope flags) disable it only for specific projects", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gateName := args[0] - if _, ok := globalSt.Features[gateName]; !ok { + if len(excludes) > 0 && len(gitlabGroups) == 0 && len(githubOrgs) == 0 { + return fmt.Errorf("--exclude requires --gitlab-group or --github-org") + } + + fs, ok := globalSt.Features[gateName] + scoped := len(projects) > 0 || len(gitlabGroups) > 0 || len(githubOrgs) > 0 + + // Unscoped disable only consults state, so it can also clean up a stale + // entry for a gate that has since been removed from the config. + if !scoped { + if !ok { + fmt.Printf("Feature gate %q was not enabled\n", gateName) + return nil + } + delete(globalSt.Features, gateName) + if err := state.Save(globalCwd, globalSt); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + fmt.Printf("Feature gate %q disabled\n", gateName) + return nil + } + + // Scoped disable needs the gate's configured scope to reconstruct the override. + gate, cfgOK := globalCfg.FeatureGates[gateName] + if !cfgOK { + return fmt.Errorf("unknown feature gate: %q", gateName) + } + if !ok || !fs.Enabled { fmt.Printf("Feature gate %q was not enabled\n", gateName) return nil } - delete(globalSt.Features, gateName) + // Remove the matching projects from the gate's current scope, leaving it + // enabled for the rest. + removal, err := buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes) + if err != nil { + return err + } + + scopeProjects := features.ProjectsInGateScope(globalCfg, gate) + checked := features.OverrideToCheckedState(fs.OverrideScope, scopeProjects) + + removed := 0 + for name, sp := range scopeProjects { + if checked[name] && features.ProjectMatchesOverrideScope(name, sp.Host, sp.Path, removal) { + checked[name] = false + removed++ + } + } + + if removed == 0 { + fmt.Printf("Feature gate %q was not enabled for the given project(s)\n", gateName) + return nil + } + + anyLeft := false + for _, v := range checked { + if v { + anyLeft = true + break + } + } + + if anyLeft { + fs.OverrideScope = features.CheckedStateToOverride(checked, scopeProjects) + globalSt.Features[gateName] = fs + } else { + delete(globalSt.Features, gateName) + } + if err := state.Save(globalCwd, globalSt); err != nil { return fmt.Errorf("failed to save state: %w", err) } - fmt.Printf("Feature gate %q disabled\n", gateName) + if anyLeft { + fmt.Printf("Feature gate %q disabled for the given project(s)\n", gateName) + } else { + fmt.Printf("Feature gate %q disabled\n", gateName) + } return nil }, } + + cmd.Flags().StringArrayVar(&projects, "project", nil, "disable only for specific project(s)") + cmd.Flags().StringArrayVar(&gitlabGroups, "gitlab-group", nil, "disable only for gitlab group (host/group)") + cmd.Flags().StringArrayVar(&githubOrgs, "github-org", nil, "disable only for github org (host/org)") + cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "exclude project from all groups/orgs") + return cmd +} + +// buildOverrideFromFlags turns the CLI scope flags into a ScopeOverride. +func buildOverrideFromFlags(projects, gitlabGroups, githubOrgs, excludes []string) (*state.ScopeOverride, error) { + override := &state.ScopeOverride{Projects: projects} + for _, g := range gitlabGroups { + host, group, ok := strings.Cut(g, "/") + if !ok { + return nil, fmt.Errorf("invalid --gitlab-group format %q, expected host/group", g) + } + override.GitlabGroups = append(override.GitlabGroups, state.ScopeOverrideGroup{Host: host, Group: group, ExcludeProjects: excludes}) + } + for _, o := range githubOrgs { + host, org, ok := strings.Cut(o, "/") + if !ok { + return nil, fmt.Errorf("invalid --github-org format %q, expected host/org", o) + } + override.GithubOrgs = append(override.GithubOrgs, state.ScopeOverrideOrg{Host: host, Org: org, ExcludeProjects: excludes}) + } + return override, nil } func buildOverrideScopeDescription(o *state.ScopeOverride) string { diff --git a/docs/commands.md b/docs/commands.md index 5c3cf705..555391d9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -89,8 +89,19 @@ Manage feature gates — opt-in behaviours that inject environment variables int ```bash gitte features list # show all gates and their state -gitte features enable HOT_RELOAD # enable a gate -gitte features disable HOT_RELOAD # disable a gate +gitte features enable HOT_RELOAD # enable a gate (all projects in its scope) +gitte features disable HOT_RELOAD # disable a gate entirely +``` + +Both `enable` and `disable` accept scope flags to target individual projects, GitLab +groups, or GitHub orgs. A scoped `disable` removes only the matching projects from the +gate's current scope, leaving it enabled for the rest (and disables the gate entirely +once the last project is removed): + +```bash +gitte features enable HOT_RELOAD --project frontend # enable for one project only +gitte features disable HOT_RELOAD --project frontend # disable for one project only +gitte features disable HOT_RELOAD --gitlab-group gitlab.example.com/myorg/services ``` --- diff --git a/docs/config.md b/docs/config.md index 0ba93fc8..e3e29268 100644 --- a/docs/config.md +++ b/docs/config.md @@ -366,6 +366,15 @@ gitte features enable HOT_RELOAD gitte features disable HOT_RELOAD ``` +`enable` and `disable` both accept `--project`, `--gitlab-group`, and `--github-org` +flags to target a subset. A scoped `disable` removes only the matching projects from the +gate's scope, leaving it on for the rest: + +```bash +gitte features enable HOT_RELOAD --project frontend +gitte features disable HOT_RELOAD --project frontend +``` + --- ## sources diff --git a/features/scope.go b/features/scope.go index 19e251e4..6038e5ea 100644 --- a/features/scope.go +++ b/features/scope.go @@ -4,9 +4,26 @@ import ( "sort" "strings" + "github.com/cego/gitte/config" "github.com/cego/gitte/state" ) +// ProjectsInGateScope returns the projects a gate's configured scope applies to, +// keyed by config project name. Projects with an unparseable remote are skipped. +func ProjectsInGateScope(cfg *config.GitteConfig, gate config.FeatureGate) map[string]ScopeProject { + projects := make(map[string]ScopeProject) + for projName, proj := range cfg.Projects { + host, path, _, err := config.ParseRemoteURL(proj.Remote) + if err != nil { + continue + } + if projectMatchesScopeByName(projName, host, path, gate.Scope) { + projects[projName] = ScopeProject{Host: host, Path: path} + } + } + return projects +} + // ProjectMatchesOverrideScope checks if a project is included in an override scope. // projName is the config key, host and path come from config.ParseRemoteURL. // Returns false if override is nil (caller should use config scope instead). diff --git a/features/scope_test.go b/features/scope_test.go index e88129b8..c38213f2 100644 --- a/features/scope_test.go +++ b/features/scope_test.go @@ -3,6 +3,7 @@ package features import ( "testing" + "github.com/cego/gitte/config" "github.com/cego/gitte/state" ) @@ -155,6 +156,52 @@ func TestCheckedStateToOverride_PartialGroup(t *testing.T) { } } +func TestProjectsInGateScope_EmptyScopeMatchesAll(t *testing.T) { + cfg := &config.GitteConfig{ + Projects: map[string]config.ProjectConfig{ + "monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"}, + "promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"}, + }, + } + gate := config.FeatureGate{} // no scope => all projects + + got := ProjectsInGateScope(cfg, gate) + if len(got) != 2 { + t.Fatalf("expected 2 projects, got %d", len(got)) + } + if got["promo"].Path != "spilnu/services/promo" { + t.Errorf("expected parsed path for promo, got %q", got["promo"].Path) + } +} + +func TestProjectsInGateScope_RestrictedByGitlabGroup(t *testing.T) { + cfg := &config.GitteConfig{ + Projects: map[string]config.ProjectConfig{ + "monolith": {Remote: "git@gitlab.cego.dk:cego/monolith.git"}, + "promo": {Remote: "git@gitlab.cego.dk:spilnu/services/promo.git"}, + "broken": {Remote: "not-a-valid-remote"}, + }, + } + gate := config.FeatureGate{ + Scope: config.FeatureScope{ + GitlabGroups: []config.GitlabScope{ + {Host: "gitlab.cego.dk", Group: "spilnu"}, + }, + }, + } + + got := ProjectsInGateScope(cfg, gate) + if len(got) != 1 { + t.Fatalf("expected 1 project in scope, got %d", len(got)) + } + if _, ok := got["promo"]; !ok { + t.Error("expected promo to be in scope") + } + if _, ok := got["monolith"]; ok { + t.Error("expected monolith to be out of scope") + } +} + func TestOverrideToCheckedState(t *testing.T) { override := &state.ScopeOverride{ GitlabGroups: []state.ScopeOverrideGroup{ diff --git a/features/tui.go b/features/tui.go index c8d43438..37e058a2 100644 --- a/features/tui.go +++ b/features/tui.go @@ -457,16 +457,7 @@ func (m *featuresModel) scopeUndo() { func (m *featuresModel) enterScopeTree() { g := m.gates[m.gateCursor] - projects := make(map[string]ScopeProject) - for projName, proj := range m.cfg.Projects { - host, path, _, err := config.ParseRemoteURL(proj.Remote) - if err != nil { - continue - } - if projectMatchesScopeByName(projName, host, path, g.Gate.Scope) { - projects[projName] = ScopeProject{Host: host, Path: path} - } - } + projects := ProjectsInGateScope(m.cfg, g.Gate) fs := m.st.Features[g.Name] var checked map[string]bool