Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • Mat.G/cli
  • viktomas/cli
  • h1zeb2/cli
  • Cl00e9ment/cli
  • pylbrecht/glab
  • kebe7jun/cli
  • rsrchboy/glab
  • gitlab-org/cli
  • rm3l/cli
  • quentin.laplanche/cli
  • riton/cli
  • thrymgjol/cli
  • xaocon/cli
  • dmakovey/gitlab-cli
  • shaun35/cli
  • hwittenborn/cli
  • rmetzler/gitlab-cli
  • eldelorotiktok/cli
  • wyphan/cli
  • jduhamel/cli
  • edsonmichaque/gitlab-cli
  • maxpushka/cli
  • hueki/cli
  • linalinn/cli
  • nikolay.kiselev/glabcli
  • dhxx/cli
  • waterkip/cli
  • maxwellpaulm/cli
  • judydivinna/cli
  • afzal442/glab-cli
  • pataar/cli
  • tcpack/cli
  • haata/cli
  • josephburnett/cli
  • jmpartisia/cli
  • armbiant/gitlab-asset-cli
  • jeanphi-baconnais/cli
  • ibado7/gitlab-cli
  • ted-regrello/gitlab-cli
  • spotlesstofu/cli
  • bondt/cli
  • alerque/cli
  • florian.heberl/cli
  • jgay/cli
  • beccis/cli
  • leonsonko97/cli
  • ashumkin/cli
  • heyymuhammad/cli
  • ForstPenguin/glab
  • blcksec/gl-cli
  • rndmh3ro/cli
  • phil-blain/glab
  • lemaillet.efflam/cli
  • tahori/cli
  • siemens/cli
  • tylerrosnett/cli
  • hera91/glab
  • corbob/cli
  • msvechla/cli
  • garyh/cli
  • austyp/cli
  • christoper.hans/cli
  • ragingpastry/cli
  • jbeirer/cli
  • stratosgear/cli
  • jeroenhuinink/glab-cli
  • mikelolasagasti/glab-cli
  • lidashuang/cli
  • Daimyo-jun/cli
  • alluse/cli
  • madflow/cli
  • biubiu7/cli
  • dhollinger/cli
  • bcdady/cli
  • il1yaz/glab
  • armbiant/gitlab-cli
  • bryant.finney/cli
  • lousyd/cli
  • wiilink/cli
  • michael-mead/cli
  • alecthegeek/glab
  • An0rak/glab-cli
  • wk.chuckwu/cli
  • gitlab-community/cli
  • awsomesawce/cli
  • luisserra/cli
  • ramin.a/cli-ramin-contribute
  • wallyqs1/cli
  • Serializator/gitlab-cli
  • thameezbo/cli
  • Grommingen/cli-gitlab-contribution
  • jima.man.pfc/cli
  • heilerich/gitlab-cli
  • mwilmer/gitlab-cli
  • dg.mg.gisor/cli
  • babastienne/cli
  • saintdle/cli
  • jahway603/gitlab-cli
  • gitlab-renovate-forks/cli
  • dougmcbride/gitlab-cli
  • phikai/code-intel-test-cli
  • jtojnar/cli
  • benedek.thaler/cli
  • zeb0x01/cli
  • jay_mccure/cli-2
  • aphikaia/cli
  • vasfed/glab-cli
  • abitrolly/cli
  • joshua.beard/gitlab-cli
  • andostronaut/cli
  • ayuryshev/cli
  • wernerhp/cli
  • treuherz/cli
  • AjjuSingh/cli
  • alxndr13/cli
  • andreascian/cli
  • hlidotbe/cli
  • edith007/cli
  • realtime-neil/glab-cli
  • jamietanna/cli
  • cn00/glab-cli
  • csouthard/cli
  • junderhill/cli
  • francis.belanger/cli
  • alien3dsatnetwork/cli
  • brendalf/cli
  • ole.reifschneider/glab
  • lromeraj/cli
  • guido.pili/cli
  • AnWeber/cli
  • MaKaNu/cli
  • mareo/cli
  • BainVillan2023/cli
  • mdietzer-fn/cli
  • tariqshaqe.103/cli
  • emersion/gitlab-cli
  • atarax665/cli
  • Daniel1854/cli
  • asciich/cli
  • rsa-sha/cli
  • bradymitch/cli
  • Andrew15-5/cli
  • yakkomajuri/cli
  • PopeNobody/cli
  • justenstall/cli
  • kiran-4444/cli
  • dansdragon01/cli
  • gitlab-com/create-stage/cli-ai-tester
  • derekbarbosa/cli
  • denysvitali_niantic/cli
  • amiehancock1979/cli
  • mvanholsteijn/cli
  • farodin91/cli
  • thcipriani/cli
  • anthraxx/cli
  • 976266892972/cli
  • markkrj/cli
  • AkashRajpurohit/cli
  • isaac.automata/cli
  • jcarnes/cli
  • eth0eth0/cli
  • gloven64/cli
  • nuzzzen/cli
  • jwillebrands/cli
  • tzfx1/cli
  • dhelfand/cli
  • denizgenc/cli
  • icbd/cli
  • Treav/cli
  • prasant94/cli
  • MashyBasker/cli
  • rajp152k/cli
  • datengraben/cli
  • andersparslov/cli
  • feomatr/cli
  • iipolovinkin/cli
  • recko0o/cli
  • nickaldwin/cli
  • titusenterprisesmd/cli
  • sailesh.nitt7/cli
  • Mouffle/cli
  • wingred96399/gitlab-cli-fork
  • braineo/cli
  • patrickbajao/cli
  • MarcUllman/cli
  • nandan.herekar/cli
  • adrianamusic.software/cli
  • ollevche/cli
  • khulnasoft-org/cli
  • naiithink/gitlab-cli
  • abitrolly/glab-cli
  • Vulwsztyn/cli
  • michaelmejaeger/cli
