Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Requires a GitHub token via `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`. For

Tear down fullsend from the specified repos and remove them from the manifest. By default, the command tears down first (deleting workflow files, variables, and secrets), then removes successfully-torn-down repos from the manifest. Partial failures leave the manifest entry intact so the user can retry.

GCP WIF cleanup is handled separately via `inference deprovision`.
GCP WIF pool/provider cleanup is handled separately via `inference deprovision`. For GitLab WIF-mode repos, `repos uninstall` performs best-effort deletion of the bot token Secret Manager secret.

When multiple repos are targeted (via globs or explicit bulk lists), the command prompts for confirmation unless `--yes` is set.

Expand Down
1 change: 1 addition & 0 deletions docs/guides/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ To remove fullsend from a single repository:
2. Delete all CI/CD variables prefixed with `FULLSEND_`
3. Revoke the `fullsend-bot` project access token (Settings → Access Tokens)
4. Delete fullsend pipeline schedules
5. For WIF-mode repos: delete the bot token Secret Manager secret (named `fullsend-bot-token-<owner>--<repo>`) from the GCP project

If you manage your own self-hosted mint, run `fullsend mint unenroll "$OWNER/$REPO"` instead of GitHub step 3. See the [standalone commands](#standalone-commands) table for details.

Expand Down
61 changes: 50 additions & 11 deletions internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,8 @@ type reposUninstallConfig struct {
uninstallOnly bool
gitlabToken string

testClient forge.Client
testClient forge.Client
testGCPClientFactory func(projectID string) gcf.GCFClient
}

func newReposUninstallCmd() *cobra.Command {
Expand Down Expand Up @@ -1003,6 +1004,36 @@ func runReposUninstall(ctx context.Context, opts *reposUninstallConfig, repoArgs
return err
}

// Pre-uninstall: gather GCP project IDs for GitLab WIF repos
// so we can delete Secret Manager secrets after teardown. The
// FULLSEND_SA variable is deleted during uninstall, so we read
Comment thread
ggallen marked this conversation as resolved.
// it now.
gcpProjectByRepo := make(map[string]string)
if !opts.dryRun {
for _, repoName := range concreteRepos {
parts := strings.SplitN(repoName, "/", 2)
if len(parts) != 2 {
continue
}
owner, repo := parts[0], parts[1]
rc, ok := manifest.ResolveConfigWithGlobs(owner, repo)
if !ok || rc.Forge != repos.ForgeGitLab {
continue
}
fc, fcErr := clients.ConfigFor(repos.ForgeGitLab)
if fcErr != nil {
continue
}
Comment thread
ggallen marked this conversation as resolved.
sa, found, readErr := fc.Client.GetRepoVariable(ctx, owner, repo, "FULLSEND_SA")
if readErr != nil || !found {
continue
}
if projectID := projectIDFromSAEmail(sa); projectID != "" {
gcpProjectByRepo[repoName] = projectID
}
}
}

teardownCfg := repos.UninstallConfig{
Manifest: manifest,
Repos: concreteRepos,
Expand Down Expand Up @@ -1031,11 +1062,14 @@ func runReposUninstall(ctx context.Context, opts *reposUninstallConfig, repoArgs
}
}

// GitLab post-uninstall: clean up pipeline schedules and bot tokens.
// Note: if the CLI's --gitlab-token lacks permission to list/revoke
// project access tokens, the bot token will be orphaned. The user
// must manually revoke it via Settings → Access Tokens.
// GitLab post-uninstall: clean up pipeline schedules, bot tokens,
// and Secret Manager secrets.
if !opts.dryRun {
newGCPClient := opts.testGCPClientFactory
if newGCPClient == nil {
newGCPClient = func(pid string) gcf.GCFClient { return gcf.NewLiveGCFClient(pid) }
}

for _, r := range results {
if !r.Success {
continue
Expand All @@ -1053,15 +1087,20 @@ func runReposUninstall(ctx context.Context, opts *reposUninstallConfig, repoArgs
printer.StepWarn(fmt.Sprintf("[%s] Could not get GitLab client: %v", repoFullName, fcErr))
continue
}
glClient, ok := fc.Client.(*gl.LiveClient)
if !ok {
_ = cleanupGitLabPipelineSchedules(ctx, fc.Client, printer, r.Owner, r.Repo)

if glClient, ok := fc.Client.(*gl.LiveClient); ok {
_ = cleanupGitLabBotToken(ctx, glClient, printer, r.Owner, r.Repo)
} else {
printer.StepWarn(fmt.Sprintf("[%s] GitLab client type assertion failed — bot token cleanup skipped", repoFullName))
_ = cleanupGitLabPipelineSchedules(ctx, fc.Client, printer, r.Owner, r.Repo)
continue
}

_ = cleanupGitLabPipelineSchedules(ctx, fc.Client, printer, r.Owner, r.Repo)
_ = cleanupGitLabBotToken(ctx, glClient, printer, r.Owner, r.Repo)
// Best-effort: delete the bot token Secret Manager
// secret if we know the GCP project from the pre-
// uninstall variable read.
if projectID, ok := gcpProjectByRepo[repoFullName]; ok {
cleanupGitLabBotTokenSecret(ctx, newGCPClient(projectID), printer, projectID, r.Owner, r.Repo)
}
}
}
} else {
Expand Down
90 changes: 87 additions & 3 deletions internal/cli/repos_gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ var secretIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]`)
const secretIDMaxLen = 255

// botTokenSecretID returns the Secret Manager secret ID for a repo's bot token.
// Slashes in GitLab subgroup paths are mapped to double underscores so that
// "group/sub" and "group-sub" produce distinct IDs.
// Slashes in GitLab subgroup paths are mapped to double underscores and dots
// are mapped to "_dot_" so that "group/sub", "group-sub", "my.group", and
// "my-group" all produce distinct IDs. Note: a literal "_dot_" in a name would
// collide with a dot-mapped name; this is accepted as extremely unlikely.
func botTokenSecretID(owner, repo string) (string, error) {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
combined := strings.ReplaceAll(owner, "/", "__") + "--" + repo
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
combined = strings.ReplaceAll(combined, ".", "_dot_")
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
sanitized := secretIDSanitizer.ReplaceAllString(combined, "-")
id := "fullsend-bot-token-" + sanitized
if len(id) > secretIDMaxLen {
Expand All @@ -48,6 +51,15 @@ func botTokenSecretID(owner, repo string) (string, error) {
return id, nil
}

// legacyBotTokenSecretID returns the pre-_dot_ secret ID for migration.
// Before the _dot_ mapping was added, dots were mapped to hyphens by the
// sanitizer. This is used during cleanup to delete secrets created under
// the old naming scheme.
Comment thread
ggallen marked this conversation as resolved.
func legacyBotTokenSecretID(owner, repo string) string {
Comment thread
ggallen marked this conversation as resolved.
combined := strings.ReplaceAll(owner, "/", "__") + "--" + repo
return "fullsend-bot-token-" + secretIDSanitizer.ReplaceAllString(combined, "-")
}

// setupGitLabBotToken creates a project access token for the fullsend bot
// identity and stores it appropriately based on the credential mode.
//
Expand All @@ -66,6 +78,7 @@ func botTokenSecretID(owner, repo string) (string, error) {
func setupGitLabBotToken(ctx context.Context, client forge.Client, glClient *gitlab.LiveClient, printer *ui.Printer, owner, repo, fallbackToken string, wifCfg *botTokenWIFConfig) (string, error) {
printer.StepStart("Creating project access token")
var botPAT string
var botTokenID int
if glClient != nil {
// Revoke any existing fullsend-bot tokens to avoid duplicates on re-install.
existing, listErr := glClient.ListProjectAccessTokens(ctx, owner, repo)
Expand Down Expand Up @@ -98,6 +111,7 @@ func setupGitLabBotToken(ctx context.Context, client forge.Client, glClient *git
}
} else {
botPAT = token.Token
botTokenID = token.ID
printer.StepDone(fmt.Sprintf("Created project access token %q (ID: %d)", gitlabBotTokenName, token.ID))
}
} else if fallbackToken != "" {
Expand Down Expand Up @@ -125,19 +139,45 @@ func setupGitLabBotToken(ctx context.Context, client forge.Client, glClient *git
// Grant the WIF service account access to read the secret.
Comment thread
ggallen marked this conversation as resolved.
saEmail := gcf.MintServiceAccountEmail(wifCfg.ProjectID)
secretResource := fmt.Sprintf("projects/%s/secrets/%s", wifCfg.ProjectID, secretID)
if err := wifCfg.GCPClient.SetSecretIAMBinding(ctx, secretResource,
if err := wifCfg.GCPClient.ReplaceSecretIAMBinding(ctx, secretResource,
"serviceAccount:"+saEmail, "roles/secretmanager.secretAccessor"); err != nil {
Comment thread
ggallen marked this conversation as resolved.
// Best-effort cleanup: delete the orphaned secret and revoke the PAT.
if delErr := wifCfg.GCPClient.DeleteSecret(ctx, wifCfg.ProjectID, secretID); delErr != nil {
printer.StepWarn(fmt.Sprintf("Failed to clean up secret %s: %v", secretID, delErr))
}
if botTokenID != 0 && glClient != nil {
if revErr := glClient.RevokeProjectAccessToken(ctx, owner, repo, botTokenID); revErr != nil {
printer.StepWarn(fmt.Sprintf("Failed to revoke bot PAT (ID %d): %v", botTokenID, revErr))
}
}
printer.StepFail("Failed to grant secret access")
return "", fmt.Errorf("granting secret access for %s: %w", secretID, err)
}

// Set FULLSEND_BOT_TOKEN_SECRET as a protected CI/CD variable
// so the scaffold knows which secret to read from Secret Manager.
if err := client.CreateProtectedCIVariable(ctx, owner, repo, "FULLSEND_BOT_TOKEN_SECRET", secretID); err != nil {
// Best-effort cleanup: delete the orphaned secret and revoke the PAT.
if delErr := wifCfg.GCPClient.DeleteSecret(ctx, wifCfg.ProjectID, secretID); delErr != nil {
printer.StepWarn(fmt.Sprintf("Failed to clean up secret %s: %v", secretID, delErr))
}
if botTokenID != 0 && glClient != nil {
if revErr := glClient.RevokeProjectAccessToken(ctx, owner, repo, botTokenID); revErr != nil {
printer.StepWarn(fmt.Sprintf("Failed to revoke bot PAT (ID %d): %v", botTokenID, revErr))
}
}
printer.StepFail("Failed to set FULLSEND_BOT_TOKEN_SECRET")
return "", fmt.Errorf("setting FULLSEND_BOT_TOKEN_SECRET: %w", err)
}
printer.StepDone("Bot credentials stored in Secret Manager")

// Best-effort: delete any legacy-named secret left by
// a previous install that used dot-to-hyphen mapping.
if legacyID := legacyBotTokenSecretID(owner, repo); legacyID != secretID {
if err := wifCfg.GCPClient.DeleteSecret(ctx, wifCfg.ProjectID, legacyID); err == nil {
printer.StepDone(fmt.Sprintf("Deleted legacy secret %s", legacyID))
}
}
} else {
// Variable mode: store bot PAT directly as a protected CI/CD variable.
printer.StepStart("Storing bot credentials")
Expand Down Expand Up @@ -262,6 +302,50 @@ func cleanupGitLabPipelineSchedules(ctx context.Context, client forge.Client, pr
return nil
}

// cleanupGitLabBotTokenSecret deletes the bot token Secret Manager secret
// and is a best-effort operation — errors are logged but not returned.
// This handles the GCP side of cleanup; the GitLab side (CI/CD variables,
// PAT revocation) is handled by the main uninstall path and
// cleanupGitLabBotToken.
//
// Tries both the current naming scheme (_dot_ for dots) and the legacy
// scheme (dots mapped to hyphens by the sanitizer) to handle secrets
// created before the _dot_ mapping was introduced.
func cleanupGitLabBotTokenSecret(ctx context.Context, gcpClient gcf.GCFClient, printer *ui.Printer, projectID, owner, repo string) {
secretID, err := botTokenSecretID(owner, repo)
if err != nil {
printer.StepWarn(fmt.Sprintf("Failed to derive secret ID for %s/%s: %v", owner, repo, err))
return
}
if err := gcpClient.DeleteSecret(ctx, projectID, secretID); err != nil {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
printer.StepWarn(fmt.Sprintf("Failed to delete Secret Manager secret %s: %v", secretID, err))
} else {
printer.StepDone(fmt.Sprintf("Deleted Secret Manager secret %s", secretID))
}

legacyID := legacyBotTokenSecretID(owner, repo)
if legacyID != secretID {
if err := gcpClient.DeleteSecret(ctx, projectID, legacyID); err == nil {
printer.StepDone(fmt.Sprintf("Deleted legacy Secret Manager secret %s", legacyID))
}
}
}

// projectIDFromSAEmail extracts the GCP project ID from a service account
// email in the standard format: name@{projectID}.iam.gserviceaccount.com.
// Returns an empty string if the email doesn't match the expected format.
func projectIDFromSAEmail(email string) string {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
return ""
}
const suffix = ".iam.gserviceaccount.com"
if !strings.HasSuffix(parts[1], suffix) {
return ""
}
return strings.TrimSuffix(parts[1], suffix)
}

// cleanupGitLabBotToken revokes any active fullsend bot project access
// tokens from a GitLab project.
func cleanupGitLabBotToken(ctx context.Context, glClient *gitlab.LiveClient, printer *ui.Printer, owner, repo string) error {
Expand Down
Loading
Loading