diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yaml similarity index 51% rename from .github/workflows/golangci-lint.yml rename to .github/workflows/golangci-lint.yaml index 17c3c823..c985be6f 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yaml @@ -1,4 +1,4 @@ -name: golangci-lint +name: golangci on: pull_request: branches: @@ -8,8 +8,7 @@ permissions: contents: read jobs: - golangci-lint: - name: golangci-lint + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 @@ -17,7 +16,17 @@ jobs: with: go-version-file: 'go.mod' cache: true - - name: golangci-lint - uses: golangci/golangci-lint-action@3cfe3a4abbb849e10058ce4af15d205b6da42804 # v4.0.0 + - name: Run tidy + run: make tidy + - name: Check if working tree is dirty + run: | + if [[ $(git diff --stat) != '' ]]; then + git diff + echo 'run make tidy and commit changes' + exit 1 + fi + - uses: golangci/golangci-lint-action@3cfe3a4abbb849e10058ce4af15d205b6da42804 # v4.0.0 with: + version: latest + skip-pkg-cache: true args: --timeout=10m diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 79ed4881..29b56759 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,12 +21,7 @@ jobs: with: go-version-file: 'go.mod' cache: true - - run: go mod download - - run: go build -v . - - name: Run linters - uses: golangci/golangci-lint-action@3cfe3a4abbb849e10058ce4af15d205b6da42804 # v4.0.0 - with: - version: latest + - run: make build generate: runs-on: ubuntu-latest steps: @@ -145,6 +140,31 @@ jobs: run: | flux check flux get all + - name: No-op apply Terraform + run: | + export TF_CLI_CONFIG_FILE="${PWD}/.terraformrc" + cd examples/github-via-ssh + terraform apply -auto-approve -var "github_token=${GITHUB_TOKEN}" -var "github_org=fluxcd-testing" -var "github_repository=${{ steps.vars.outputs.test_repo_name }}" + env: + GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }} + - name: Teardown Flux + run: | + flux uninstall -s --keep-namespace + kubectl delete ns flux-system + - name: Restore Flux with Terraform + run: | + export TF_CLI_CONFIG_FILE="${PWD}/.terraformrc" + cd examples/github-via-ssh + terraform apply -auto-approve -var "github_token=${GITHUB_TOKEN}" -var "github_org=fluxcd-testing" -var "github_repository=${{ steps.vars.outputs.test_repo_name }}" + env: + GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }} + - name: No-op apply Terraform + run: | + export TF_CLI_CONFIG_FILE="${PWD}/.terraformrc" + cd examples/github-via-ssh + terraform apply -auto-approve -var "github_token=${GITHUB_TOKEN}" -var "github_org=fluxcd-testing" -var "github_repository=${{ steps.vars.outputs.test_repo_name }}" + env: + GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }} - name: Destroy Terraform run: | cd examples/github-via-ssh diff --git a/Makefile b/Makefile index 152c423e..62f76e1c 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,12 @@ test: tidy fmt vet testacc: tidy fmt vet TF_ACC=1 go test ./... -v -count $(TEST_COUNT) -parallel $(ACCTEST_PARALLELISM) -timeout $(ACCTEST_TIMEOUT) +# Run acceptance tests on macOS with the gitea-flux instance +# Requires the following entry in /etc/hosts: +# 127.0.0.1 gitea-flux +testmacos: tidy fmt vet + TF_ACC=1 GITEA_HOSTNAME=gitea-flux go test ./... -v -parallel 1 -run TestAccBootstrapGit_Drift + build: CGO_ENABLED=0 go build -o ./bin/terraform-provider-flux main.go diff --git a/docs/index.md b/docs/index.md index e225b225..c54c095c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -125,7 +125,7 @@ provider "flux" { ### Optional -- `git` (Attributes) Configuration block with settings for Kubernetes. (see [below for nested schema](#nestedatt--git)) +- `git` (Attributes) Configuration block with settings for Git. (see [below for nested schema](#nestedatt--git)) - `kubernetes` (Attributes) Configuration block with settings for Kubernetes. (see [below for nested schema](#nestedatt--kubernetes)) @@ -133,15 +133,15 @@ provider "flux" { Required: -- `url` (String) Url of git repository to bootstrap from. +- `url` (String) Url of Git repository to bootstrap from. Optional: - `author_email` (String) Author email for Git commits. - `author_name` (String) Author name for Git commits. Defaults to `Flux`. -- `branch` (String) Branch in repository to reconcile from. Defaults to `main`. +- `branch` (String) Branch of the repository to reconcile from. Defaults to `main`. - `commit_message_appendix` (String) String to add to the commit messages. -- `gpg_key_id` (String) Key id for selecting a particular key. +- `gpg_key_id` (String) Key id for selecting a particular GPG key. - `gpg_key_ring` (String) Path to the GPG key ring for signing commits. - `gpg_passphrase` (String, Sensitive) Passphrase for decrypting GPG private key. - `http` (Attributes) (see [below for nested schema](#nestedatt--git--http)) @@ -163,7 +163,7 @@ Optional: Optional: -- `password` (String, Sensitive) Password for private key. +- `password` (String, Sensitive) Password of the SSH private key. - `private_key` (String, Sensitive) Private key used for authenticating to the Git SSH server. - `username` (String) Username for Git SSH server. diff --git a/docs/resources/bootstrap_git.md b/docs/resources/bootstrap_git.md index b0335e6e..dc5d9dcc 100644 --- a/docs/resources/bootstrap_git.md +++ b/docs/resources/bootstrap_git.md @@ -40,7 +40,7 @@ The following examples are available to help you use the provider: - `manifests_path` (String) The install manifests are built from a GitHub release or kustomize overlay if using a local path. Defaults to `https://github.com/fluxcd/flux2/releases`. - `namespace` (String) The namespace scope for install manifests. Defaults to `flux-system`. It will be created if it does not exist. - `network_policy` (Boolean) Deny ingress access to the toolkit controllers from other namespaces using network policies. Defaults to `true`. -- `path` (String) Path relative to the repository root, when specified the cluster sync will be scoped to this path. +- `path` (String) Path relative to the repository root, when specified the cluster sync will be scoped to this path (immutable). - `recurse_submodules` (Boolean) Configures the GitRepository source to initialize and include Git submodules in the artifact it produces. - `registry` (String) Container registry where the toolkit images are published. Defaults to `ghcr.io/fluxcd`. - `secret_name` (String) Name of the secret the sync credentials can be found in or stored to. Defaults to `flux-system`. diff --git a/go.mod b/go.mod index 602d2fde..078daec0 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/fluxcd/image-reflector-controller/api v0.31.2 github.com/fluxcd/kustomize-controller/api v1.2.2 github.com/fluxcd/notification-controller/api v1.2.4 + github.com/fluxcd/pkg/apis/meta v1.4.0 github.com/fluxcd/pkg/git v0.18.0 github.com/fluxcd/pkg/git/gogit v0.18.0 github.com/fluxcd/pkg/runtime v0.45.0 @@ -84,7 +85,6 @@ require ( github.com/fluxcd/go-git-providers v0.20.0 // indirect github.com/fluxcd/pkg/apis/acl v0.2.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.3.0 // indirect - github.com/fluxcd/pkg/apis/meta v1.4.0 // indirect github.com/fluxcd/pkg/kustomize v1.6.0 // indirect github.com/fluxcd/pkg/tar v0.4.0 // indirect github.com/fluxcd/pkg/version v0.3.0 // indirect @@ -154,6 +154,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/oklog/run v1.1.0 // indirect + github.com/onsi/gomega v1.32.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc3 // indirect github.com/pelletier/go-toml v1.9.4 // indirect diff --git a/go.sum b/go.sum index 092ade22..4427c5ef 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.17.1 h1:3jCPOG2ojbl8AcfaUCRYLT5MUcBMFwS0OSK2mA5Zok8= code.gitea.io/sdk/gitea v0.17.1/go.mod h1:aCnBqhHpoEWA180gMbaCtdX9Pl6BWBAuuP2miadoTNM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b6e8854d..11678c3f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -207,18 +207,18 @@ func (p *fluxProvider) Schema(ctx context.Context, req provider.SchemaRequest, r Optional: true, }, "git": schema.SingleNestedAttribute{ - Description: "Configuration block with settings for Kubernetes.", + Description: "Configuration block with settings for Git.", Attributes: map[string]schema.Attribute{ "url": schema.StringAttribute{ CustomType: customtypes.URLType{}, - Description: "Url of git repository to bootstrap from.", + Description: "Url of Git repository to bootstrap from.", Required: true, Validators: []validator.String{ validators.URLScheme("http", "https", "ssh"), }, }, "branch": schema.StringAttribute{ - Description: fmt.Sprintf("Branch in repository to reconcile from. Defaults to `%s`.", defaultBranch), + Description: fmt.Sprintf("Branch of the repository to reconcile from. Defaults to `%s`.", defaultBranch), Optional: true, }, "author_name": schema.StringAttribute{ @@ -239,7 +239,7 @@ func (p *fluxProvider) Schema(ctx context.Context, req provider.SchemaRequest, r Sensitive: true, }, "gpg_key_id": schema.StringAttribute{ - Description: "Key id for selecting a particular key.", + Description: "Key id for selecting a particular GPG key.", Optional: true, }, "commit_message_appendix": schema.StringAttribute{ @@ -253,7 +253,7 @@ func (p *fluxProvider) Schema(ctx context.Context, req provider.SchemaRequest, r Optional: true, }, "password": schema.StringAttribute{ - Description: "Password for private key.", + Description: "Password of the SSH private key.", Optional: true, Sensitive: true, }, diff --git a/internal/provider/provider_resource_data.go b/internal/provider/provider_resource_data.go index 4be302f5..b6651ed9 100644 --- a/internal/provider/provider_resource_data.go +++ b/internal/provider/provider_resource_data.go @@ -71,8 +71,7 @@ func (prd *providerResourceData) GetKubernetesClient() (client.WithWatch, error) return kubeClient, nil } -func (prd *providerResourceData) GetGitClient(ctx context.Context) (*gogit.Client, error) { - // Git configuration +func (prd *providerResourceData) GetGitClient(tmpDir string) (*gogit.Client, error) { authOpts, err := getAuthOpts(prd.git) if err != nil { return nil, err @@ -82,16 +81,25 @@ func (prd *providerResourceData) GetGitClient(ctx context.Context) (*gogit.Clien clientOpts = append(clientOpts, gogit.WithInsecureCredentialsOverHTTP()) } + gitClient, err := gogit.NewClient(tmpDir, authOpts, clientOpts...) + if err != nil { + return nil, fmt.Errorf("could not create git client: %w", err) + } + + return gitClient, nil +} + +func (prd *providerResourceData) CloneRepository(ctx context.Context) (*gogit.Client, error) { tmpDir, err := manifestgen.MkdirTempAbs("", "flux-bootstrap-") if err != nil { return nil, fmt.Errorf("could not create temporary working directory for git repository: %w", err) } - client, err := gogit.NewClient(tmpDir, authOpts, clientOpts...) + gitClient, err := prd.GetGitClient(tmpDir) if err != nil { return nil, fmt.Errorf("could not create git client: %w", err) } // TODO: Need to conditionally clone here. If repository is empty this will fail. - _, err = client.Clone(ctx, prd.GetRepositoryURL().String(), repository.CloneConfig{ + _, err = gitClient.Clone(ctx, prd.GetRepositoryURL().String(), repository.CloneConfig{ CheckoutStrategy: repository.CheckoutStrategy{ Branch: prd.git.Branch.ValueString(), }, @@ -99,7 +107,26 @@ func (prd *providerResourceData) GetGitClient(ctx context.Context) (*gogit.Clien if err != nil { return nil, fmt.Errorf("could not clone git repository: %w", err) } - return client, nil + return gitClient, nil +} + +func (prd *providerResourceData) GetBootstrapProvider(tmpDir string) (*bootstrap.PlainGitBootstrapper, error) { + gitClient, err := prd.GetGitClient(tmpDir) + if err != nil { + return nil, fmt.Errorf("could not create git client: %w", err) + } + + kubeClient, err := prd.GetKubernetesClient() + if err != nil { + return nil, fmt.Errorf("could not get Kubernetes client: %w", err) + } + + bootstrapOpts, err := prd.GetBootstrapOptions() + if err != nil { + return nil, fmt.Errorf("could not get bootstrap options: %w", err) + } + + return bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...) } func (prd *providerResourceData) GetBootstrapOptions() ([]bootstrap.GitOption, error) { diff --git a/internal/provider/resource_bootstrap_git.go b/internal/provider/resource_bootstrap_git.go index 814dae0a..109e834f 100644 --- a/internal/provider/resource_bootstrap_git.go +++ b/internal/provider/resource_bootstrap_git.go @@ -30,6 +30,9 @@ import ( "strings" "time" + "github.com/fluxcd/flux2/v2/pkg/manifestgen" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" "github.com/google/go-containerregistry/pkg/name" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -47,8 +50,10 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" appsv1 "k8s.io/api/apps/v1" networkingv1 "k8s.io/api/networking/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apitypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/kustomize/api/konfig" @@ -196,6 +201,9 @@ func (r *bootstrapGitResource) Schema(ctx context.Context, req resource.SchemaRe }, "id": schema.StringAttribute{ Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "image_pull_secret": schema.StringAttribute{ Description: "Kubernetes secret name used for pulling the toolkit images from a private registry.", @@ -256,7 +264,7 @@ func (r *bootstrapGitResource) Schema(ctx context.Context, req resource.SchemaRe Default: booldefault.StaticBool(defaultOpts.NetworkPolicy), }, "path": schema.StringAttribute{ - Description: "Path relative to the repository root, when specified the cluster sync will be scoped to this path.", + Description: "Path relative to the repository root, when specified the cluster sync will be scoped to this path (immutable).", Optional: true, }, "recurse_submodules": schema.BoolAttribute{ @@ -319,6 +327,7 @@ func (r *bootstrapGitResource) Schema(ctx context.Context, req resource.SchemaRe } } +// ModifyPlan sets the desired Git repository files to be managed by the provider. func (r bootstrapGitResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -351,12 +360,10 @@ func (r bootstrapGitResource) ModifyPlan(ctx context.Context, req resource.Modif diags = resp.Plan.Set(ctx, &data) resp.Diagnostics.Append(diags...) - // This has to be set here, probably a bug in the SDK. - diags = resp.Plan.SetAttribute(ctx, path.Root("id"), data.Namespace) - resp.Diagnostics.Append(diags...) } -// TODO: If kustomization file exists and not all resource files exist bootstrap will fail. This is because kustomize build is run. +// Create pushes the Flux manifests in the Git repository, installs the Flux controllers on the cluster +// and configures Flux to sync the cluster state with the given Git repository path. func (r *bootstrapGitResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -383,7 +390,7 @@ func (r *bootstrapGitResource) Create(ctx context.Context, req resource.CreateRe return } - gitClient, err := r.prd.GetGitClient(ctx) + gitClient, err := r.prd.CloneRepository(ctx) if err != nil { resp.Diagnostics.AddError("Git Client", err.Error()) return @@ -411,7 +418,7 @@ func (r *bootstrapGitResource) Create(ctx context.Context, req resource.CreateRe resp.Diagnostics.AddError("Could not get bootstrap options", err.Error()) return } - b, err := bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...) + bootstrapProvider, err := bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...) if err != nil { resp.Diagnostics.AddError("Could not create bootstrap provider", err.Error()) return @@ -423,39 +430,38 @@ func (r *bootstrapGitResource) Create(ctx context.Context, req resource.CreateRe basePath := filepath.Join(gitClient.Path(), data.Path.ValueString(), data.Namespace.ValueString()) files := map[string]io.Reader{ filepath.Join(basePath, konfig.DefaultKustomizationFileName()): strings.NewReader(data.KustomizationOverride.ValueString()), - filepath.Join(basePath, "gotk-components.yaml"): &strings.Reader{}, - filepath.Join(basePath, "gotk-sync.yaml"): &strings.Reader{}, + filepath.Join(basePath, installOpts.ManifestFile): &strings.Reader{}, + filepath.Join(basePath, syncOpts.ManifestFile): &strings.Reader{}, } - commit, signer, err := r.prd.CreateCommit("Add kustomize override") + commit, signer, err := r.prd.CreateCommit("Init Flux with kustomize override") if err != nil { - resp.Diagnostics.AddError("Unable to create commit", err.Error()) + resp.Diagnostics.AddError("Unable to create kustomize override commit", err.Error()) return } _, err = gitClient.Commit(commit, signer, repository.WithFiles(files)) if err != nil { - resp.Diagnostics.AddError("Could not create bootstrap provider", err.Error()) + resp.Diagnostics.AddError("Unable to commit kustomize override", err.Error()) return } } manifestsBase := "" - err = bootstrap.Run(ctx, b, manifestsBase, installOpts, secretOpts, syncOpts, 2*time.Second, timeout) + err = bootstrap.Run(ctx, bootstrapProvider, manifestsBase, installOpts, secretOpts, syncOpts, 2*time.Second, timeout) if err != nil { resp.Diagnostics.AddError("Bootstrap run error", err.Error()) return } - // TODO: Figure out a better way to track files committed to git. repositoryFiles := map[string]string{} - files := []string{install.MakeDefaultOptions().ManifestFile, sync.MakeDefaultOptions().ManifestFile, konfig.DefaultKustomizationFileName()} + files := []string{installOpts.ManifestFile, syncOpts.ManifestFile, konfig.DefaultKustomizationFileName()} for _, f := range files { - path := filepath.Join(data.Path.ValueString(), data.Namespace.ValueString(), f) - b, err := os.ReadFile(filepath.Join(gitClient.Path(), path)) + filePath := filepath.Join(data.Path.ValueString(), data.Namespace.ValueString(), f) + b, err := os.ReadFile(filepath.Join(gitClient.Path(), filePath)) if err != nil { resp.Diagnostics.AddError("Could not read repository state", err.Error()) return } - repositoryFiles[path] = string(b) + repositoryFiles[filePath] = string(b) } mapValue, diags := types.MapValueFrom(ctx, types.StringType, repositoryFiles) resp.Diagnostics.Append(diags...) @@ -469,8 +475,10 @@ func (r *bootstrapGitResource) Create(ctx context.Context, req resource.CreateRe resp.Diagnostics.Append(diags...) } -// TODO: Consider if more value reading should be done here to detect drift. Similar to how import works. -// TODO: Resources in the cluster should be verified to exist. If not resource id should be set to nil. This is to detect changing clusters. +// Read pulls the Flux manifests from the Git repository to detect drift +// and checks the health of the Flux controllers in the cluster. +// If the Flux controllers are not healthy, the state is marked as needing an update. +// TODO: Handle Git auth key rotation. func (r *bootstrapGitResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -491,28 +499,55 @@ func (r *bootstrapGitResource) Read(ctx context.Context, req resource.ReadReques ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - gitClient, err := r.prd.GetGitClient(ctx) + gitClient, err := r.prd.CloneRepository(ctx) if err != nil { resp.Diagnostics.AddError("Git Client", err.Error()) return } defer os.RemoveAll(gitClient.Path()) - // Sync git repository with Terraform state. + // Detect drift for the Flux manifests stored in Git. repositoryFiles := map[string]string{} for k := range data.RepositoryFiles.Elements() { - path := filepath.Join(gitClient.Path(), k) - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - tflog.Debug(ctx, "Skip reading file that no longer exists in git repository", map[string]interface{}{"path": path}) + filePath := filepath.Join(gitClient.Path(), k) + if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { + tflog.Debug(ctx, "Skip reading file that no longer exists in git repository", map[string]interface{}{"path": filePath}) continue } - b, err := os.ReadFile(path) + b, err := os.ReadFile(filePath) if err != nil { resp.Diagnostics.AddError("Unable to read file in git repository", err.Error()) return } repositoryFiles[k] = string(b) } + + kubeClient, err := r.prd.GetKubernetesClient() + if err != nil { + resp.Diagnostics.AddError("Kubernetes Client", err.Error()) + return + } + + // Check cluster access and kubeconfig permissions + if err := isKubernetesReady(ctx, kubeClient); err != nil { + resp.Diagnostics.AddError("Kubernetes cluster", err.Error()) + return + } + + // Detect drift for the Flux installation in the cluster. + ready, err := isFluxReady(ctx, kubeClient, data) + if !ready { + // reset the gotk_sync.yaml file content to simulate a Git drift which will trigger a redeployment + syncOpts := sync.MakeDefaultOptions() + syncPath := filepath.Join(data.Path.ValueString(), data.Namespace.ValueString(), syncOpts.ManifestFile) + repositoryFiles[syncPath] = "" + warnDetails := "" + if err != nil { + warnDetails = err.Error() + } + resp.Diagnostics.AddWarning("Flux controllers are not healthy and will be redeployed", warnDetails) + } + mapValue, diags := types.MapValueFrom(ctx, types.StringType, repositoryFiles) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -524,7 +559,7 @@ func (r *bootstrapGitResource) Read(ctx context.Context, req resource.ReadReques resp.Diagnostics.Append(diags...) } -// TODO: Verify Flux components after updating Git. +// Update pushes the Flux manifests in the Git repository and applies the changes on the cluster. func (r bootstrapGitResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -558,8 +593,9 @@ func (r bootstrapGitResource) Update(ctx context.Context, req resource.UpdateReq return } + // Sync Git repository with Terraform state. err := retry.RetryContext(ctx, timeout, func() *retry.RetryError { - gitClient, err := r.prd.GetGitClient(ctx) + gitClient, err := r.prd.CloneRepository(ctx) if err != nil { return retry.NonRetryableError(err) } @@ -571,16 +607,16 @@ func (r bootstrapGitResource) Update(ctx context.Context, req resource.UpdateReq if ok { continue } - path := filepath.Join(gitClient.Path(), k) - _, err := os.Stat(path) + filePath := filepath.Join(gitClient.Path(), k) + _, err := os.Stat(filePath) if errors.Is(err, os.ErrNotExist) { - tflog.Debug(ctx, "Skipping removing no longer tracked file as it does not exist", map[string]interface{}{"path": path}) + tflog.Debug(ctx, "Skipping removing no longer tracked file as it does not exist", map[string]interface{}{"path": filePath}) continue } if err != nil { retry.NonRetryableError(fmt.Errorf("could not stat no longer tracked file: %w", err)) } - err = os.Remove(path) + err = os.Remove(filePath) if err != nil { return retry.NonRetryableError(fmt.Errorf("could not remove no longer tracked file: %w", err)) } @@ -591,7 +627,7 @@ func (r bootstrapGitResource) Update(ctx context.Context, req resource.UpdateReq for k, v := range repositoryFiles { files[k] = strings.NewReader(v) } - commit, signer, err := r.prd.CreateCommit("Update Flux") + commit, signer, err := r.prd.CreateCommit("Update Flux manifests") if err != nil { return retry.NonRetryableError(fmt.Errorf("unable to create commit: %w", err)) } @@ -605,18 +641,56 @@ func (r bootstrapGitResource) Update(ctx context.Context, req resource.UpdateReq } err = gitClient.Push(ctx, repository.PushConfig{}) if err != nil { - return retry.RetryableError(fmt.Errorf("unable to push file update: %w", err)) + return retry.RetryableError(fmt.Errorf("unable to push updated manifests: %w", err)) } return nil }) if err != nil { - resp.Diagnostics.AddError("Could not update Flux", err.Error()) + resp.Diagnostics.AddError("Could not update Flux manifests in Git", err.Error()) + } else { + // Sync Flux installation with Git state. + + installOpts := getInstallOptions(data) + syncOpts := getSyncOptions(data, r.prd.GetRepositoryURL(), r.prd.git.Branch.ValueString()) + var secretOpts sourcesecret.Options + if data.DisableSecretCreation.ValueBool() { + secretOpts = sourcesecret.Options{ + Name: data.SecretName.ValueString(), + Namespace: data.Namespace.ValueString(), + } + } else { + secretOpts, err = r.prd.GetSecretOptions(data.SecretName.ValueString(), data.Namespace.ValueString(), data.Path.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Could not get secret options", err.Error()) + return + } + } + + tmpDir, err := manifestgen.MkdirTempAbs("", "flux-bootstrap-") + if err != nil { + resp.Diagnostics.AddError("could not create temporary working directory for git repository", err.Error()) + } + defer os.RemoveAll(tmpDir) + + bootstrapProvider, err := r.prd.GetBootstrapProvider(tmpDir) + if err != nil { + resp.Diagnostics.AddError("Bootstrap Provider", err.Error()) + return + } + + manifestsBase := "" + err = bootstrap.Run(ctx, bootstrapProvider, manifestsBase, installOpts, secretOpts, syncOpts, 2*time.Second, timeout) + if err != nil { + resp.Diagnostics.AddError("Bootstrap run error", err.Error()) + return + } } diags = resp.State.Set(ctx, &data) resp.Diagnostics.Append(diags...) } +// Delete removes the Flux components from the cluster and the manifests from the Git repository. func (r bootstrapGitResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -677,7 +751,7 @@ func (r bootstrapGitResource) Delete(ctx context.Context, req resource.DeleteReq } err = retry.RetryContext(ctx, timeout, func() *retry.RetryError { - gitClient, err := r.prd.GetGitClient(ctx) + gitClient, err := r.prd.CloneRepository(ctx) if err != nil { return retry.NonRetryableError(err) } @@ -718,7 +792,7 @@ func (r bootstrapGitResource) Delete(ctx context.Context, req resource.DeleteReq } } -// TODO: Validate Flux installation before proceeding with import. +// ImportState scans the cluster for the Flux components configuration and imports it into the Terraform state. func (r *bootstrapGitResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { if r.prd == nil { resp.Diagnostics.AddError(missingConfiguration, bootstrapGitResourceMissingConfigError) @@ -742,6 +816,16 @@ func (r *bootstrapGitResource) ImportState(ctx context.Context, req resource.Imp data.ID = types.StringValue(req.ID) data.Namespace = data.ID + ready, err := isFluxReady(ctx, kubeClient, data) + if err != nil { + resp.Diagnostics.AddError("Could not check Flux readiness", err.Error()) + return + } + if !ready { + resp.Diagnostics.AddError("Flux is not ready", "Flux Kustomization is failing") + return + } + // Set values that cant be null. data.TolerationKeys = types.SetNull(types.StringType) @@ -888,9 +972,9 @@ func (r *bootstrapGitResource) ImportState(ctx context.Context, req resource.Imp } // Only set path value if path is something other than nil. This is to be consistent with the default value. data.Path = types.StringNull() - path := strings.TrimPrefix(kustomization.Spec.Path, "./") - if path != "" { - data.Path = types.StringValue(path) + syncPath := strings.TrimPrefix(kustomization.Spec.Path, "./") + if syncPath != "" { + data.Path = types.StringValue(syncPath) } // Check which components are present and which are not. @@ -1046,3 +1130,41 @@ func getExpectedRepositoryFiles(data bootstrapGitResourceData, url *url.URL, bra repositoryFiles[filepath.Join(data.Path.ValueString(), data.Namespace.ValueString(), konfig.DefaultKustomizationFileName())] = getKustomizationFile(data) return repositoryFiles, nil } + +// isKubernetesReady checks if the Kubernetes API is accessible +// and if the user has the necessary permissions. +func isKubernetesReady(ctx context.Context, kubeClient client.Client) error { + var list apiextensionsv1.CustomResourceDefinitionList + selector := client.MatchingLabels{manifestgen.PartOfLabelKey: manifestgen.PartOfLabelValue} + if err := kubeClient.List(ctx, &list, client.InNamespace(""), selector); err != nil { + return err + } + return nil +} + +// isFluxReady checks if the Flux sync objects are present and ready. +func isFluxReady(ctx context.Context, kubeClient client.Client, data bootstrapGitResourceData) (bool, error) { + syncName := apitypes.NamespacedName{ + Namespace: data.Namespace.ValueString(), + Name: data.Namespace.ValueString(), + } + + rootSource := &sourcev1.GitRepository{} + if err := kubeClient.Get(ctx, syncName, rootSource); err != nil { + return false, err + } + if conditions.IsFalse(rootSource, meta.ReadyCondition) { + return false, errors.New(conditions.GetMessage(rootSource, meta.ReadyCondition)) + } + + rootSync := &kustomizev1.Kustomization{} + if err := kubeClient.Get(ctx, syncName, rootSync); err != nil { + return false, err + } + if conditions.IsFalse(rootSync, meta.ReadyCondition) { + conditions.GetMessage(rootSync, meta.ReadyCondition) + return false, errors.New(conditions.GetMessage(rootSync, meta.ReadyCondition)) + } + + return true, nil +} diff --git a/internal/provider/resource_bootstrap_git_test.go b/internal/provider/resource_bootstrap_git_test.go index 3dc6330e..8bd554e2 100644 --- a/internal/provider/resource_bootstrap_git_test.go +++ b/internal/provider/resource_bootstrap_git_test.go @@ -40,6 +40,7 @@ import ( "github.com/fluxcd/pkg/git/gogit" "github.com/fluxcd/pkg/git/repository" "github.com/fluxcd/pkg/ssh" + sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/go-logr/logr" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -49,6 +50,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" + crclient "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/kind/pkg/cluster" @@ -99,7 +101,7 @@ kind: Kustomization resources: - gotk-components.yaml` env := environment{ - httpClone: "https://gitub.com", + httpClone: "https://git.example", } resource.ParallelTest(t, resource.TestCase{ @@ -115,7 +117,7 @@ resources: func TestAccBootstrapGit_TolerationKeys(t *testing.T) { env := environment{ - httpClone: "https://gitub.com", + httpClone: "https://git.example", } resource.ParallelTest(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, @@ -185,7 +187,7 @@ func TestAccBootstrapGit_Drift(t *testing.T) { resource.ParallelTest(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ - // Basic installation of Flux. + // Default installation of Flux. { Config: bootstrapGitHTTP(env), Check: resource.ComposeTestCheckFunc( @@ -194,7 +196,7 @@ func TestAccBootstrapGit_Drift(t *testing.T) { resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-sync.yaml"), ), }, - // Remove file and expect Terraform to detect drift. + // Remove file in Git and expect Terraform to correct drift. { PreConfig: func() { gitClient := getTestGitClient(t, env.username, env.password) @@ -217,15 +219,43 @@ func TestAccBootstrapGit_Drift(t *testing.T) { resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-sync.yaml"), ), }, - // Change path and expect files to be moved + // Remove GitRepository in-cluster and expect Terraform to correct drift. { - Config: bootstrapGitCustomPath(env, "custom-path"), + PreConfig: func() { + cfg, err := clientcmd.BuildConfigFromFlags("", env.kubeCfgPath) + if err != nil { + t.Fatalf("Can not initialize kubeconfig: %s", err) + } + kubeClient, err := crclient.NewWithWatch(cfg, crclient.Options{Scheme: utils.NewScheme()}) + if err != nil { + t.Fatalf("Can not initialize kube client: %s", err) + } + rootSource := &sourcev1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "flux-system", + Namespace: "flux-system", + }, + } + if err := kubeClient.Delete(context.Background(), rootSource); err != nil { + t.Fatalf("Can not delete source: %s", err) + } + + }, + Config: bootstrapGitSSH(env), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.custom-path/flux-system/kustomization.yaml"), - resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.custom-path/flux-system/gotk-components.yaml"), - resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.custom-path/flux-system/gotk-sync.yaml"), + resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/kustomization.yaml"), + resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-components.yaml"), + resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-sync.yaml"), ), }, + // Expect no-op when Git and in-cluster state are in sync. + { + Config: bootstrapGitSSH(env), + ResourceName: "flux_bootstrap_git.this", + ImportState: true, + ImportStateId: "flux-system", + ImportStateVerify: true, + }, }, }) } @@ -309,7 +339,6 @@ patches: resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/kustomization.yaml"), resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-components.yaml"), resource.TestCheckResourceAttrSet("flux_bootstrap_git.this", "repository_files.flux-system/gotk-sync.yaml"), - //resource.TestCheckResourceAttr("flux_bootstrap_git.this", "repository_files.flux-system/kustomization.yaml", kustomizationOverride), func(state *terraform.State) error { cfg, err := clientcmd.BuildConfigFromFlags("", env.kubeCfgPath) if err != nil { @@ -326,7 +355,7 @@ patches: for _, deployment := range deploymentList.Items { v, ok := deployment.Spec.Template.Annotations["cluster-autoscaler.kubernetes.io/safe-to-evict"] if !ok { - return fmt.Errorf("expected annotation not set in Deployment %s", deployment.Name) + return fmt.Errorf("expected annotation to be set in Deployment %s", deployment.Name) } if v != "true" { return fmt.Errorf("expected annotation value to be true but was %s", v) @@ -497,28 +526,6 @@ EOF `, env.kubeCfgPath, env.sshClone, env.privateKey) } -func bootstrapGitCustomPath(env environment, path string) string { - return fmt.Sprintf(` - provider "flux" { - kubernetes = { - config_path = "%s" - } - git = { - url = "%s" - http = { - username = "%s" - password = "%s" - allow_insecure_http = true - } - } - } - - resource "flux_bootstrap_git" "this" { - path = "%s" - } - `, env.kubeCfgPath, env.httpClone, env.username, env.password, path) -} - func bootstrapGitVersion(env environment, version string) string { return fmt.Sprintf(` provider "flux" { @@ -638,7 +645,10 @@ func setupEnvironment(t *testing.T) environment { httpPort := rand.Intn(65535-1024) + 1024 sshPort := httpPort + 10 randSuffix := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) - giteaName := fmt.Sprintf("gitea-%s", randSuffix) + giteaName := os.Getenv("GITEA_HOSTNAME") + if giteaName == "" { + giteaName = fmt.Sprintf("gitea-%s", randSuffix) + } username := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) password := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) tmpDir := t.TempDir()