193 results
Show changes
Commits on Source (41)
Showing
with 1590 additions and 31 deletions
......@@ -12,6 +12,10 @@ workflow:
default:
image: golang:${GO_VERSION}
tags:
# NOTE: largest linux-based hosted runner available in Free tier.
# see https://docs.gitlab.com/ee/ci/runners/hosted_runners/linux.html#machine-types-available-for-linux---x86-64
- 'saas-linux-medium-amd64'
stages:
- documentation
......@@ -66,7 +70,7 @@ check_docs_update:
fi
check_docs_markdown:
image: registry.gitlab.com/gitlab-org/gitlab-docs/lint-markdown:alpine-3.20-vale-3.7.1-markdownlint2-0.13.0-lychee-0.15.1
image: registry.gitlab.com/gitlab-org/gitlab-docs/lint-markdown:alpine-3.20-vale-3.7.1-markdownlint2-0.14.0-lychee-0.15.1
extends: .documentation
script:
# Lint prose
......@@ -146,9 +150,14 @@ release_test:
rules:
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
when: never
- changes:
- .goreleaser.yml
script: |
docker run --rm --privileged \
-v $PWD:/go/src/gitlab.com/gitlab-org/cli \
-e GO_VERSION="$GO_VERSION" \
-w /go/src/gitlab.com/gitlab-org/cli \
-v /var/run/docker.sock:/var/run/docker.sock \
goreleaser/goreleaser release --snapshot
......@@ -157,11 +166,17 @@ release:
extends: .release
rules:
- if: $CI_COMMIT_TAG
tags:
# NOTE: we really benefit from the capacities of this 2xlarge runner.
# we only want to build from the canonical project so we don't really
# need to bother about this being compatible with forks.
- 'saas-linux-2xlarge-amd64'
script: |
docker run --rm --privileged \
-v $PWD:/go/src/gitlab.com/gitlab-org/cli \
-w /go/src/gitlab.com/gitlab-org/cli \
-v /var/run/docker.sock:/var/run/docker.sock \
-e GO_VERSION="$GO_VERSION" \
-e GITLAB_TOKEN=$GITLAB_TOKEN_RELEASE \
--entrypoint "" \
goreleaser/goreleaser \
......@@ -193,6 +208,8 @@ windows_installer:
- if: $CI_COMMIT_TAG
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- changes:
- .goreleaser.yml
script:
- mv scripts/setup_windows.iss .
- iscc "setup_windows.iss" -DVersion=${CI_COMMIT_TAG//v}
......@@ -205,11 +222,14 @@ windows_installer:
build_windows:
stage: release
needs: []
extends: .go-cache
rules:
- if: $CI_COMMIT_TAG
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- changes:
- .goreleaser.yml
script:
- GOOS=windows GOARCH=amd64 make build
- mv bin/glab bin/glab.exe
......
## Description
<!--- Describe your changes in detail -->
%{all_commits}
## Related Issues
<!--- This project only accepts merge requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
......
......@@ -18,7 +18,7 @@ builds:
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X main.version=v{{.Version}} -X main.build={{time "2006-01-02"}}
- -s -w -X main.version=v{{.Version}} -X main.build={{time "2006-01-02"}} -X "main.goversion={{.Env.GO_VERSION}}"
id: macos
goos: [darwin]
goarch: [amd64, arm64]
......@@ -85,37 +85,16 @@ docker_manifests:
archives:
- id: nix
builds: [macos, linux]
<<: &archive_defaults
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- if eq .Os "darwin" }}macOS
{{- else if eq .Os "linux" }}Linux
{{- else if eq .Os "windows" }}Windows{{ end }}_
{{- if eq .Arch "386" }}i386
{{- else if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "arm" }}arm
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
wrap_in_directory: false
wrap_in_directory: false
format: tar.gz
- id: windows
builds: [windows]
<<: *archive_defaults
format: zip
nfpms:
- id: foo
package_name: glab
file_name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- if eq .Os "darwin" }}macOS
{{- else if eq .Os "linux" }}Linux{{ end }}_
{{- if eq .Arch "386" }}i386
{{- else if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
# Build IDs for the builds you want to create NFPM packages for.
# Defaults to all builds.
......
......@@ -8,8 +8,6 @@ config:
code-block-style: # MD046
style: "fenced"
emphasis-style: false # MD049
first-header-h1: true # MD002
first-line-h1: false # MD041
header-style: # MD003
style: "atx"
hr-style: # MD035
......
golang 1.23.1
# For linting documentation
markdownlint-cli2 0.14.0
vale 3.7.1
<!-- markdownlint-disable -->
This [documentation](https://about.gitlab.com/community/contribute/code-of-conduct/) has been moved.
......@@ -46,6 +46,18 @@ If you are a GitLab team member that is interested in becoming a maintainer of t
## Getting Started
### Reporting Issues
Create a [new issue from the "Default" template](https://gitlab.com/gitlab-org/cli/-/issues/new?issuable_template=Default) and follow the instructions in the template.
### Your First Code Contribution?
Read about the contribution process in [`development_process.md`](docs/development_process.md). This document explains how we review and release changes.
If your merge request is trivial (fixing typos, fixing a bug with 20 lines of code), create a merge request.
If your merge request is large, create an issue first. See [Reporting Issues](#reporting-issues) and [Proposing Features](#proposing-features). In the issue, the project maintainers can help you scope the work and make you more efficient.
### Building the project
Prerequisites:
......@@ -72,6 +84,10 @@ Integration tests use the `_Integration` test suffix and use the `_integration_t
[personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) and requires the `api` scope.
To ensure the `glab duo` feature is functioning correctly, the token's user must have a GitLab Duo seat.
### Proposing Features
Create a [new issue from the "Feature Request" template](https://gitlab.com/gitlab-org/cli/-/issues/new?issuable_template=Feature%20Request) and follow the instructions in the template.
### Designing a new feature
The
......
package api
import "github.com/xanzy/go-gitlab"
import (
"errors"
"fmt"
"slices"
"time"
"github.com/xanzy/go-gitlab"
)
// agentTokenLimit specifies the maximal amount of agent tokens that can be active per agent at any given time.
const agentTokenLimit = 2
var AgentNotFoundErr = errors.New("agent not found")
var ListAgents = func(client *gitlab.Client, projectID interface{}, opts *gitlab.ListAgentsOptions) ([]*gitlab.Agent, error) {
if client == nil {
......@@ -27,3 +39,78 @@ var GetAgent = func(client *gitlab.Client, projectID interface{}, agentID int) (
return agent, nil
}
var GetAgentByName = func(client *gitlab.Client, projectID interface{}, agentName string) (*gitlab.Agent, error) {
opts := &gitlab.ListAgentsOptions{
Page: 1,
PerPage: 100,
}
for opts.Page != 0 {
paginatedAgents, resp, err := client.ClusterAgents.ListAgents(projectID, opts)
if err != nil {
return nil, err
}
for _, agent := range paginatedAgents {
if agent.Name == agentName {
// found
return agent, nil
}
}
opts.Page = resp.NextPage
}
return nil, AgentNotFoundErr
}
var RegisterAgent = func(client *gitlab.Client, projectID interface{}, agentName string) (*gitlab.Agent, error) {
if client == nil {
client = apiClient.Lab()
}
agent, _, err := client.ClusterAgents.RegisterAgent(projectID, &gitlab.RegisterAgentOptions{Name: gitlab.Ptr(agentName)})
if err != nil {
return nil, err
}
return agent, nil
}
var CreateAgentToken = func(client *gitlab.Client, projectID interface{}, agentID int, recreateOnLimit bool) (*gitlab.AgentToken, bool /* recreated */, error) {
recreated := false
if recreateOnLimit {
tokens, _, err := client.ClusterAgents.ListAgentTokens(projectID, agentID, &gitlab.ListAgentTokensOptions{PerPage: agentTokenLimit})
if err != nil {
return nil, false, err
}
if len(tokens) == agentTokenLimit {
slices.SortFunc(tokens, agentTokenSortFunc)
longestUnusedToken := tokens[0]
_, err := client.ClusterAgents.RevokeAgentToken(projectID, agentID, longestUnusedToken.ID)
if err != nil {
return nil, false, err
}
recreated = true
}
}
// create new token
token, _, err := client.ClusterAgents.CreateAgentToken(projectID, agentID, &gitlab.CreateAgentTokenOptions{
Name: gitlab.Ptr(fmt.Sprintf("glab-bootstrap-%d", time.Now().UTC().Unix())),
Description: gitlab.Ptr("Created by the `glab cluster agent bootstrap command"),
})
return token, recreated, err
}
func agentTokenSortFunc(a, b *gitlab.AgentToken) int {
if a.LastUsedAt == nil {
return 1
}
if b.LastUsedAt == nil {
return -1
}
return a.LastUsedAt.Compare(*b.LastUsedAt)
}
......@@ -21,7 +21,7 @@ var DeleteProject = func(client *gitlab.Client, projectID interface{}) (*gitlab.
if client == nil {
client = apiClient.Lab()
}
project, err := client.Projects.DeleteProject(projectID)
project, err := client.Projects.DeleteProject(projectID, nil)
if err != nil {
return nil, err
}
......
package api
import "github.com/xanzy/go-gitlab"
import (
"fmt"
"net/http"
"github.com/xanzy/go-gitlab"
)
const (
commitAuthorName = "glab"
commitAuthorEmail = "noreply@glab.gitlab.com"
)
// GetFile retrieves a file from repository. Note that file content is Base64 encoded.
var GetFile = func(client *gitlab.Client, projectID interface{}, path string, ref string) (*gitlab.File, error) {
......@@ -18,3 +28,35 @@ var GetFile = func(client *gitlab.Client, projectID interface{}, path string, re
return file, nil
}
// SyncFile syncs (add or update) a file in the repository
var SyncFile = func(client *gitlab.Client, projectID interface{}, path string, content []byte, ref string) error {
_, resp, err := client.RepositoryFiles.GetFileMetaData(projectID, path, &gitlab.GetFileMetaDataOptions{
Ref: gitlab.Ptr(ref),
})
if err != nil {
if resp.StatusCode != http.StatusNotFound {
return err
}
// file does not exist yet, lets create it
_, _, err := client.RepositoryFiles.CreateFile(projectID, path, &gitlab.CreateFileOptions{
Branch: gitlab.Ptr(ref),
Content: gitlab.Ptr(string(content)),
CommitMessage: gitlab.Ptr(fmt.Sprintf("Add %s via glab file sync", path)),
AuthorName: gitlab.Ptr(commitAuthorName),
AuthorEmail: gitlab.Ptr(commitAuthorEmail),
})
return err
}
// file already exists, lets update it
_, _, err = client.RepositoryFiles.UpdateFile(projectID, path, &gitlab.UpdateFileOptions{
Branch: gitlab.Ptr(ref),
Content: gitlab.Ptr(string(content)),
CommitMessage: gitlab.Ptr(fmt.Sprintf("Update %s via glab file sync", path)),
AuthorName: gitlab.Ptr(commitAuthorName),
AuthorEmail: gitlab.Ptr(commitAuthorEmail),
})
return err
}
......@@ -102,3 +102,27 @@ var CreatePersonalAccessToken = func(client *gitlab.Client, uid int, opts *gitla
token, _, err := client.Users.CreatePersonalAccessToken(uid, opts)
return token, err
}
var RevokeProjectAccessToken = func(client *gitlab.Client, pid interface{}, id int) error {
if client == nil {
client = apiClient.Lab()
}
_, err := client.ProjectAccessTokens.RevokeProjectAccessToken(pid, id)
return err
}
var RevokeGroupAccessToken = func(client *gitlab.Client, gid interface{}, id int) error {
if client == nil {
client = apiClient.Lab()
}
_, err := client.GroupAccessTokens.RevokeGroupAccessToken(gid, id)
return err
}
var RevokePersonalAccessToken = func(client *gitlab.Client, id int) error {
if client == nil {
client = apiClient.Lab()
}
_, err := client.PersonalAccessTokens.RevokePersonalAccessToken(id)
return err
}
{"id":452959326,"iid":14,"project_id":29316529,"status":"success","source":"push","ref":"1-fake-issue-3","name":"","sha":"44eb489568f7cb1a5a730fce6b247cd3797172ca","before_sha":"001eb421e586a3f07f90aea102c8b2d4068ab5b6","tag":false,"yaml_errors":"","user":{"id":8814129,"username":"OWNER","name":"Some User","state":"active","created_at":null,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/8814129/avatar.png","web_url":"https://gitlab.com/OWNER"},"updated_at":"2022-01-20T21:47:31.358Z","created_at":"2022-01-20T21:47:16.276Z","started_at":"2022-01-20T21:47:17.448Z","finished_at":"2022-01-20T21:47:31.35Z","committed_at":null,"duration":14,"queued_duration":1,"coverage":"","web_url":"https://gitlab.com/OWNER/REPO/-/pipelines/452959326","detailed_status":{"icon":"status_success","text":"Passed","label":"passed","group":"success","tooltip":"passed","has_details":true,"details_path":"/OWNER/REPO/-/pipelines/452959326","illustration":{"image":""},"favicon":"/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"},"jobs":[{"commit":{"id":"44eb489568f7cb1a5a730fce6b247cd3797172ca","short_id":"44eb4895","title":"Add new file","author_name":"Some User","author_email":"OWNER@gitlab.com","authored_date":"2022-01-20T21:47:15Z","committer_name":"Some User","committer_email":"OWNER@gitlab.com","committed_date":"2022-01-20T21:47:15Z","created_at":"2022-01-20T21:47:15Z","message":"Add new file","parent_ids":["001eb421e586a3f07f90aea102c8b2d4068ab5b6"],"stats":null,"status":null,"last_pipeline":null,"project_id":0,"trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/OWNER/REPO/-/commit/44eb489568f7cb1a5a730fce6b247cd3797172ca"},"coverage":0,"allow_failure":false,"created_at":"2022-01-20T21:47:16.291Z","started_at":"2022-01-20T21:47:16.693Z","finished_at":"2022-01-20T21:47:31.274Z","erased_at":null,"duration":14.580467,"queued_duration":0.211715,"artifacts_expire_at":null,"tag_list":[],"id":1999017704,"name":"test_vars","pipeline":{"id":452959326,"project_id":29316529,"ref":"1-fake-issue-3","sha":"44eb489568f7cb1a5a730fce6b247cd3797172ca","status":"success"},"ref":"1-fake-issue-3","artifacts":[{"file_type":"trace","filename":"job.log","size":2770,"file_format":""}],"artifacts_file":{"filename":"","size":0},"runner":{"id":12270859,"description":"5-green.saas-linux-small-amd64.runners-manager.gitlab.com/default","active":true,"is_shared":true,"name":"gitlab-runner"},"stage":"test","status":"success","failure_reason":"","tag":false,"web_url":"https://gitlab.com/OWNER/REPO/-/jobs/1999017704","project":{"id":0,"description":"","default_branch":"","visibility":"","ssh_url_to_repo":"","http_url_to_repo":"","web_url":"","readme_url":"","tag_list":null,"topics":null,"owner":null,"name":"","name_with_namespace":"","path":"","path_with_namespace":"","issues_enabled":false,"open_issues_count":0,"merge_requests_enabled":false,"approvals_before_merge":0,"jobs_enabled":false,"wiki_enabled":false,"snippets_enabled":false,"resolve_outdated_diff_discussions":false,"container_registry_enabled":false,"container_registry_access_level":"","creator_id":0,"namespace":null,"permissions":null,"marked_for_deletion_at":null,"empty_repo":false,"archived":false,"avatar_url":"","license_url":"","license":null,"shared_runners_enabled":false,"group_runners_enabled":false,"runner_token_expiration_interval":0,"forks_count":0,"star_count":0,"runners_token":"","allow_merge_on_skipped_pipeline":false,"only_allow_merge_if_pipeline_succeeds":false,"only_allow_merge_if_all_discussions_are_resolved":false,"remove_source_branch_after_merge":false,"prevent_merge_without_jira_issue":false,"printing_merge_request_link_enabled":false,"lfs_enabled":false,"repository_storage":"","request_access_enabled":false,"merge_method":"","can_create_merge_request_in":false,"forked_from_project":null,"mirror":false,"mirror_user_id":0,"mirror_trigger_builds":false,"only_mirror_protected_branches":false,"mirror_overwrites_diverged_branches":false,"packages_enabled":false,"service_desk_enabled":false,"service_desk_address":"","issues_access_level":"","repository_access_level":"","merge_requests_access_level":"","forking_access_level":"","wiki_access_level":"","builds_access_level":"","snippets_access_level":"","pages_access_level":"","operations_access_level":"","analytics_access_level":"","environments_access_level":"","feature_flags_access_level":"","infrastructure_access_level":"","monitor_access_level":"","autoclose_referenced_issues":false,"suggestion_commit_message":"","squash_option":"","shared_with_groups":null,"statistics":null,"import_url":"","import_type":"","import_status":"","import_error":"","ci_default_git_depth":0,"ci_forward_deployment_enabled":false,"ci_forward_deployment_rollback_allowed":false,"ci_separated_caches":false,"ci_job_token_scope_enabled":false,"ci_opt_in_jwt":false,"ci_allow_fork_pipelines_to_run_in_parent_project":false,"ci_restrict_pipeline_cancellation_role":"","public_jobs":false,"build_timeout":0,"auto_cancel_pending_pipelines":"","ci_config_path":"","custom_attributes":null,"compliance_frameworks":null,"build_coverage_regex":"","issues_template":"","merge_requests_template":"","issue_branch_template":"","keep_latest_artifact":false,"merge_pipelines_enabled":false,"merge_trains_enabled":false,"restrict_user_defined_variables":false,"merge_commit_template":"","squash_commit_template":"","auto_devops_deploy_strategy":"","auto_devops_enabled":false,"build_git_strategy":"","emails_enabled":false,"external_authorization_classification_label":"","requirements_enabled":false,"requirements_access_level":"","security_and_compliance_enabled":false,"security_and_compliance_access_level":"","mr_default_target_self":false,"model_experiments_access_level":"","model_registry_access_level":"","pre_receive_secret_detection_enabled":false,"emails_disabled":false,"public_builds":false},"user":{"id":8814129,"username":"OWNER","email":"","name":"Some User","state":"active","web_url":"https://gitlab.com/OWNER","created_at":"2021-05-03T14:58:50.059Z","bio":"","bot":false,"location":"Canada","public_email":"","skype":"","linkedin":"","twitter":"","website_url":"","organization":"GitLab","job_title":"Sr Backend Engineer","extern_uid":"","provider":"","theme_id":0,"last_activity_on":null,"color_scheme_id":0,"is_admin":false,"is_auditor":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/8814129/avatar.png","can_create_group":false,"can_create_project":false,"projects_limit":0,"current_sign_in_at":null,"current_sign_in_ip":null,"last_sign_in_at":null,"last_sign_in_ip":null,"confirmed_at":null,"two_factor_enabled":false,"note":"","identities":null,"external":false,"private_profile":false,"shared_runners_minutes_limit":0,"extra_shared_runners_minutes_limit":0,"using_license_seat":false,"custom_attributes":null,"namespace_id":0,"locked":false}}],"variables":null}
{"id":452959326,"iid":14,"project_id":29316529,"status":"success","source":"push","ref":"1-fake-issue-3","name":"","sha":"44eb489568f7cb1a5a730fce6b247cd3797172ca","before_sha":"001eb421e586a3f07f90aea102c8b2d4068ab5b6","tag":false,"yaml_errors":"","user":{"id":8814129,"username":"OWNER","name":"Some User","state":"active","created_at":null,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/8814129/avatar.png","web_url":"https://gitlab.com/OWNER"},"updated_at":"2022-01-20T21:47:31.358Z","created_at":"2022-01-20T21:47:16.276Z","started_at":"2022-01-20T21:47:17.448Z","finished_at":"2022-01-20T21:47:31.35Z","committed_at":null,"duration":14,"queued_duration":1,"coverage":"","web_url":"https://gitlab.com/OWNER/REPO/-/pipelines/452959326","detailed_status":{"icon":"status_success","text":"Passed","label":"passed","group":"success","tooltip":"passed","has_details":true,"details_path":"/OWNER/REPO/-/pipelines/452959326","illustration":{"image":""},"favicon":"/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"},"jobs":[{"commit":{"id":"44eb489568f7cb1a5a730fce6b247cd3797172ca","short_id":"44eb4895","title":"Add new file","author_name":"Some User","author_email":"OWNER@gitlab.com","authored_date":"2022-01-20T21:47:15Z","committer_name":"Some User","committer_email":"OWNER@gitlab.com","committed_date":"2022-01-20T21:47:15Z","created_at":"2022-01-20T21:47:15Z","message":"Add new file","parent_ids":["001eb421e586a3f07f90aea102c8b2d4068ab5b6"],"stats":null,"status":null,"last_pipeline":null,"project_id":0,"trailers":{},"extended_trailers":{},"web_url":"https://gitlab.com/OWNER/REPO/-/commit/44eb489568f7cb1a5a730fce6b247cd3797172ca"},"coverage":0,"allow_failure":false,"created_at":"2022-01-20T21:47:16.291Z","started_at":"2022-01-20T21:47:16.693Z","finished_at":"2022-01-20T21:47:31.274Z","erased_at":null,"duration":14.580467,"queued_duration":0.211715,"artifacts_expire_at":null,"tag_list":[],"id":1999017704,"name":"test_vars","pipeline":{"id":452959326,"project_id":29316529,"ref":"1-fake-issue-3","sha":"44eb489568f7cb1a5a730fce6b247cd3797172ca","status":"success"},"ref":"1-fake-issue-3","artifacts":[{"file_type":"trace","filename":"job.log","size":2770,"file_format":""}],"artifacts_file":{"filename":"","size":0},"runner":{"id":12270859,"description":"5-green.saas-linux-small-amd64.runners-manager.gitlab.com/default","active":true,"is_shared":true,"name":"gitlab-runner"},"stage":"test","status":"success","failure_reason":"","tag":false,"web_url":"https://gitlab.com/OWNER/REPO/-/jobs/1999017704","project":{"id":0,"description":"","default_branch":"","visibility":"","ssh_url_to_repo":"","http_url_to_repo":"","web_url":"","readme_url":"","tag_list":null,"topics":null,"owner":null,"name":"","name_with_namespace":"","path":"","path_with_namespace":"","issues_enabled":false,"open_issues_count":0,"merge_requests_enabled":false,"approvals_before_merge":0,"jobs_enabled":false,"wiki_enabled":false,"snippets_enabled":false,"resolve_outdated_diff_discussions":false,"container_registry_enabled":false,"container_registry_access_level":"","creator_id":0,"namespace":null,"permissions":null,"marked_for_deletion_at":null,"empty_repo":false,"archived":false,"avatar_url":"","license_url":"","license":null,"shared_runners_enabled":false,"group_runners_enabled":false,"runner_token_expiration_interval":0,"forks_count":0,"star_count":0,"runners_token":"","allow_merge_on_skipped_pipeline":false,"allow_pipeline_trigger_approve_deployment":false,"only_allow_merge_if_pipeline_succeeds":false,"only_allow_merge_if_all_discussions_are_resolved":false,"remove_source_branch_after_merge":false,"prevent_merge_without_jira_issue":false,"printing_merge_request_link_enabled":false,"lfs_enabled":false,"repository_storage":"","request_access_enabled":false,"merge_method":"","can_create_merge_request_in":false,"forked_from_project":null,"mirror":false,"mirror_user_id":0,"mirror_trigger_builds":false,"only_mirror_protected_branches":false,"mirror_overwrites_diverged_branches":false,"packages_enabled":false,"service_desk_enabled":false,"service_desk_address":"","issues_access_level":"","repository_access_level":"","merge_requests_access_level":"","forking_access_level":"","wiki_access_level":"","builds_access_level":"","snippets_access_level":"","pages_access_level":"","operations_access_level":"","analytics_access_level":"","environments_access_level":"","feature_flags_access_level":"","infrastructure_access_level":"","monitor_access_level":"","autoclose_referenced_issues":false,"suggestion_commit_message":"","squash_option":"","shared_with_groups":null,"statistics":null,"import_url":"","import_type":"","import_status":"","import_error":"","ci_default_git_depth":0,"ci_forward_deployment_enabled":false,"ci_forward_deployment_rollback_allowed":false,"ci_separated_caches":false,"ci_job_token_scope_enabled":false,"ci_opt_in_jwt":false,"ci_allow_fork_pipelines_to_run_in_parent_project":false,"ci_restrict_pipeline_cancellation_role":"","public_jobs":false,"build_timeout":0,"auto_cancel_pending_pipelines":"","ci_config_path":"","custom_attributes":null,"compliance_frameworks":null,"build_coverage_regex":"","issues_template":"","merge_requests_template":"","issue_branch_template":"","keep_latest_artifact":false,"merge_pipelines_enabled":false,"merge_trains_enabled":false,"restrict_user_defined_variables":false,"merge_commit_template":"","squash_commit_template":"","auto_devops_deploy_strategy":"","auto_devops_enabled":false,"build_git_strategy":"","emails_enabled":false,"external_authorization_classification_label":"","requirements_enabled":false,"requirements_access_level":"","security_and_compliance_enabled":false,"security_and_compliance_access_level":"","mr_default_target_self":false,"model_experiments_access_level":"","model_registry_access_level":"","pre_receive_secret_detection_enabled":false,"emails_disabled":false,"public_builds":false},"user":{"id":8814129,"username":"OWNER","email":"","name":"Some User","state":"active","web_url":"https://gitlab.com/OWNER","created_at":"2021-05-03T14:58:50.059Z","bio":"","bot":false,"location":"Canada","public_email":"","skype":"","linkedin":"","twitter":"","website_url":"","organization":"GitLab","job_title":"Sr Backend Engineer","extern_uid":"","provider":"","theme_id":0,"last_activity_on":null,"color_scheme_id":0,"is_admin":false,"is_auditor":false,"avatar_url":"https://gitlab.com/uploads/-/system/user/avatar/8814129/avatar.png","can_create_group":false,"can_create_project":false,"projects_limit":0,"current_sign_in_at":null,"current_sign_in_ip":null,"last_sign_in_at":null,"last_sign_in_ip":null,"confirmed_at":null,"two_factor_enabled":false,"note":"","identities":null,"external":false,"private_profile":false,"shared_runners_minutes_limit":0,"extra_shared_runners_minutes_limit":0,"using_license_seat":false,"custom_attributes":null,"namespace_id":0,"locked":false}}],"variables":null}
......@@ -2,6 +2,7 @@ package cluster
import (
"github.com/spf13/cobra"
agentBootstrapCmd "gitlab.com/gitlab-org/cli/commands/cluster/agent/bootstrap"
checkManifestUsageCmd "gitlab.com/gitlab-org/cli/commands/cluster/agent/check_manifest_usage"
agentGetTokenCmd "gitlab.com/gitlab-org/cli/commands/cluster/agent/get_token"
agentListCmd "gitlab.com/gitlab-org/cli/commands/cluster/agent/list"
......@@ -23,5 +24,14 @@ func NewCmdAgent(f *cmdutils.Factory) *cobra.Command {
agentCmd.AddCommand(agentUpdateKubeconfigCmd.NewCmdAgentUpdateKubeconfig(f))
agentCmd.AddCommand(checkManifestUsageCmd.NewCmdCheckManifestUsage(f))
agentCmd.AddCommand(agentBootstrapCmd.NewCmdAgentBootstrap(
f,
agentBootstrapCmd.EnsureRequirements,
agentBootstrapCmd.NewAPI,
agentBootstrapCmd.NewLocalKubectlWrapper,
agentBootstrapCmd.NewLocalFluxWrapper,
agentBootstrapCmd.NewCmd,
))
return agentCmd
}
......@@ -5,7 +5,10 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/xanzy/go-gitlab"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"gitlab.com/gitlab-org/cli/test"
)
......@@ -14,7 +17,13 @@ func TestNewCmdAgent(t *testing.T) {
r, w, _ := os.Pipe()
os.Stdout = w
assert.Nil(t, NewCmdAgent(&cmdutils.Factory{}).Execute())
assert.Nil(t, NewCmdAgent(&cmdutils.Factory{
IO: &iostreams.IOStreams{
StdOut: os.Stdout,
},
HttpClient: func() (*gitlab.Client, error) { return nil, nil },
BaseRepo: func() (glrepo.Interface, error) { return glrepo.New("OWNER", "REPO"), nil },
}).Execute())
out := test.ReturnBuffer(old, r, w)
......
package bootstrap
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
glab_api "gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"github.com/spf13/cobra"
"github.com/xanzy/go-gitlab"
)
//go:generate go run go.uber.org/mock/mockgen@v0.4.0 -typed -destination=./mocks_for_test.go -package=bootstrap gitlab.com/gitlab-org/cli/commands/cluster/agent/bootstrap API,FluxWrapper,KubectlWrapper,Cmd
//go:generate go run go.uber.org/mock/mockgen@v0.4.0 -typed -destination=./stdlib_mocks_for_test.go -package=bootstrap "io" "Writer"
type API interface {
GetDefaultBranch() (string, error)
GetAgentByName(name string) (*gitlab.Agent, error)
RegisterAgent(name string) (*gitlab.Agent, error)
CreateAgentToken(agentID int) (*gitlab.AgentToken, error)
SyncFile(f file, branch string) error
}
type FluxWrapper interface {
createHelmRepositoryManifest() (file, error)
createHelmReleaseManifest() (file, error)
reconcile() error
}
type KubectlWrapper interface {
createAgentTokenSecret(token string) error
}
type (
APIFactory func(*gitlab.Client, any) API
KubectlWrapperFactory func(Cmd, string, string, string) KubectlWrapper
FluxWrapperFactory func(Cmd, string, string, string, string, string, string, string, string, string, string, string, string) FluxWrapper
CmdFactory func(io.Writer, io.Writer, []string) Cmd
)
var reconcileErr = errors.New("failed to reconcile the GitLab Agent")
const (
kubectlBinaryName = "kubectl"
fluxBinaryName = "flux"
)
func EnsureRequirements() error {
if _, err := exec.LookPath(kubectlBinaryName); err != nil {
return fmt.Errorf("unable to find %s binary in PATH", kubectlBinaryName)
}
if _, err := exec.LookPath(fluxBinaryName); err != nil {
return fmt.Errorf("unable to find %s binary in PATH", fluxBinaryName)
}
return nil
}
func NewCmdAgentBootstrap(f *cmdutils.Factory, ensureRequirements func() error, af APIFactory, kwf KubectlWrapperFactory, fwf FluxWrapperFactory, cf CmdFactory) *cobra.Command {
agentBootstrapCmd := &cobra.Command{
Use: "bootstrap agent-name [flags]",
Short: `Bootstrap a GitLab Agent for Kubernetes in a project.`,
Long: `Bootstrap a GitLab Agent for Kubernetes (agentk) in a project.
The first argument must be the name of the agent.
It requires the kubectl and flux commands to be accessible via $PATH.
This command consists of multiple idempotent steps:
1. Register the agent with the project.
1. Create a token for the agent.
- If the agent has reached the maximum amount of tokens,
the one that has not been used the longest is revoked
and a new one is created.
- If the agent has not reached the maximum amount of tokens,
a new one is created.
1. Push the Kubernetes Secret that contains the token to the cluster.
1. Create Flux HelmRepository and HelmRelease resource.
1. Commit and Push the created Flux Helm resources to the manifest path.
1. Trigger Flux reconciliation of GitLab Agent HelmRelease.
`,
Example: `
# Bootstrap "my-agent" to root of Git project in CWD and trigger reconciliation
glab cluster agent bootstrap my-agent
# Bootstrap "my-agent" to "manifests/" of Git project in CWD and trigger reconciliation
glab cluster agent bootstrap my-agent --manifest-path manifests/
# Bootstrap "my-agent" to "manifests/" of Git project in CWD and do not manually trigger a reconilication
glab cluster agent bootstrap my-agent --manifest-path manifests/ --no-reconcile
`,
Aliases: []string{"bs"},
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
return ensureRequirements()
},
RunE: func(cmd *cobra.Command, args []string) error {
stdout, stderr := f.IO.StdOut, f.IO.StdErr
apiClient, err := f.HttpClient()
if err != nil {
return err
}
repo, err := f.BaseRepo()
if err != nil {
return err
}
api := af(apiClient, repo.FullName())
manifestPath, err := cmd.Flags().GetString("manifest-path")
if err != nil {
return err
}
manifestBranch, err := cmd.Flags().GetString("manifest-branch")
if err != nil {
return err
}
if manifestBranch == "" {
manifestBranch, err = api.GetDefaultBranch()
if err != nil {
return err
}
}
noReconcile, err := cmd.Flags().GetBool("no-reconcile")
if err != nil {
return err
}
helmRepositoryName, err := cmd.Flags().GetString("helm-repository-name")
if err != nil {
return err
}
helmRepositoryNamespace, err := cmd.Flags().GetString("helm-repository-namespace")
if err != nil {
return err
}
helmRepositoryFilepath, err := cmd.Flags().GetString("helm-repository-filepath")
if err != nil {
return err
}
helmReleaseName, err := cmd.Flags().GetString("helm-release-name")
if err != nil {
return err
}
helmReleaseNamespace, err := cmd.Flags().GetString("helm-release-namespace")
if err != nil {
return err
}
helmReleaseFilepath, err := cmd.Flags().GetString("helm-release-filepath")
if err != nil {
return err
}
helmReleaseTargetNamespace, err := cmd.Flags().GetString("helm-release-target-namespace")
if err != nil {
return err
}
gitlabAgentTokenSecretName, err := cmd.Flags().GetString("gitlab-agent-token-secret-name")
if err != nil {
return err
}
fluxSourceType, err := cmd.Flags().GetString("flux-source-type")
if err != nil {
return err
}
fluxSourceNamespace, err := cmd.Flags().GetString("flux-source-namespace")
if err != nil {
return err
}
fluxSourceName, err := cmd.Flags().GetString("flux-source-name")
if err != nil {
return err
}
c := cf(stdout, stderr, os.Environ())
return (&bootstrapCmd{
api: api,
stdout: stdout,
stderr: stderr,
agentName: args[0],
manifestBranch: manifestBranch,
kubectl: kwf(c, kubectlBinaryName, helmReleaseTargetNamespace, gitlabAgentTokenSecretName),
flux: fwf(
c, fluxBinaryName, manifestPath,
helmRepositoryName, helmRepositoryNamespace, helmRepositoryFilepath,
helmReleaseName, helmReleaseNamespace, helmReleaseFilepath, helmReleaseTargetNamespace,
fluxSourceType, fluxSourceNamespace, fluxSourceName,
),
noReconcile: noReconcile,
}).run()
},
}
agentBootstrapCmd.Flags().StringP("manifest-path", "p", "", "Location of directory in Git repository for storing the GitLab Agent for Kubernetes Helm resources.")
agentBootstrapCmd.Flags().StringP("manifest-branch", "b", "", "Branch to commit the Flux Manifests to. (default to the project default branch)")
agentBootstrapCmd.Flags().Bool("no-reconcile", false, "Do not trigger Flux reconciliation for GitLab Agent for Kubernetes Flux resource.")
agentBootstrapCmd.Flags().String("helm-repository-name", "gitlab", "Name of the Flux HelmRepository manifest.")
agentBootstrapCmd.Flags().String("helm-repository-namespace", "flux-system", "Namespace of the Flux HelmRepository manifest.")
agentBootstrapCmd.Flags().String("helm-repository-filepath", "gitlab-helm-repository.yaml", "Filepath within the GitLab Agent project to commit the Flux HelmRepository to.")
agentBootstrapCmd.Flags().String("helm-release-name", "gitlab-agent", "Name of the Flux HelmRelease manifest.")
agentBootstrapCmd.Flags().String("helm-release-namespace", "flux-system", "Namespace of the Flux HelmRelease manifest.")
agentBootstrapCmd.Flags().String("helm-release-filepath", "gitlab-agent-helm-release.yaml", "Filepath within the GitLab Agent project to commit the Flux HelmRelease to.")
agentBootstrapCmd.Flags().String("helm-release-target-namespace", "gitlab-agent", "Namespace of the GitLab Agent deployment.")
agentBootstrapCmd.Flags().String("gitlab-agent-token-secret-name", "gitlab-agent-token", "Name of the Secret where the token for the GitLab Agent is stored. The helm-release-target-namespace is implied for the namespace of the Secret.")
agentBootstrapCmd.Flags().String("flux-source-type", "git", "Source type of the flux-system, e.g. git, oci, helm, ...")
agentBootstrapCmd.Flags().String("flux-source-namespace", "flux-system", "Flux source namespace.")
agentBootstrapCmd.Flags().String("flux-source-name", "flux-system", "Flux source name.")
return agentBootstrapCmd
}
type bootstrapCmd struct {
api API
stdout io.Writer
stderr io.Writer
agentName string
manifestBranch string
kubectl KubectlWrapper
flux FluxWrapper
noReconcile bool
}
type file struct {
path string
content []byte
}
func (c *bootstrapCmd) run() error {
// 1. Register the agent
fmt.Fprintf(c.stderr, "Registering Agent ... ")
agent, err := c.registerAgent()
if err != nil {
return err
}
fmt.Fprintf(c.stderr, "[OK]\n")
// 2. Create a token for the registered agent
fmt.Fprintf(c.stderr, "Creating Agent Token ... ")
token, err := c.createAgentToken(agent)
if err != nil {
fmt.Fprintf(c.stderr, "[FAILED]\n")
return err
}
fmt.Fprintf(c.stderr, "[OK]\n")
// 3. Push token in Kubernetes secret to cluster
fmt.Fprintf(c.stderr, "Creating Kubernetes Secret with Agent Token ... ")
err = c.createAgentTokenKubernetesSecret(token)
if err != nil {
fmt.Fprintf(c.stderr, "[FAILED]\n")
return err
}
fmt.Fprintf(c.stderr, "[OK]\n")
// 4. Create Flux HelmRepository and HelmRelease resource.
fmt.Fprintf(c.stderr, "Creating Flux Helm Resources ... ")
helmResourceFiles, err := c.createFluxHelmResources()
if err != nil {
fmt.Fprintf(c.stderr, "[FAILED]\n")
return err
}
fmt.Fprintf(c.stderr, "[OK]\n")
// 5. Commit and Push the created Flux Helm resources to the manifest path.
fmt.Fprintf(c.stderr, "Syncing Flux Helm Resources ... ")
err = c.syncFluxHelmResourceFiles(helmResourceFiles)
if err != nil {
fmt.Fprintf(c.stderr, "[FAILED]\n")
return err
}
fmt.Fprintf(c.stderr, "[OK]\n")
if !c.noReconcile {
// 6. Trigger Flux reconciliation of GitLab Agent HelmRelease.
fmt.Fprintln(c.stderr, "Reconciling Flux Helm Resources ... Output from flux command:")
err = c.fluxReconcile()
if err != nil {
return reconcileErr
}
}
fmt.Fprintln(c.stderr, "Successfully bootstrapped the GitLab Agent")
return nil
}
func (c *bootstrapCmd) registerAgent() (*gitlab.Agent, error) {
agent, err := c.api.GetAgentByName(c.agentName)
if err != nil {
if !errors.Is(err, glab_api.AgentNotFoundErr) {
return nil, err
}
// register agent
agent, err = c.api.RegisterAgent(c.agentName)
if err != nil {
return nil, err
}
}
return agent, nil
}
func (c *bootstrapCmd) createAgentToken(agent *gitlab.Agent) (*gitlab.AgentToken, error) {
return c.api.CreateAgentToken(agent.ID)
}
func (c *bootstrapCmd) createAgentTokenKubernetesSecret(token *gitlab.AgentToken) error {
return c.kubectl.createAgentTokenSecret(token.Token)
}
func (c *bootstrapCmd) createFluxHelmResources() ([]file, error) {
helmRepository, err := c.flux.createHelmRepositoryManifest()
if err != nil {
return nil, err
}
helmRelease, err := c.flux.createHelmReleaseManifest()
if err != nil {
return nil, err
}
return []file{helmRepository, helmRelease}, nil
}
func (c *bootstrapCmd) syncFluxHelmResourceFiles(files []file) error {
for _, f := range files {
err := c.api.SyncFile(f, c.manifestBranch)
if err != nil {
return err
}
}
return nil
}
func (c *bootstrapCmd) fluxReconcile() error {
return c.flux.reconcile()
}
This diff is collapsed.
package bootstrap
import (
"github.com/xanzy/go-gitlab"
glab_api "gitlab.com/gitlab-org/cli/api"
)
var _ API = (*apiWrapper)(nil)
func NewAPI(client *gitlab.Client, projectID any) API {
return &apiWrapper{client: client, projectID: projectID}
}
type apiWrapper struct {
client *gitlab.Client
projectID any
}
func (a *apiWrapper) GetDefaultBranch() (string, error) {
project, err := glab_api.GetProject(a.client, a.projectID)
if err != nil {
return "", err
}
return project.DefaultBranch, nil
}
func (a *apiWrapper) GetAgentByName(name string) (*gitlab.Agent, error) {
return glab_api.GetAgentByName(a.client, a.projectID, name)
}
func (a *apiWrapper) RegisterAgent(name string) (*gitlab.Agent, error) {
return glab_api.RegisterAgent(a.client, a.projectID, name)
}
func (a *apiWrapper) CreateAgentToken(agentID int) (*gitlab.AgentToken, error) {
token, _, err := glab_api.CreateAgentToken(a.client, a.projectID, agentID, true)
return token, err
}
func (a *apiWrapper) SyncFile(f file, branch string) error {
return glab_api.SyncFile(a.client, a.projectID, f.path, f.content, branch)
}
package bootstrap
import (
"fmt"
"io"
"os/exec"
)
type Cmd interface {
RunWithOutput(name string, arg ...string) ([]byte, error)
Run(name string, arg ...string) error
}
type cmdWrapper struct {
stdout, stderr io.Writer
env []string
}
type errorWithOutput struct {
output []byte
err error
}
func (e errorWithOutput) Error() string {
return fmt.Sprintf("command failed with %q and output:\n%s", e.err, e.output)
}
func (e errorWithOutput) Unwrap() error {
return e.err
}
func NewCmd(stdout, stderr io.Writer, env []string) Cmd {
return &cmdWrapper{
stdout: stdout,
stderr: stderr,
env: env,
}
}
func (c *cmdWrapper) RunWithOutput(name string, arg ...string) ([]byte, error) {
command := exec.Command(name, arg...)
command.Env = c.env
output, err := command.CombinedOutput()
if err != nil {
return output, &errorWithOutput{output: output, err: err}
}
return output, nil
}
func (c *cmdWrapper) Run(name string, arg ...string) error {
command := exec.Command(name, arg...)
command.Stdout = c.stdout
command.Stderr = c.stderr
command.Env = c.env
return command.Run()
}
package bootstrap
import (
"bytes"
"errors"
"fmt"
"os"
"path"
"time"
"github.com/avast/retry-go/v4"
)
var _ FluxWrapper = (*localFluxWrapper)(nil)
func NewLocalFluxWrapper(
cmd Cmd,
binary string,
manifestPath string,
helmRepositoryName string,
helmRepositoryNamespace string,
helmRepositoryFilepath string,
helmReleaseName string,
helmReleaseNamespace string,
helmReleaseFilepath string,
helmReleaseTargetNamespace string,
fluxSourceType string,
fluxSourceNamespace string,
fluxSourceName string,
) FluxWrapper {
return &localFluxWrapper{
cmd: cmd,
binary: binary,
manifestPath: manifestPath,
helmRepositoryName: helmRepositoryName,
helmRepositoryNamespace: helmRepositoryNamespace,
helmRepositoryFilepath: helmRepositoryFilepath,
helmReleaseName: helmReleaseName,
helmReleaseNamespace: helmReleaseNamespace,
helmReleaseFilepath: helmReleaseFilepath,
helmReleaseTargetNamespace: helmReleaseTargetNamespace,
fluxSourceType: fluxSourceType,
fluxSourceNamespace: fluxSourceNamespace,
fluxSourceName: fluxSourceName,
reconcileRetryDelay: 10 * time.Second,
}
}
type localFluxWrapper struct {
cmd Cmd
binary string
manifestPath string
helmRepositoryName string
helmRepositoryNamespace string
helmRepositoryFilepath string
helmReleaseName string
helmReleaseNamespace string
helmReleaseFilepath string
helmReleaseTargetNamespace string
fluxSourceType string
fluxSourceNamespace string
fluxSourceName string
reconcileRetryDelay time.Duration
}
func (f *localFluxWrapper) createHelmRepositoryManifest() (file, error) {
helmRepositoryYAML, err := f.cmd.RunWithOutput(
f.binary,
"create",
"source",
"helm",
f.helmRepositoryName,
"--export",
fmt.Sprintf("-n=%s", f.helmRepositoryNamespace),
"--url=https://charts.gitlab.io",
)
if err != nil {
return file{}, err
}
return file{path: path.Join(f.manifestPath, f.helmRepositoryFilepath), content: helmRepositoryYAML}, nil
}
func (f *localFluxWrapper) createHelmReleaseManifest() (file, error) {
// create temporary file for Flux CLI to read values from.
// The Flux CLI does not yet support reading values from literal flags.
valuesFile, err := os.CreateTemp("", "glab-bootstrap-helmrelease-values")
if err != nil {
return file{}, err
}
defer os.Remove(valuesFile.Name())
defer valuesFile.Close()
_, err = valuesFile.Write([]byte(`config:
secretName: gitlab-agent-token
`))
if err != nil {
return file{}, err
}
if err = valuesFile.Sync(); err != nil {
return file{}, err
}
helmReleaseYAML, err := f.cmd.RunWithOutput(
f.binary,
"create",
"helmrelease",
f.helmReleaseName,
"--export",
fmt.Sprintf("-n=%s", f.helmReleaseNamespace),
fmt.Sprintf("--target-namespace=%s", f.helmReleaseTargetNamespace),
"--create-target-namespace=true",
fmt.Sprintf("--source=HelmRepository/%s.%s", f.helmRepositoryName, f.helmRepositoryNamespace),
"--chart=gitlab-agent",
fmt.Sprintf("--release-name=%s", f.helmReleaseName),
fmt.Sprintf("--values=%s", valuesFile.Name()),
)
if err != nil {
return file{}, err
}
return file{path: path.Join(f.manifestPath, f.helmReleaseFilepath), content: helmReleaseYAML}, nil
}
func (f *localFluxWrapper) reconcile() error {
// reconcile flux source to pull new HelmRepository source
err := f.cmd.Run(f.binary, "reconcile", "source", f.fluxSourceType, f.fluxSourceName, fmt.Sprintf("-n=%s", f.fluxSourceNamespace))
if err != nil {
return err
}
// just reconciling doesn't mean that the HelmRelease now exists ... (bug in flux? At least very unfortunate behavior)
err = retry.Do(func() error {
output, err := f.cmd.RunWithOutput(f.binary, "get", "helmreleases", f.helmReleaseName, fmt.Sprintf("-n=%s", f.helmReleaseNamespace))
if err != nil {
// flux always returns with exit code 0, even when the helmrelease does not exist (yet)
return retry.Unrecoverable(err)
}
if bytes.Contains(output, []byte(fmt.Sprintf(`HelmRelease object '%s' not found in "%s" namespace`, f.helmReleaseName, f.helmReleaseNamespace))) {
return errors.New(string(output))
}
return nil
}, retry.Attempts(6), retry.Delay(f.reconcileRetryDelay))
if err != nil {
return err
}
return f.cmd.Run(f.binary, "reconcile", "helmrelease", f.helmReleaseName, fmt.Sprintf("-n=%s", f.helmReleaseNamespace), "--with-source")
}
package bootstrap
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestFlux_createHelmRepositoryManifest(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
mockCmd.EXPECT().RunWithOutput(
"flux", "create", "source", "helm", "helm-repository-name", "--export",
"-n=helm-repository-namespace", "--url=https://charts.gitlab.io").
Return([]byte("content"), nil)
actualFile, err := f.createHelmRepositoryManifest()
// THEN
require.NoError(t, err)
assert.Equal(t, actualFile.path, "manifest-path/helm-repository-filepath")
assert.Equal(t, actualFile.content, []byte("content"))
}
func TestFlux_createHelmRepositoryManifest_Failure(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
mockCmd.EXPECT().RunWithOutput(
"flux", "create", "source", "helm", "helm-repository-name", "--export",
"-n=helm-repository-namespace", "--url=https://charts.gitlab.io").
Return(nil, errors.New("test"))
actualFile, err := f.createHelmRepositoryManifest()
// THEN
require.Error(t, err)
assert.Equal(t, actualFile, file{})
}
func TestFlux_createHelmReReleaseManifest(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
mockCmd.EXPECT().RunWithOutput(
"flux", "create", "helmrelease", "helm-release-name", "--export",
"-n=helm-release-namespace", "--target-namespace=helm-release-target-namespace",
"--create-target-namespace=true", "--source=HelmRepository/helm-repository-name.helm-repository-namespace",
"--chart=gitlab-agent", "--release-name=helm-release-name", StartsWith("--values=")).
Return([]byte("content"), nil)
actualFile, err := f.createHelmReleaseManifest()
// THEN
require.NoError(t, err)
assert.Equal(t, actualFile.path, "manifest-path/helm-release-filepath")
assert.Equal(t, actualFile.content, []byte("content"))
}
func TestFlux_createHelmReReleaseManifest_Failure(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
mockCmd.EXPECT().RunWithOutput(
"flux", "create", "helmrelease", "helm-release-name", "--export",
"-n=helm-release-namespace", "--target-namespace=helm-release-target-namespace",
"--create-target-namespace=true", "--source=HelmRepository/helm-repository-name.helm-repository-namespace",
"--chart=gitlab-agent", "--release-name=helm-release-name", StartsWith("--values=")).
Return([]byte(""), errors.New("test"))
actualFile, err := f.createHelmReleaseManifest()
// THEN
require.Error(t, err)
assert.Equal(t, actualFile, file{})
}
func TestFlux_reconcile(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
gomock.InOrder(
mockCmd.EXPECT().Run("flux", "reconcile", "source", "flux-source-type", "flux-source-name", "-n=flux-source-namespace"),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace"),
mockCmd.EXPECT().Run("flux", "reconcile", "helmrelease", "helm-release-name", "-n=helm-release-namespace", "--with-source"),
)
// WHEN
_ = f.reconcile()
}
func TestFlux_reconcile_retries(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
gomock.InOrder(
mockCmd.EXPECT().Run("flux", "reconcile", "source", "flux-source-type", "flux-source-name", "-n=flux-source-namespace"),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace"),
mockCmd.EXPECT().Run("flux", "reconcile", "helmrelease", "helm-release-name", "-n=helm-release-namespace", "--with-source"),
)
// WHEN
_ = f.reconcile()
}
func TestFlux_reconcile_abort_retry_max(t *testing.T) {
// GIVEN
mockCmd, f := setupFlux(t)
gomock.InOrder(
mockCmd.EXPECT().Run("flux", "reconcile", "source", "flux-source-type", "flux-source-name", "-n=flux-source-namespace"),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
mockCmd.EXPECT().RunWithOutput("flux", "get", "helmreleases", "helm-release-name", "-n=helm-release-namespace").Return([]byte(`HelmRelease object 'helm-release-name' not found in "helm-release-namespace" namespace`), nil),
)
// WHEN
err := f.reconcile()
assert.Error(t, err)
}
func setupFlux(t *testing.T) (*MockCmd, FluxWrapper) {
ctrl := gomock.NewController(t)
mockCmd := NewMockCmd(ctrl)
f := NewLocalFluxWrapper(
mockCmd,
"flux", "manifest-path",
"helm-repository-name", "helm-repository-namespace", "helm-repository-filepath",
"helm-release-name", "helm-release-namespace", "helm-release-filepath", "helm-release-target-namespace",
"flux-source-type", "flux-source-namespace", "flux-source-name",
)
fHack := f.(*localFluxWrapper)
fHack.reconcileRetryDelay = 0
return mockCmd, f
}
func StartsWith(prefix string) gomock.Matcher {
return &startsWithMatcher{prefix: prefix}
}
type startsWithMatcher struct {
prefix string
actualS string
}
func (m startsWithMatcher) Matches(arg interface{}) bool {
m.actualS = arg.(string)
return strings.HasPrefix(m.actualS, m.prefix)
}
func (m startsWithMatcher) String() string {
return fmt.Sprintf("does not start with: %q, got %q", m.prefix, m.actualS)
}