diff --git a/.github/workflows/v2-run-acceptance-tests.yaml b/.github/workflows/v2-run-acceptance-tests.yaml index a95f67f7..4aa34169 100644 --- a/.github/workflows/v2-run-acceptance-tests.yaml +++ b/.github/workflows/v2-run-acceptance-tests.yaml @@ -10,6 +10,8 @@ on: env: PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PULUMI_BOT_TOKEN: ${{ secrets.PULUMI_BOT_TOKEN }} + jobs: build: runs-on: ubuntu-latest @@ -31,9 +33,9 @@ jobs: version: "~> v2" args: release --snapshot --clean --skip=docker - agent-integration-tests: + unit-tests: runs-on: ubuntu-latest - name: Integration Testing + name: Unit tests steps: - name: Check out code uses: actions/checkout@v4 @@ -55,3 +57,27 @@ jobs: files: agent/coverage.out,operator/coverage.out env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + e2e-tests: + runs-on: ubuntu-latest + name: E2E tests + if: false + steps: + # Building the rootless image currently eats up all of our free disk. + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + - name: Setup cluster + uses: helm/kind-action@v1 + with: + cluster_name: kind + node_image: kindest/node:v1.31.0 + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.22.x + - name: Run tests + run: make -C operator test-e2e diff --git a/.mise.toml b/.mise.toml index b648c9e4..c049e652 100644 --- a/.mise.toml +++ b/.mise.toml @@ -4,6 +4,7 @@ etcd = "3.5.16" protoc-gen-go = "1.34.2" protoc-gen-go-grpc = "1.5.1" kubebuilder = "4.2.0" +envsubst = "1.4.2" [settings] experimental = true # Enable Go backend. diff --git a/agent/Makefile b/agent/Makefile index 5689e0f7..2f95ca08 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -11,7 +11,7 @@ protoc: --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto test: - go test -covermode=atomic -coverprofile=coverage.out -v ./... + go test -covermode=atomic -coverprofile=coverage.out -coverpkg=./... -v ./... agent: protoc @echo "Building agent" diff --git a/agent/cmd/init.go b/agent/cmd/init.go index 5e01dc43..7d027bd0 100644 --- a/agent/cmd/init.go +++ b/agent/cmd/init.go @@ -17,28 +17,25 @@ package cmd import ( "context" - "net/url" + "errors" + "fmt" + "io" "os" - "github.com/fluxcd/pkg/git" - "github.com/fluxcd/pkg/git/gogit" - "github.com/fluxcd/pkg/git/repository" + "github.com/blang/semver" "github.com/fluxcd/pkg/http/fetch" - "github.com/go-git/go-git/v5/plumbing/transport" + git "github.com/go-git/go-git/v5" + "github.com/pulumi/pulumi/sdk/v3/go/auto" "github.com/spf13/cobra" "go.uber.org/zap" ) -const ( - DefaultFluxRetries = 3 -) - var ( - TargetDir string - FluxUrl string - FluxDigest string - GitUrl string - GitRevision string + _targetDir string + _fluxURL string + _fluxDigest string + _gitURL string + _gitRevision string ) // initCmd represents the init command @@ -50,99 +47,158 @@ var initCmd = &cobra.Command{ For Flux sources: pulumi-kubernetes-agent init --flux-fetch-url URL `, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - log.Debugw("executing init command", "TargetDir", TargetDir) + var f fetchWithContexter + var g newLocalWorkspacer - err := os.MkdirAll(TargetDir, 0777) - if err != nil { - log.Errorw("fatal: unable to make target directory", zap.Error(err)) - os.Exit(1) - } - log.Debugw("target directory created", "dir", TargetDir) - - // fetch the configured flux artifact - if FluxUrl != "" { - // https://github.com/fluxcd/kustomize-controller/blob/a1a33f2adda783dd2a17234f5d8e84caca4e24e2/internal/controller/kustomization_controller.go#L328 - fetcher := fetch.New( - fetch.WithRetries(DefaultFluxRetries), - fetch.WithHostnameOverwrite(os.Getenv("SOURCE_CONTROLLER_LOCALHOST")), - fetch.WithUntar()) - - log.Infow("flux artifact fetching", "url", FluxUrl, "digest", FluxDigest) - err := fetcher.FetchWithContext(ctx, FluxUrl, FluxDigest, TargetDir) - if err != nil { - log.Errorw("fatal: unable to fetch flux artifact", zap.Error(err)) - os.Exit(2) - } - log.Infow("flux artifact fetched", "dir", TargetDir) + if _gitURL != "" { + g = &gitFetcher{url: _gitURL, revision: _gitRevision} } - - // fetch the configured git artifact - if GitUrl != "" { - u, err := url.Parse(GitUrl) - if err != nil { - log.Errorw("fatal: unable to parse git url", zap.Error(err)) - os.Exit(2) - } - // Configure authentication strategy to access the source - authData := map[string][]byte{} - authOpts, err := git.NewAuthOptions(*u, authData) - if err != nil { - log.Errorw("fatal: unable to parse git auth options", zap.Error(err)) - os.Exit(2) - } - cloneOpts := repository.CloneConfig{ - RecurseSubmodules: false, - ShallowClone: true, + // https://github.com/fluxcd/kustomize-controller/blob/a1a33f2adda783dd2a17234f5d8e84caca4e24e2/internal/controller/kustomization_controller.go#L328 + if _fluxURL != "" { + f = &fluxFetcher{ + url: _fluxURL, + digest: _fluxDigest, + wrapped: fetch.New( + fetch.WithRetries(3), + fetch.WithHostnameOverwrite(os.Getenv("SOURCE_CONTROLLER_LOCALHOST")), + fetch.WithUntar()), } - cloneOpts.Commit = GitRevision - log.Infow("git source fetching", "url", GitUrl, "revision", GitRevision) - _, err = gitCheckout(ctx, GitUrl, cloneOpts, authOpts, nil, TargetDir) - if err != nil { - log.Errorw("fatal: unable to fetch git source", zap.Error(err)) - os.Exit(2) - } - log.Infow("git artifact fetched", "dir", TargetDir) } + // Don't display usage on error. + cmd.SilenceUsage = true + return runInit(ctx, log, _targetDir, f, g) }, } -func gitCheckout(ctx context.Context, url string, cloneOpts repository.CloneConfig, - authOpts *git.AuthOptions, proxyOpts *transport.ProxyOptions, dir string) (*git.Commit, error) { +func runInit(ctx context.Context, + log *zap.SugaredLogger, + targetDir string, + f fetchWithContexter, + g newLocalWorkspacer, +) error { + log.Debugw("executing init command", "TargetDir", targetDir) + + // fetch the configured flux artifact + if f != nil { + err := os.MkdirAll(targetDir, 0o777) + if err != nil { + return fmt.Errorf("unable to make target directory: %w", err) + } + log.Debugw("target directory created", "dir", targetDir) + + log.Infow("flux artifact fetching", "url", f.URL(), "digest", f.Digest()) + err = f.FetchWithContext(ctx, f.URL(), f.Digest(), targetDir) + if err != nil { + return fmt.Errorf("unable to fetch flux artifact: %w", err) + } + log.Infow("flux artifact fetched", "dir", targetDir) + return nil + } - clientOpts := []gogit.ClientOption{gogit.WithDiskStorage()} - if authOpts.Transport == git.HTTP { - clientOpts = append(clientOpts, gogit.WithInsecureCredentialsOverHTTP()) + // fetch the configured git artifact + auth := &auto.GitAuth{ + SSHPrivateKey: os.Getenv("GIT_SSH_PRIVATE_KEY"), + Username: os.Getenv("GIT_USERNAME"), + Password: os.Getenv("GIT_PASSWORD"), + PersonalAccessToken: os.Getenv("GIT_TOKEN"), } - if proxyOpts != nil { - clientOpts = append(clientOpts, gogit.WithProxy(*proxyOpts)) + repo := auto.GitRepo{ + URL: g.URL(), + CommitHash: g.Revision(), + Auth: auth, + Shallow: os.Getenv("GIT_SHALLOW") == "true", } - gitReader, err := gogit.NewClient(dir, authOpts, clientOpts...) - if err != nil { - return nil, err + log.Infow("about to clone into", "TargetDir", targetDir, "fluxURL", g.URL()) + + // This will also handle creating the TargetDir for us. + _, err := g.NewLocalWorkspace(ctx, + auto.Repo(repo), + auto.WorkDir(targetDir), + auto.Pulumi(noop{}), + ) + if errors.Is(err, git.ErrRepositoryAlreadyExists) { + // TODO(https://github.com/pulumi/pulumi/issues/17288): Automation + // API needs to ensure the existing checkout is valid. + log.Infow("repository was previously checked out", "dir", targetDir) + return nil } - defer gitReader.Close() - - commit, err := gitReader.Clone(ctx, url, cloneOpts) if err != nil { - return nil, err + return fmt.Errorf("unable to fetch git source: %w", err) } + log.Infow("git artifact fetched", "dir", targetDir) + return nil +} + +type fetchWithContexter interface { + FetchWithContext(ctx context.Context, archiveURL, digest, dir string) error + URL() string + Digest() string +} +type fluxFetcher struct { + wrapped *fetch.ArchiveFetcher + url, digest string +} + +func (f fluxFetcher) FetchWithContext(ctx context.Context, archiveURL, digest, dir string) error { + return f.wrapped.FetchWithContext(ctx, archiveURL, digest, dir) +} + +func (f fluxFetcher) URL() string { + return f.url +} + +func (f fluxFetcher) Digest() string { + return f.digest +} + +type newLocalWorkspacer interface { + NewLocalWorkspace(context.Context, ...auto.LocalWorkspaceOption) (auto.Workspace, error) + URL() string + Revision() string +} + +type gitFetcher struct { + url, revision string +} + +func (g gitFetcher) NewLocalWorkspace(ctx context.Context, opts ...auto.LocalWorkspaceOption) (auto.Workspace, error) { + return auto.NewLocalWorkspace(ctx, opts...) +} + +func (g gitFetcher) URL() string { + return g.url +} - return commit, nil +func (g gitFetcher) Revision() string { + return g.revision +} + +// noop is a PulumiCommand which doesn't do anything -- because the underlying CLI +// isn't available in our image. +type noop struct{} + +func (noop) Version() semver.Version { return semver.Version{} } + +func (noop) Run(context.Context, string, io.Reader, []io.Writer, []io.Writer, []string, ...string) (string, string, int, error) { + return "", "", 0, fmt.Errorf("pulumi CLI is not available during init") } func init() { rootCmd.AddCommand(initCmd) - initCmd.Flags().StringVarP(&TargetDir, "target-dir", "t", "", "The target directory to initialize") + initCmd.Flags().StringVarP(&_targetDir, "target-dir", "t", "", "The target directory to initialize") initCmd.MarkFlagRequired("target-dir") - initCmd.Flags().StringVar(&FluxUrl, "flux-url", "", "Flux archive URL") - initCmd.Flags().StringVar(&FluxDigest, "flux-digest", "", "Flux digest") + initCmd.Flags().StringVar(&_fluxURL, "flux-url", "", "Flux archive URL") + initCmd.Flags().StringVar(&_fluxDigest, "flux-digest", "", "Flux digest") initCmd.MarkFlagsRequiredTogether("flux-url", "flux-digest") - initCmd.Flags().StringVar(&GitUrl, "git-url", "", "Git repository URL") - initCmd.Flags().StringVar(&GitRevision, "git-revision", "", "Git revision (tag or commit SHA)") + initCmd.Flags().StringVar(&_gitURL, "git-url", "", "Git repository URL") + initCmd.Flags().StringVar(&_gitRevision, "git-revision", "", "Git revision (tag or commit SHA)") initCmd.MarkFlagsRequiredTogether("git-url", "git-revision") + + initCmd.MarkFlagsOneRequired("git-url", "flux-url") + initCmd.MarkFlagsMutuallyExclusive("git-url", "flux-url") } diff --git a/agent/cmd/init_test.go b/agent/cmd/init_test.go new file mode 100644 index 00000000..5fe55be1 --- /dev/null +++ b/agent/cmd/init_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestInitFluxSource(t *testing.T) { + t.Parallel() + dir := t.TempDir() + log := zap.L().Named(t.Name()).Sugar() + + url := "https://github.com/pulumi/examples.git" + digest := "sha256:bcbed45526b241ab3366707b5a58c900e9d60a1d5c385cdfe976b1306584b454" + + ctrl := gomock.NewController(t) + f := NewMockfetchWithContexter(ctrl) + f.EXPECT().URL().Return(url).AnyTimes() + f.EXPECT().Digest().Return(digest).AnyTimes() + f.EXPECT().FetchWithContext(gomock.Any(), url, digest, dir).Return(nil) + + err := runInit(context.Background(), log, dir, f, nil) + assert.NoError(t, err) +} + +func TestInitGitSource(t *testing.T) { + t.Parallel() + dir := t.TempDir() + log := zap.L().Named(t.Name()).Sugar() + + ctrl := gomock.NewController(t) + g := NewMocknewLocalWorkspacer(ctrl) + g.EXPECT().URL().Return("https://github.com/pulumi/examples.git").AnyTimes() + g.EXPECT().Revision().Return("f143bd369afcb5455edb54c2b90ad7aaac719339").AnyTimes() + + // Simulate a successful pull, followed by a second unnecessary pull. + // TODO: Check auth etc. + gomock.InOrder( + g.EXPECT().NewLocalWorkspace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil), + g.EXPECT().NewLocalWorkspace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, git.ErrRepositoryAlreadyExists), + ) + + err := runInit(context.Background(), log, dir, nil, g) + assert.NoError(t, err) + + err = runInit(context.Background(), log, dir, nil, g) + assert.NoError(t, err) +} + +func TestInitGitSourceE2E(t *testing.T) { + if testing.Short() { + t.Skip() + } + t.Parallel() + + // Copy the command so we don't mutate it. + root := cobra.Command(*rootCmd) + root.SetArgs([]string{ + "init", + "--git-url=https://github.com/git-fixtures/basic", + "--git-revision=6ecf0ef2c2dffb796033e5a02219af86ec6584e5", + "--target-dir=" + t.TempDir(), + }) + assert.NoError(t, root.Execute()) +} diff --git a/agent/cmd/mock_test.go b/agent/cmd/mock_test.go new file mode 100644 index 00000000..db034910 --- /dev/null +++ b/agent/cmd/mock_test.go @@ -0,0 +1,298 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: cmd/init.go +// +// Generated by this command: +// +// mockgen -source cmd/init.go -package cmd -typed --destination cmd/mock_test.go +// + +// Package cmd is a generated GoMock package. +package cmd + +import ( + context "context" + reflect "reflect" + + auto "github.com/pulumi/pulumi/sdk/v3/go/auto" + gomock "go.uber.org/mock/gomock" +) + +// MockfetchWithContexter is a mock of fetchWithContexter interface. +type MockfetchWithContexter struct { + ctrl *gomock.Controller + recorder *MockfetchWithContexterMockRecorder +} + +// MockfetchWithContexterMockRecorder is the mock recorder for MockfetchWithContexter. +type MockfetchWithContexterMockRecorder struct { + mock *MockfetchWithContexter +} + +// NewMockfetchWithContexter creates a new mock instance. +func NewMockfetchWithContexter(ctrl *gomock.Controller) *MockfetchWithContexter { + mock := &MockfetchWithContexter{ctrl: ctrl} + mock.recorder = &MockfetchWithContexterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockfetchWithContexter) EXPECT() *MockfetchWithContexterMockRecorder { + return m.recorder +} + +// Digest mocks base method. +func (m *MockfetchWithContexter) Digest() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Digest") + ret0, _ := ret[0].(string) + return ret0 +} + +// Digest indicates an expected call of Digest. +func (mr *MockfetchWithContexterMockRecorder) Digest() *MockfetchWithContexterDigestCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Digest", reflect.TypeOf((*MockfetchWithContexter)(nil).Digest)) + return &MockfetchWithContexterDigestCall{Call: call} +} + +// MockfetchWithContexterDigestCall wrap *gomock.Call +type MockfetchWithContexterDigestCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockfetchWithContexterDigestCall) Return(arg0 string) *MockfetchWithContexterDigestCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockfetchWithContexterDigestCall) Do(f func() string) *MockfetchWithContexterDigestCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockfetchWithContexterDigestCall) DoAndReturn(f func() string) *MockfetchWithContexterDigestCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FetchWithContext mocks base method. +func (m *MockfetchWithContexter) FetchWithContext(ctx context.Context, archiveURL, digest, dir string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchWithContext", ctx, archiveURL, digest, dir) + ret0, _ := ret[0].(error) + return ret0 +} + +// FetchWithContext indicates an expected call of FetchWithContext. +func (mr *MockfetchWithContexterMockRecorder) FetchWithContext(ctx, archiveURL, digest, dir any) *MockfetchWithContexterFetchWithContextCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWithContext", reflect.TypeOf((*MockfetchWithContexter)(nil).FetchWithContext), ctx, archiveURL, digest, dir) + return &MockfetchWithContexterFetchWithContextCall{Call: call} +} + +// MockfetchWithContexterFetchWithContextCall wrap *gomock.Call +type MockfetchWithContexterFetchWithContextCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockfetchWithContexterFetchWithContextCall) Return(arg0 error) *MockfetchWithContexterFetchWithContextCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockfetchWithContexterFetchWithContextCall) Do(f func(context.Context, string, string, string) error) *MockfetchWithContexterFetchWithContextCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockfetchWithContexterFetchWithContextCall) DoAndReturn(f func(context.Context, string, string, string) error) *MockfetchWithContexterFetchWithContextCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// URL mocks base method. +func (m *MockfetchWithContexter) URL() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "URL") + ret0, _ := ret[0].(string) + return ret0 +} + +// URL indicates an expected call of URL. +func (mr *MockfetchWithContexterMockRecorder) URL() *MockfetchWithContexterURLCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "URL", reflect.TypeOf((*MockfetchWithContexter)(nil).URL)) + return &MockfetchWithContexterURLCall{Call: call} +} + +// MockfetchWithContexterURLCall wrap *gomock.Call +type MockfetchWithContexterURLCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockfetchWithContexterURLCall) Return(arg0 string) *MockfetchWithContexterURLCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockfetchWithContexterURLCall) Do(f func() string) *MockfetchWithContexterURLCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockfetchWithContexterURLCall) DoAndReturn(f func() string) *MockfetchWithContexterURLCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MocknewLocalWorkspacer is a mock of newLocalWorkspacer interface. +type MocknewLocalWorkspacer struct { + ctrl *gomock.Controller + recorder *MocknewLocalWorkspacerMockRecorder +} + +// MocknewLocalWorkspacerMockRecorder is the mock recorder for MocknewLocalWorkspacer. +type MocknewLocalWorkspacerMockRecorder struct { + mock *MocknewLocalWorkspacer +} + +// NewMocknewLocalWorkspacer creates a new mock instance. +func NewMocknewLocalWorkspacer(ctrl *gomock.Controller) *MocknewLocalWorkspacer { + mock := &MocknewLocalWorkspacer{ctrl: ctrl} + mock.recorder = &MocknewLocalWorkspacerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocknewLocalWorkspacer) EXPECT() *MocknewLocalWorkspacerMockRecorder { + return m.recorder +} + +// NewLocalWorkspace mocks base method. +func (m *MocknewLocalWorkspacer) NewLocalWorkspace(arg0 context.Context, arg1 ...auto.LocalWorkspaceOption) (auto.Workspace, error) { + m.ctrl.T.Helper() + varargs := []any{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "NewLocalWorkspace", varargs...) + ret0, _ := ret[0].(auto.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewLocalWorkspace indicates an expected call of NewLocalWorkspace. +func (mr *MocknewLocalWorkspacerMockRecorder) NewLocalWorkspace(arg0 any, arg1 ...any) *MocknewLocalWorkspacerNewLocalWorkspaceCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewLocalWorkspace", reflect.TypeOf((*MocknewLocalWorkspacer)(nil).NewLocalWorkspace), varargs...) + return &MocknewLocalWorkspacerNewLocalWorkspaceCall{Call: call} +} + +// MocknewLocalWorkspacerNewLocalWorkspaceCall wrap *gomock.Call +type MocknewLocalWorkspacerNewLocalWorkspaceCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocknewLocalWorkspacerNewLocalWorkspaceCall) Return(arg0 auto.Workspace, arg1 error) *MocknewLocalWorkspacerNewLocalWorkspaceCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocknewLocalWorkspacerNewLocalWorkspaceCall) Do(f func(context.Context, ...auto.LocalWorkspaceOption) (auto.Workspace, error)) *MocknewLocalWorkspacerNewLocalWorkspaceCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocknewLocalWorkspacerNewLocalWorkspaceCall) DoAndReturn(f func(context.Context, ...auto.LocalWorkspaceOption) (auto.Workspace, error)) *MocknewLocalWorkspacerNewLocalWorkspaceCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Revision mocks base method. +func (m *MocknewLocalWorkspacer) Revision() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revision") + ret0, _ := ret[0].(string) + return ret0 +} + +// Revision indicates an expected call of Revision. +func (mr *MocknewLocalWorkspacerMockRecorder) Revision() *MocknewLocalWorkspacerRevisionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revision", reflect.TypeOf((*MocknewLocalWorkspacer)(nil).Revision)) + return &MocknewLocalWorkspacerRevisionCall{Call: call} +} + +// MocknewLocalWorkspacerRevisionCall wrap *gomock.Call +type MocknewLocalWorkspacerRevisionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocknewLocalWorkspacerRevisionCall) Return(arg0 string) *MocknewLocalWorkspacerRevisionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocknewLocalWorkspacerRevisionCall) Do(f func() string) *MocknewLocalWorkspacerRevisionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocknewLocalWorkspacerRevisionCall) DoAndReturn(f func() string) *MocknewLocalWorkspacerRevisionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// URL mocks base method. +func (m *MocknewLocalWorkspacer) URL() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "URL") + ret0, _ := ret[0].(string) + return ret0 +} + +// URL indicates an expected call of URL. +func (mr *MocknewLocalWorkspacerMockRecorder) URL() *MocknewLocalWorkspacerURLCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "URL", reflect.TypeOf((*MocknewLocalWorkspacer)(nil).URL)) + return &MocknewLocalWorkspacerURLCall{Call: call} +} + +// MocknewLocalWorkspacerURLCall wrap *gomock.Call +type MocknewLocalWorkspacerURLCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocknewLocalWorkspacerURLCall) Return(arg0 string) *MocknewLocalWorkspacerURLCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocknewLocalWorkspacerURLCall) Do(f func() string) *MocknewLocalWorkspacerURLCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocknewLocalWorkspacerURLCall) DoAndReturn(f func() string) *MocknewLocalWorkspacerURLCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/agent/go.mod b/agent/go.mod index d44725ea..103c77f5 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -3,8 +3,7 @@ module github.com/pulumi/pulumi-kubernetes-operator/agent go 1.22.5 require ( - github.com/fluxcd/pkg/git v0.19.0 - github.com/fluxcd/pkg/git/gogit v0.19.0 + github.com/blang/semver v3.5.1+incompatible github.com/fluxcd/pkg/http/fetch v0.11.0 github.com/go-git/go-git/v5 v5.12.0 github.com/gogo/protobuf v1.3.2 @@ -12,6 +11,8 @@ require ( github.com/onsi/gomega v1.33.1 github.com/pulumi/pulumi/sdk/v3 v3.127.1-0.20240801092425-22d28187db0a github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 + go.uber.org/mock v0.4.0 go.uber.org/zap v1.27.0 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 @@ -25,7 +26,6 @@ replace github.com/opencontainers/go-digest => github.com/opencontainers/go-dige require ( dario.cat/mergo v1.0.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect @@ -33,7 +33,6 @@ require ( github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect github.com/charmbracelet/bubbles v0.16.1 // indirect github.com/charmbracelet/bubbletea v0.25.0 // indirect github.com/charmbracelet/lipgloss v0.7.1 // indirect @@ -43,10 +42,9 @@ require ( github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/djherbis/times v1.5.0 // indirect + github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fluxcd/pkg/ssh v0.13.0 // indirect github.com/fluxcd/pkg/tar v0.7.0 // indirect - github.com/fluxcd/pkg/version v0.4.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect diff --git a/agent/go.sum b/agent/go.sum index 3a25496d..c67c6140 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -6,8 +6,6 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= @@ -58,6 +56,7 @@ github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M= github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -67,24 +66,12 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fluxcd/gitkit v0.6.0 h1:iNg5LTx6ePo+Pl0ZwqHTAkhbUHxGVSY3YCxCdw7VIFg= -github.com/fluxcd/gitkit v0.6.0/go.mod h1:svOHuKi0fO9HoawdK4HfHAJJseZDHHjk7I3ihnCIqNo= -github.com/fluxcd/pkg/git v0.19.0 h1:zIv+GAT0ieIUpnGBVi3Bhax/qq4Rr28BW7Jv4DTt6zE= -github.com/fluxcd/pkg/git v0.19.0/go.mod h1:wkqUOSrTjtsVVk/gC6/7RxVpi9GcqAA+7O5HVJF5S14= -github.com/fluxcd/pkg/git/gogit v0.19.0 h1:SdoNAmC/HTPXniQjp609X59rCsBiA+Sdq1Hv8SnYC6I= -github.com/fluxcd/pkg/git/gogit v0.19.0/go.mod h1:8kOmrNMjq8daQTVLhp6klhuoY8+s81gydM0MozDjaHM= -github.com/fluxcd/pkg/gittestserver v0.12.0 h1:QGbIVyje9U6urSAeDw3diKb/5wdA+Cnw1YJN+3Zflaw= -github.com/fluxcd/pkg/gittestserver v0.12.0/go.mod h1:Eh82e+kzKdhpafnUwR5oCBmxqAqhF5QuCn290AFntPM= github.com/fluxcd/pkg/http/fetch v0.11.0 h1:ms9M+RlpdmM6/cJxuaw83MQGi3LIutEk2Ck+qWJEpDY= github.com/fluxcd/pkg/http/fetch v0.11.0/go.mod h1:gSSUEC8xNy+ydOYHEG0aRl8mjTpEY5U0yTksM/zCoQ4= -github.com/fluxcd/pkg/ssh v0.13.0 h1:lPU1Gst8XIz7AU2dhdqVFaaOWd54/O1LZu62vH4JB/s= -github.com/fluxcd/pkg/ssh v0.13.0/go.mod h1:J9eyirMd4s++tWG4euRRhmcthKX203GPHpzFpH++TP8= github.com/fluxcd/pkg/tar v0.7.0 h1:xdg95f4DlzMgd4m+xPRXrX4NLb8P8b5SAqB19sDOLIs= github.com/fluxcd/pkg/tar v0.7.0/go.mod h1:KLg1zMZF7sEncGA9LEsfkskbCMyLSEgrjBRXqFK++VE= github.com/fluxcd/pkg/testserver v0.7.0 h1:kNVAn+3bAF2rfR9cT6SxzgEz2o84i+o7zKY3XRKTXmk= github.com/fluxcd/pkg/testserver v0.7.0/go.mod h1:Ih5IK3Y5G3+a6c77BTqFkdPDCY1Yj1A1W5cXQqkCs9s= -github.com/fluxcd/pkg/version v0.4.0 h1:3F6oeIZ+ug/f7pALIBhcUhfURel37EPPOn7nsGfsnOg= -github.com/fluxcd/pkg/version v0.4.0/go.mod h1:izVsSDxac81qWRmpOL9qcxZYx+zAN1ajoP5SidGP6PA= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= @@ -105,8 +92,6 @@ github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= -github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -231,6 +216,7 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -251,8 +237,9 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -288,6 +275,8 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= diff --git a/operator/Makefile b/operator/Makefile index 5530b2d6..9e60aa4e 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -118,7 +118,7 @@ test: manifests generate fmt vet envtest ## Run tests. # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. test-e2e: - go test -count=1 -v --timeout=5m -v ./e2e/... + go test -count=1 -v --timeout=10m -v ./e2e/... GOLANGCI_LINT = $(shell pwd)/bin/golangci-lint GOLANGCI_LINT_VERSION ?= v1.54.2 diff --git a/operator/api/auto/v1alpha1/workspace_types.go b/operator/api/auto/v1alpha1/workspace_types.go index aaf9c40e..0fbe29e1 100644 --- a/operator/api/auto/v1alpha1/workspace_types.go +++ b/operator/api/auto/v1alpha1/workspace_types.go @@ -32,7 +32,7 @@ type SecurityProfile string const ( // SecurityProfileBaseline applies the baseline security profile. SecurityProfileBaseline SecurityProfile = "baseline" - // SecurityProfileBaseline applies the restricted security profile. + // SecurityProfileRestricted applies the restricted security profile. SecurityProfileRestricted SecurityProfile = "restricted" ) @@ -58,7 +58,7 @@ type WorkspaceSpec struct { // +optional ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` - // Repo is the git source containing the Pulumi program. + // Git is the git source containing the Pulumi program. // +optional Git *GitSource `json:"git,omitempty"` @@ -93,7 +93,7 @@ type WorkspaceSpec struct { // +optional PodTemplate *EmbeddedPodTemplateSpec `json:"podTemplate,omitempty"` - // List of stacks to . + // List of stacks this workspace manages. // +optional // +patchMergeKey=name // +patchStrategy=merge @@ -104,15 +104,45 @@ type WorkspaceSpec struct { // GitSource specifies how to fetch from a git repository directly. type GitSource struct { - // Url is the git source control repository from which we fetch the project code and configuration. - Url string `json:"url,omitempty"` - // Revision is the git revision (tag, or commit SHA) to fetch. - Revision string `json:"revision,omitempty"` - // (optional) Dir is the directory to work from in the project's source repository + // URL is the git source control repository from which we fetch the project + // code and configuration. + URL string `json:"url,omitempty"` + // Ref is the git ref (tag, branch, or commit SHA) to fetch. + Ref string `json:"ref,omitempty"` + // Dir is the directory to work from in the project's source repository // where Pulumi.yaml is located. It is used in case Pulumi.yaml is not // in the project source root. // +optional Dir string `json:"dir,omitempty"` + // Auth contains optional authentication information to use when cloning + // the repository. + // +optional + Auth *GitAuth `json:"auth,omitempty"` + // Shallow controls whether the workspace uses a shallow clone or whether + // all history is cloned. + // +optional + Shallow bool `json:"shallow,omitempty"` +} + +// GitAuth specifies git authentication configuration options. +// There are 3 different authentication options: +// - Personal access token +// - SSH private key (and its optional password) +// - Basic auth username and password +// +// Only 1 authentication mode is valid. +type GitAuth struct { + // SSHPrivateKey should contain a private key for access to the git repo. + // When using `SSHPrivateKey`, the URL of the repository must be in the + // format git@github.com:org/repository.git. + SSHPrivateKey *corev1.SecretKeySelector `json:"sshPrivateKey,omitempty"` + // Username is the username to use when authenticating to a git repository. + Username *corev1.SecretKeySelector `json:"username,omitempty"` + // The password that pairs with a username or as part of an SSH Private Key. + Password *corev1.SecretKeySelector `json:"password,omitempty"` + // Token is a Git personal access token in replacement of + // your password. + Token *corev1.SecretKeySelector `json:"token,omitempty"` } // FluxSource specifies how to fetch a Fllux source artifact. diff --git a/operator/api/auto/v1alpha1/zz_generated.deepcopy.go b/operator/api/auto/v1alpha1/zz_generated.deepcopy.go index f95e0909..f2111b45 100644 --- a/operator/api/auto/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/auto/v1alpha1/zz_generated.deepcopy.go @@ -162,9 +162,49 @@ func (in *FluxSource) DeepCopy() *FluxSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitAuth) DeepCopyInto(out *GitAuth) { + *out = *in + if in.SSHPrivateKey != nil { + in, out := &in.SSHPrivateKey, &out.SSHPrivateKey + *out = new(corev1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + if in.Username != nil { + in, out := &in.Username, &out.Username + *out = new(corev1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(corev1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + if in.Token != nil { + in, out := &in.Token, &out.Token + *out = new(corev1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitAuth. +func (in *GitAuth) DeepCopy() *GitAuth { + if in == nil { + return nil + } + out := new(GitAuth) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitSource) DeepCopyInto(out *GitSource) { *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(GitAuth) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitSource. @@ -385,7 +425,7 @@ func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { if in.Git != nil { in, out := &in.Git, &out.Git *out = new(GitSource) - **out = **in + (*in).DeepCopyInto(*out) } if in.Flux != nil { in, out := &in.Flux, &out.Flux diff --git a/operator/api/pulumi/shared/stack_types.go b/operator/api/pulumi/shared/stack_types.go index 023e6535..13239e10 100644 --- a/operator/api/pulumi/shared/stack_types.go +++ b/operator/api/pulumi/shared/stack_types.go @@ -173,6 +173,9 @@ type GitSource struct { // When specified, the operator will periodically poll to check if the branch has any new commits. // The frequency of the polling is configurable through ResyncFrequencySeconds, defaulting to every 60 seconds. Branch string `json:"branch,omitempty"` + // Shallow controls whether the workspace uses a shallow checkout or + // whether all history is cloned. + Shallow bool `json:"shallow,omitempty"` } // PrerequisiteRef refers to another stack, and gives requirements for the prerequisite to be diff --git a/operator/config/crd/bases/auto.pulumi.com_workspaces.yaml b/operator/config/crd/bases/auto.pulumi.com_workspaces.yaml index 5f8f4287..b9535bb6 100644 --- a/operator/config/crd/bases/auto.pulumi.com_workspaces.yaml +++ b/operator/config/crd/bases/auto.pulumi.com_workspaces.yaml @@ -247,21 +247,144 @@ spec: type: string type: object git: - description: Repo is the git source containing the Pulumi program. + description: Git is the git source containing the Pulumi program. properties: + auth: + description: |- + Auth contains optional authentication information to use when cloning + the repository. + properties: + password: + description: The password that pairs with a username or as + part of an SSH Private Key. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + sshPrivateKey: + description: |- + SSHPrivateKey should contain a private key for access to the git repo. + When using `SSHPrivateKey`, the URL of the repository must be in the + format git@github.com:org/repository.git. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + token: + description: |- + Token is a Git personal access token in replacement of + your password. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + username: + description: Username is the username to use when authenticating + to a git repository. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object dir: description: |- - (optional) Dir is the directory to work from in the project's source repository + Dir is the directory to work from in the project's source repository where Pulumi.yaml is located. It is used in case Pulumi.yaml is not in the project source root. type: string - revision: - description: Revision is the git revision (tag, or commit SHA) - to fetch. + ref: + description: Ref is the git ref (tag, branch, or commit SHA) to + fetch. type: string + shallow: + description: |- + Shallow controls whether the workspace uses a shallow clone or whether + all history is cloned. + type: boolean url: - description: Url is the git source control repository from which - we fetch the project code and configuration. + description: |- + URL is the git source control repository from which we fetch the project + code and configuration. type: string type: object image: @@ -8296,7 +8419,7 @@ spec: identity of the workspace. type: string stacks: - description: List of stacks to . + description: List of stacks this workspace manages. items: properties: config: diff --git a/operator/config/crd/bases/pulumi.com_stacks.yaml b/operator/config/crd/bases/pulumi.com_stacks.yaml index 173b00ce..1c44824d 100644 --- a/operator/config/crd/bases/pulumi.com_stacks.yaml +++ b/operator/config/crd/bases/pulumi.com_stacks.yaml @@ -719,6 +719,11 @@ spec: (optional) SecretRefs is the secret configuration for this stack which can be specified through ResourceRef. If this is omitted, secrets configuration is assumed to be checked in and taken from the source repository. type: object + shallow: + description: |- + Shallow controls whether the workspace uses a shallow checkout or + whether all history is cloned. + type: boolean stack: description: Stack is the fully qualified name of the stack to deploy (/). @@ -976,22 +981,144 @@ spec: type: string type: object git: - description: Repo is the git source containing the Pulumi - program. + description: Git is the git source containing the Pulumi program. properties: + auth: + description: |- + Auth contains optional authentication information to use when cloning + the repository. + properties: + password: + description: The password that pairs with a username + or as part of an SSH Private Key. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + sshPrivateKey: + description: |- + SSHPrivateKey should contain a private key for access to the git repo. + When using `SSHPrivateKey`, the URL of the repository must be in the + format git@github.com:org/repository.git. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + token: + description: |- + Token is a Git personal access token in replacement of + your password. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + username: + description: Username is the username to use when + authenticating to a git repository. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object dir: description: |- - (optional) Dir is the directory to work from in the project's source repository + Dir is the directory to work from in the project's source repository where Pulumi.yaml is located. It is used in case Pulumi.yaml is not in the project source root. type: string - revision: - description: Revision is the git revision (tag, or commit + ref: + description: Ref is the git ref (tag, branch, or commit SHA) to fetch. type: string + shallow: + description: |- + Shallow controls whether the workspace uses a shallow clone or whether + all history is cloned. + type: boolean url: - description: Url is the git source control repository - from which we fetch the project code and configuration. + description: |- + URL is the git source control repository from which we fetch the project + code and configuration. type: string type: object image: @@ -9214,7 +9341,7 @@ spec: account identity of the workspace. type: string stacks: - description: List of stacks to . + description: List of stacks this workspace manages. items: properties: config: @@ -10127,6 +10254,11 @@ spec: (optional) SecretRefs is the secret configuration for this stack which can be specified through ResourceRef. If this is omitted, secrets configuration is assumed to be checked in and taken from the source repository. type: object + shallow: + description: |- + Shallow controls whether the workspace uses a shallow checkout or + whether all history is cloned. + type: boolean stack: description: Stack is the fully qualified name of the stack to deploy (/). @@ -10384,22 +10516,144 @@ spec: type: string type: object git: - description: Repo is the git source containing the Pulumi - program. + description: Git is the git source containing the Pulumi program. properties: + auth: + description: |- + Auth contains optional authentication information to use when cloning + the repository. + properties: + password: + description: The password that pairs with a username + or as part of an SSH Private Key. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + sshPrivateKey: + description: |- + SSHPrivateKey should contain a private key for access to the git repo. + When using `SSHPrivateKey`, the URL of the repository must be in the + format git@github.com:org/repository.git. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + token: + description: |- + Token is a Git personal access token in replacement of + your password. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + username: + description: Username is the username to use when + authenticating to a git repository. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object dir: description: |- - (optional) Dir is the directory to work from in the project's source repository + Dir is the directory to work from in the project's source repository where Pulumi.yaml is located. It is used in case Pulumi.yaml is not in the project source root. type: string - revision: - description: Revision is the git revision (tag, or commit + ref: + description: Ref is the git ref (tag, branch, or commit SHA) to fetch. type: string + shallow: + description: |- + Shallow controls whether the workspace uses a shallow clone or whether + all history is cloned. + type: boolean url: - description: Url is the git source control repository - from which we fetch the project code and configuration. + description: |- + URL is the git source control repository from which we fetch the project + code and configuration. type: string type: object image: @@ -18622,7 +18876,7 @@ spec: account identity of the workspace. type: string stacks: - description: List of stacks to . + description: List of stacks this workspace manages. items: properties: config: diff --git a/operator/config/rbac/role.yaml b/operator/config/rbac/role.yaml index 9d4d68f5..ec5ba1d9 100644 --- a/operator/config/rbac/role.yaml +++ b/operator/config/rbac/role.yaml @@ -4,6 +4,14 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/operator/e2e/e2e_test.go b/operator/e2e/e2e_test.go index db717e4c..01191d84 100644 --- a/operator/e2e/e2e_test.go +++ b/operator/e2e/e2e_test.go @@ -25,6 +25,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -47,6 +48,8 @@ func TestE2E(t *testing.T) { err := loadImageToKindClusterWithName(projectimage) require.NoError(t, err, "failed to load image into kind") + err = loadImageToKindClusterWithName("pulumi/pulumi:3.130.0-nonroot") + require.NoError(t, err, "failed to load image into kind") cmd = exec.Command("make", "install") require.NoError(t, run(cmd), "failed to install CRDs") @@ -69,8 +72,7 @@ func TestE2E(t *testing.T) { { name: "random-yaml-nonroot", f: func(t *testing.T) { - err := loadImageToKindClusterWithName("pulumi/pulumi:3.130.0-nonroot") - require.NoError(t, err, "failed to load image into kind") + t.Parallel() cmd := exec.Command("kubectl", "apply", "-f", "e2e/testdata/random-yaml-nonroot") require.NoError(t, run(cmd)) @@ -79,6 +81,25 @@ func TestE2E(t *testing.T) { // to GitHub rate limiting and "resource modified" retries. }, }, + { + name: "git-auth-nonroot", + f: func(t *testing.T) { + t.Parallel() + if os.Getenv("PULUMI_BOT_TOKEN") == "" { + t.Skip("missing PULUMI_BOT_TOKEN") + } + + cmd := exec.Command("bash", "-c", "envsubst < e2e/testdata/git-auth-nonroot/* | kubectl apply -f -") + require.NoError(t, run(cmd)) + t.Cleanup(func() { + _ = run(exec.Command("kubectl", "delete", "-f", "e2e/testdata/git-auth-nonroot")) + }) + + cmd = exec.Command("kubectl", "wait", "stacks/git-auth-nonroot", + "--for", "condition=Ready", "-n", "git-auth-nonroot", "--timeout", "300s") + assert.NoError(t, run(cmd), "stack didn't reconcile within 5 minutes") + }, + }, } for _, tt := range tests { diff --git a/operator/e2e/testdata/git-auth-nonroot/manifests.yaml b/operator/e2e/testdata/git-auth-nonroot/manifests.yaml new file mode 100644 index 00000000..ac6c89b3 --- /dev/null +++ b/operator/e2e/testdata/git-auth-nonroot/manifests.yaml @@ -0,0 +1,68 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: git-auth-nonroot +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: state + namespace: git-auth-nonroot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +--- +apiVersion: v1 +kind: Secret +metadata: + name: git-auth + namespace: git-auth-nonroot +stringData: + accessToken: $PULUMI_BOT_TOKEN +--- +apiVersion: pulumi.com/v1 +kind: Stack +metadata: + name: git-auth-nonroot + namespace: git-auth-nonroot +spec: + projectRepo: https://github.com/pulumi/fixtures + branch: main + shallow: true + repoDir: resources + gitAuthSecret: git-auth + + stack: dev + refresh: false + continueResyncOnCommitMatch: false + resyncFrequencySeconds: 60 + destroyOnFinalize: true + + # Enable file state for testing. + envRefs: + PULUMI_BACKEND_URL: + type: Literal + literal: + value: "file:///state/" + PULUMI_CONFIG_PASSPHRASE: + type: Literal + literal: + value: "test" + workspaceTemplate: + spec: + image: pulumi/pulumi:3.130.0-nonroot + podTemplate: + spec: + containers: + - name: pulumi + volumeMounts: + - name: state + mountPath: /state + volumes: + - name: state + persistentVolumeClaim: + claimName: state diff --git a/operator/e2e/testdata/random-yaml-nonroot/manifests.yaml b/operator/e2e/testdata/random-yaml-nonroot/manifests.yaml index 806fae3a..5fd1d1f7 100644 --- a/operator/e2e/testdata/random-yaml-nonroot/manifests.yaml +++ b/operator/e2e/testdata/random-yaml-nonroot/manifests.yaml @@ -31,7 +31,7 @@ spec: apiVersion: pulumi.com/v1 kind: Stack metadata: - name: resources + name: random-yaml-nonroot namespace: random-yaml-nonroot spec: fluxSource: diff --git a/operator/internal/controller/auto/workspace_controller.go b/operator/internal/controller/auto/workspace_controller.go index 80f4e762..8bcb4b3e 100644 --- a/operator/internal/controller/auto/workspace_controller.go +++ b/operator/internal/controller/auto/workspace_controller.go @@ -113,9 +113,16 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } if w.Spec.Git != nil { source.Git = &gitSource{ - Url: w.Spec.Git.Url, - Dir: w.Spec.Git.Dir, - Revision: w.Spec.Git.Revision, + URL: w.Spec.Git.URL, + Dir: w.Spec.Git.Dir, + Ref: w.Spec.Git.Ref, + Shallow: w.Spec.Git.Shallow, + } + if w.Spec.Git.Auth != nil { + source.Git.Password = w.Spec.Git.Auth.Password + source.Git.SSHPrivateKey = w.Spec.Git.Auth.SSHPrivateKey + source.Git.Token = w.Spec.Git.Auth.Token + source.Git.Username = w.Spec.Git.Auth.Username } } if w.Spec.Flux != nil { @@ -271,7 +278,7 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } } - // set the "initalized" annotation + // set the "initialized" annotation if pod.Annotations == nil { pod.Annotations = make(map[string]string) } @@ -435,6 +442,59 @@ func newStatefulSet(ctx context.Context, w *autov1alpha1.Workspace, source *sour /share/agent init -t /share/source --git-url $GIT_URL --git-revision $GIT_REVISION && ln -s /share/source/$GIT_DIR /share/workspace ` + env := []corev1.EnvVar{ + { + Name: "GIT_URL", + Value: source.Git.URL, + }, + { + Name: "GIT_REVISION", + Value: source.Git.Ref, + }, + { + Name: "GIT_DIR", + Value: source.Git.Dir, + }, + } + if source.Git.Shallow { + env = append(env, corev1.EnvVar{ + Name: "GIT_SHALLOW", + Value: "true", + }) + } + if source.Git.SSHPrivateKey != nil { + env = append(env, corev1.EnvVar{ + Name: "GIT_SSH_PRIVATE_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: source.Git.SSHPrivateKey, + }, + }) + } + if source.Git.Username != nil { + env = append(env, corev1.EnvVar{ + Name: "GIT_USERNAME", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: source.Git.Username, + }, + }) + } + if source.Git.Password != nil { + env = append(env, corev1.EnvVar{ + Name: "GIT_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: source.Git.Password, + }, + }) + } + if source.Git.Token != nil { + env = append(env, corev1.EnvVar{ + Name: "GIT_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: source.Git.Token, + }, + }) + } + container := corev1.Container{ Name: "fetch", Image: workspaceAgentImage, @@ -445,20 +505,7 @@ ln -s /share/source/$GIT_DIR /share/workspace MountPath: WorkspaceShareMountPath, }, }, - Env: []corev1.EnvVar{ - { - Name: "GIT_URL", - Value: source.Git.Url, - }, - { - Name: "GIT_REVISION", - Value: source.Git.Revision, - }, - { - Name: "GIT_DIR", - Value: source.Git.Dir, - }, - }, + Env: env, Command: []string{"sh", "-c", script}, } statefulset.Spec.Template.Spec.InitContainers = append(statefulset.Spec.Template.Spec.InitContainers, container) @@ -577,9 +624,14 @@ type sourceSpec struct { } type gitSource struct { - Url string - Revision string - Dir string + URL string + Ref string + Dir string + Shallow bool + SSHPrivateKey *corev1.SecretKeySelector + Username *corev1.SecretKeySelector + Password *corev1.SecretKeySelector + Token *corev1.SecretKeySelector } type fluxSource struct { diff --git a/operator/internal/controller/auto/workspace_controller_test.go b/operator/internal/controller/auto/workspace_controller_test.go index c4ce145e..50449efd 100644 --- a/operator/internal/controller/auto/workspace_controller_test.go +++ b/operator/internal/controller/auto/workspace_controller_test.go @@ -45,9 +45,9 @@ const ( var ( TestGitSource = &autov1alpha1.GitSource{ - Url: "https://github.com/pulumi/examples.git", - Revision: "1e2fc471709448f3c9f7a250f28f1eafcde7017b", - Dir: "random-yaml", + URL: "https://github.com/pulumi/examples.git", + Ref: "1e2fc471709448f3c9f7a250f28f1eafcde7017b", + Dir: "random-yaml", } TestFluxSource = &autov1alpha1.FluxSource{ Url: "http://source-controller.flux-system.svc.cluster.local./gitrepository/default/pulumi-examples/1e2fc471709448f3c9f7a250f28f1eafcde7017b.tar.gz", @@ -92,7 +92,6 @@ var _ = Describe("Workspace Controller", func() { }) reconcileF := func(ctx context.Context) (result reconcile.Result, err error) { - result, err = r.Reconcile(ctx, reconcile.Request{NamespacedName: objName}) // refresh the object and find its status condition(s) @@ -284,8 +283,8 @@ var _ = Describe("Workspace Controller", func() { fetch := findContainer(ss.Spec.Template.Spec.InitContainers, "fetch") Expect(fetch).NotTo(BeNil()) Expect(fetch.Env).To(ConsistOf( - corev1.EnvVar{Name: "GIT_URL", Value: TestGitSource.Url}, - corev1.EnvVar{Name: "GIT_REVISION", Value: TestGitSource.Revision}, + corev1.EnvVar{Name: "GIT_URL", Value: TestGitSource.URL}, + corev1.EnvVar{Name: "GIT_REVISION", Value: TestGitSource.Ref}, corev1.EnvVar{Name: "GIT_DIR", Value: TestGitSource.Dir}, )) }) diff --git a/operator/internal/controller/pulumi/git.go b/operator/internal/controller/pulumi/git.go index 8902e5e5..5b12d75b 100644 --- a/operator/internal/controller/pulumi/git.go +++ b/operator/internal/controller/pulumi/git.go @@ -1,4 +1,4 @@ -// Copyright 2016-2020, Pulumi Corporation. +// Copyright 2024, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,318 +18,314 @@ import ( "context" "errors" "fmt" - "path/filepath" - "strings" - "sync" git "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/go-git/go-git/v5/storage/memory" + autov1alpha1 "github.com/pulumi/pulumi-kubernetes-operator/operator/api/auto/v1alpha1" + "github.com/pulumi/pulumi-kubernetes-operator/operator/api/pulumi/shared" "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/gitutil" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ) -// func (sess *StackReconcilerSession) SetupWorkspaceFromGitSource(ctx context.Context, gitAuth *auto.GitAuth, source *shared.GitSource) (string, error) { -// repo := auto.GitRepo{ -// URL: source.ProjectRepo, -// ProjectPath: source.RepoDir, -// CommitHash: source.Commit, -// Branch: source.Branch, -// Auth: gitAuth, -// } -// homeDir := sess.getPulumiHome() -// workspaceDir := sess.getWorkspaceDir() - -// sess.logger.Debug("Setting up pulumi workspace for stack", "stack", sess.stack, "workspace", workspaceDir) -// // Create a new workspace. - -// secretsProvider := auto.SecretsProvider(sess.stack.SecretsProvider) - -// w, err := auto.NewLocalWorkspace( -// ctx, -// auto.PulumiHome(homeDir), -// auto.WorkDir(workspaceDir), -// auto.Repo(repo), -// secretsProvider) -// if err != nil { -// return "", fmt.Errorf("failed to create local workspace: %w", err) -// } - -// revision, err := revisionAtWorkingDir(w.WorkDir()) -// if err != nil { -// return "", err -// } - -// return revision, sess.setupWorkspace(ctx, w) -// } - -// SetupGitAuth sets up the authentication option to use for the git source -// repository of the stack. If neither gitAuth or gitAuthSecret are set, -// a pointer to a zero value of GitAuth is returned — representing -// unauthenticated git access. -func (sess *StackReconcilerSession) SetupGitAuth(ctx context.Context) (*auto.GitAuth, error) { - return nil, nil - // gitAuth := &auto.GitAuth{} - - // // check that the URL is valid (and we'll use it later to check we got appropriate auth) - // u, err := giturls.Parse(sess.stack.ProjectRepo) - // if err != nil { - // return gitAuth, err - // } - - // if sess.stack.GitAuth != nil { - - // if sess.stack.GitAuth.SSHAuth != nil { - // privateKey, err := sess.resolveResourceRef(ctx, &sess.stack.GitAuth.SSHAuth.SSHPrivateKey) - // if err != nil { - // return nil, fmt.Errorf("resolving gitAuth SSH private key: %w", err) - // } - // gitAuth.SSHPrivateKey = privateKey - - // if sess.stack.GitAuth.SSHAuth.Password != nil { - // password, err := sess.resolveResourceRef(ctx, sess.stack.GitAuth.SSHAuth.Password) - // if err != nil { - // return nil, fmt.Errorf("resolving gitAuth SSH password: %w", err) - // } - // gitAuth.Password = password - // } - - // return gitAuth, nil - // } - - // if sess.stack.GitAuth.PersonalAccessToken != nil { - // accessToken, err := sess.resolveResourceRef(ctx, sess.stack.GitAuth.PersonalAccessToken) - // if err != nil { - // return nil, fmt.Errorf("resolving gitAuth personal access token: %w", err) - // } - // gitAuth.PersonalAccessToken = accessToken - // return gitAuth, nil - // } - - // if sess.stack.GitAuth.BasicAuth == nil { - // return nil, errors.New("gitAuth config must specify exactly one of " + - // "'personalAccessToken', 'sshPrivateKey' or 'basicAuth'") - // } - - // userName, err := sess.resolveResourceRef(ctx, &sess.stack.GitAuth.BasicAuth.UserName) - // if err != nil { - // return nil, fmt.Errorf("resolving gitAuth username: %w", err) - // } - - // password, err := sess.resolveResourceRef(ctx, &sess.stack.GitAuth.BasicAuth.Password) - // if err != nil { - // return nil, fmt.Errorf("resolving gitAuth password: %w", err) - // } - - // gitAuth.Username = userName - // gitAuth.Password = password - // } else if sess.stack.GitAuthSecret != "" { - // namespacedName := types.NamespacedName{Name: sess.stack.GitAuthSecret, Namespace: sess.namespace} - - // // Fetch the named secret. - // secret := &corev1.Secret{} - // if err := sess.kubeClient.Get(ctx, namespacedName, secret); err != nil { - // sess.logger.Error(err, "Could not find secret for access to the git repository", - // "Namespace", sess.namespace, "Stack.GitAuthSecret", sess.stack.GitAuthSecret) - // return nil, err - // } - - // // First check if an SSH private key has been specified. - // if sshPrivateKey, exists := secret.Data["sshPrivateKey"]; exists { - // gitAuth = &auto.GitAuth{ - // SSHPrivateKey: string(sshPrivateKey), - // } - - // if password, exists := secret.Data["password"]; exists { - // gitAuth.Password = string(password) - // } - // // Then check if a personal access token has been specified. - // } else if accessToken, exists := secret.Data["accessToken"]; exists { - // gitAuth = &auto.GitAuth{ - // PersonalAccessToken: string(accessToken), - // } - // // Then check if basic authentication has been specified. - // } else if username, exists := secret.Data["username"]; exists { - // if password, exists := secret.Data["password"]; exists { - // gitAuth = &auto.GitAuth{ - // Username: string(username), - // Password: string(password), - // } - // } else { - // return nil, errors.New("creating gitAuth: missing 'password' secret entry") - // } - // } - // } - - // if u.Scheme == "ssh" && gitAuth.SSHPrivateKey == "" { - // return gitAuth, fmt.Errorf("a private key must be provided for SSH") - // } - - // return gitAuth, nil +// Source represents a source of commits. +type Source interface { + CurrentCommit(context.Context) (string, error) +} + +// NewGitSource creates a new Git source. URL is the https location of the +// repository. Ref is typically the branch to fetch. +func NewGitSource(gs shared.GitSource, auth *auto.GitAuth) (Source, error) { + if gs.ProjectRepo == "" { + return nil, fmt.Errorf(`missing "projectRepo"`) + } + if gs.Commit == "" && gs.Branch == "" { + return nil, fmt.Errorf(`missing "commit" or "branch"`) + } + if gs.Commit != "" && gs.Branch != "" { + return nil, fmt.Errorf(`only one of "commit" or "branch" should be specified`) + } + + ref := gs.Commit + if gs.Branch != "" { + ref = gs.Branch + } + url := gs.ProjectRepo + return newGitSource(url, ref, auth) } -var transportMutex sync.Mutex +func newGitSource(rawURL string, ref string, auth *auto.GitAuth) (Source, error) { + url, _, err := gitutil.ParseGitRepoURL(rawURL) + if err != nil { + return nil, fmt.Errorf("parsing %q: %w", rawURL, err) + } + + fs := memory.NewStorage() + remote := git.NewRemote(fs, &config.RemoteConfig{ + Name: "origin", + URLs: []string{url}, + }) + + return &gitSource{ + fs: fs, + ref: ref, + remote: remote, + auth: auth, + }, nil +} + +type gitSource struct { + fs *memory.Storage + ref string + remote *git.Remote + auth *auto.GitAuth +} -func setupGitRepo(ctx context.Context, workDir string, repoArgs *auto.GitRepo) (string, error) { - cloneOptions := &git.CloneOptions{ - RemoteName: "origin", // be explicit so we can require it in remote refs - URL: repoArgs.URL, +func (gs gitSource) CurrentCommit(ctx context.Context) (string, error) { + // If our ref is already a commit then use it directly. + if plumbing.IsHash(gs.ref) { + return gs.ref, nil } - if repoArgs.Shallow { - cloneOptions.Depth = 1 - cloneOptions.SingleBranch = true + // Otherwise fetch the most recent commit for the ref (branch) we care + // about. + auth, err := gs.authMethod() + if err != nil { + return "", fmt.Errorf("getting auth method: %w", err) } - if repoArgs.Auth != nil { - authDetails := repoArgs.Auth - // Each of the authentication options are mutually exclusive so let's check that only 1 is specified - if authDetails.SSHPrivateKeyPath != "" && authDetails.Username != "" || - authDetails.PersonalAccessToken != "" && authDetails.Username != "" || - authDetails.PersonalAccessToken != "" && authDetails.SSHPrivateKeyPath != "" || - authDetails.Username != "" && authDetails.SSHPrivateKey != "" { - return "", errors.New("please specify one authentication option of `Personal Access Token`, " + - "`Username\\Password`, `SSH Private Key Path` or `SSH Private Key`") + refs, err := gs.remote.ListContext(ctx, &git.ListOptions{ + Auth: auth, + }) + if err != nil { + return "", fmt.Errorf("listing: %w", err) + } + for _, r := range refs { + if r.Name().String() == gs.ref || r.Name().Short() == gs.ref { + return r.Hash().String(), nil } + } - // Firstly we will try to check that an SSH Private Key Path has been specified - if authDetails.SSHPrivateKeyPath != "" { - publicKeys, err := ssh.NewPublicKeysFromFile("git", repoArgs.Auth.SSHPrivateKeyPath, repoArgs.Auth.Password) - if err != nil { - return "", fmt.Errorf("unable to use SSH Private Key Path: %w", err) - } + return "", fmt.Errorf("no commits found for ref %q", gs.ref) +} - cloneOptions.Auth = publicKeys - } +// authMethod translates auto.GitAuth into a go-git transport.AuthMethod. +func (gs gitSource) authMethod() (transport.AuthMethod, error) { + if gs.auth == nil { + return nil, nil + } - // Then we check if the details of a SSH Private Key as passed - if authDetails.SSHPrivateKey != "" { - publicKeys, err := ssh.NewPublicKeys("git", []byte(repoArgs.Auth.SSHPrivateKey), repoArgs.Auth.Password) - if err != nil { - return "", fmt.Errorf("unable to use SSH Private Key: %w", err) - } + if gs.auth.Username != "" { + return &http.BasicAuth{Username: gs.auth.Username, Password: gs.auth.Password}, nil + } - cloneOptions.Auth = publicKeys - } + if gs.auth.PersonalAccessToken != "" { + return &http.BasicAuth{Username: "git", Password: gs.auth.PersonalAccessToken}, nil + } - // Then we check to see if a Personal Access Token has been specified - // the username for use with a PAT can be *anything* but an empty string - // so we are setting this to `git` - if authDetails.PersonalAccessToken != "" { - cloneOptions.Auth = &http.BasicAuth{ - Username: "git", - Password: repoArgs.Auth.PersonalAccessToken, - } + if gs.auth.SSHPrivateKey != "" { + return ssh.NewPublicKeys("git", []byte(gs.auth.SSHPrivateKey), gs.auth.Password) + } + + return nil, nil +} + +// resolveGitAuth sets up the authentication option to use for the git source +// repository of the stack. If neither gitAuth or gitAuthSecret are set, +// a pointer to a zero value of GitAuth is returned — representing +// unauthenticated git access. +// +// The workspace pod is also mutated to mount these references at some +// well-known paths. +func (sess *StackReconcilerSession) resolveGitAuth(ctx context.Context) (*auto.GitAuth, error) { + auth := &auto.GitAuth{} + + if sess.stack.GitSource == nil { + return auth, nil // No git source to auth. + } + + if sess.stack.GitAuthSecret == "" && sess.stack.GitAuth == nil { + return auth, nil // No authentication. + } + + if sess.stack.GitAuthSecret != "" { + namespacedName := types.NamespacedName{Name: sess.stack.GitAuthSecret, Namespace: sess.namespace} + + // Fetch the named secret. + secret := &corev1.Secret{} + if err := sess.kubeClient.Get(ctx, namespacedName, secret); err != nil { + sess.logger.Error(err, "Could not find secret for access to the git repository", + "Namespace", sess.namespace, "Stack.GitAuthSecret", sess.stack.GitAuthSecret) + return nil, err } - // then we check to see if a username and a password has been specified - if authDetails.Password != "" && authDetails.Username != "" { - cloneOptions.Auth = &http.BasicAuth{ - Username: repoArgs.Auth.Username, - Password: repoArgs.Auth.Password, + // First check if an SSH private key has been specified. + if sshPrivateKey, exists := secret.Data["sshPrivateKey"]; exists { + auth.SSHPrivateKey = string(sshPrivateKey) + + if password, exists := secret.Data["password"]; exists { + auth.Password = string(password) + } + // Then check if a personal access token has been specified. + } else if accessToken, exists := secret.Data["accessToken"]; exists { + auth.PersonalAccessToken = string(accessToken) + // Then check if basic authentication has been specified. + } else if username, exists := secret.Data["username"]; exists { + if password, exists := secret.Data["password"]; exists { + auth.Username = string(username) + auth.Password = string(password) + } else { + return nil, fmt.Errorf(`creating gitAuth: no key "password" found in secret %s`, namespacedName) } } + + return auth, nil } - // *Repository.Clone() will do appropriate fetching given a branch name. We must deal with - // different varieties, since people have been advised to use these as a workaround while only - // "refs/heads/" worked. - // - // If a reference name is not supplied, then .Clone will fetch all refs (and all objects - // referenced by those), and checking out a commit later will work as expected. - if repoArgs.Branch != "" { - refName := plumbing.ReferenceName(repoArgs.Branch) - switch { - case refName.IsRemote(): // e.g., refs/remotes/origin/branch - shorter := refName.Short() // this gives "origin/branch" - parts := strings.SplitN(shorter, "/", 2) - if len(parts) == 2 && parts[0] == "origin" { - refName = plumbing.NewBranchReferenceName(parts[1]) - } else { - return "", fmt.Errorf("a remote ref must begin with 'refs/remote/origin/', but got %q", repoArgs.Branch) + stackAuth := sess.stack.GitAuth + if stackAuth.SSHAuth != nil { + privateKey, err := sess.resolveSecretResourceRef(ctx, &stackAuth.SSHAuth.SSHPrivateKey) + if err != nil { + return auth, fmt.Errorf("resolving gitAuth SSH private key: %w", err) + } + auth.SSHPrivateKey = privateKey + + if stackAuth.SSHAuth.Password != nil { + password, err := sess.resolveSecretResourceRef(ctx, stackAuth.SSHAuth.Password) + if err != nil { + return auth, fmt.Errorf("resolving gitAuth SSH password: %w", err) } - case refName.IsTag(): // looks like `refs/tags/v1.0.0` -- respect this even though the field is `.Branch` - // nothing to do - case !refName.IsBranch(): // not a remote, not refs/heads/branch; treat as a simple branch name - refName = plumbing.NewBranchReferenceName(repoArgs.Branch) - default: - // already looks like a full branch name, so use as is + auth.Password = password } - cloneOptions.ReferenceName = refName + + return auth, nil } - // Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't implement. - // But: it's possible to do a full clone by saying it's _not_ _un_supported, in which case the library - // happily functions so long as it doesn't _actually_ get a multi_ack packet. See - // https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go. - repo, err := func() (*git.Repository, error) { - // Because transport.UnsupportedCapabilities is a global variable, we need a global lock around the - // use of this. - transportMutex.Lock() - defer transportMutex.Unlock() - - oldUnsupportedCaps := transport.UnsupportedCapabilities - // This check is crude, but avoids having another dependency to parse the git URL. - if strings.Contains(repoArgs.URL, "dev.azure.com") { - transport.UnsupportedCapabilities = []capability.Capability{ - capability.ThinPack, - } + if stackAuth.PersonalAccessToken != nil { + accessToken, err := sess.resolveSecretResourceRef(ctx, sess.stack.GitAuth.PersonalAccessToken) + if err != nil { + return auth, fmt.Errorf("resolving gitAuth personal access token: %w", err) } + auth.PersonalAccessToken = accessToken + return auth, nil + } - // clone - repo, err := git.PlainCloneContext(ctx, workDir, false, cloneOptions) + if stackAuth.BasicAuth == nil { + return auth, errors.New("gitAuth config must specify exactly one of " + + "'personalAccessToken', 'sshPrivateKey' or 'basicAuth'") + } - // Regardless of error we need to restore the UnsupportedCapabilities - transport.UnsupportedCapabilities = oldUnsupportedCaps - return repo, err - }() + username, err := sess.resolveSecretResourceRef(ctx, &sess.stack.GitAuth.BasicAuth.UserName) if err != nil { - return "", fmt.Errorf("unable to clone repo: %w", err) + return auth, fmt.Errorf("resolving gitAuth username: %w", err) } - if repoArgs.CommitHash != "" { - // ensure that the commit has been fetched - err := func() error { - // repo.FetchContext ends up looking at the global transport.UnsupportedCapabilities, so we need a - // global lock around the use of this. - transportMutex.Lock() - defer transportMutex.Unlock() - return repo.FetchContext(ctx, &git.FetchOptions{ - RemoteName: "origin", - Auth: cloneOptions.Auth, - Depth: cloneOptions.Depth, - RefSpecs: []config.RefSpec{config.RefSpec(repoArgs.CommitHash + ":" + repoArgs.CommitHash)}, - }) - }() - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) && !errors.Is(err, git.ErrExactSHA1NotSupported) { - return "", fmt.Errorf("fetching commit: %w", err) - } + password, err := sess.resolveSecretResourceRef(ctx, &sess.stack.GitAuth.BasicAuth.Password) + if err != nil { + return auth, fmt.Errorf("resolving gitAuth password: %w", err) + } - // checkout commit if specified - w, err := repo.Worktree() - if err != nil { - return "", err - } + auth.Username = username + auth.Password = password + return auth, nil +} - hash := repoArgs.CommitHash - err = w.Checkout(&git.CheckoutOptions{ - Hash: plumbing.NewHash(hash), - Force: true, - }) - if err != nil { - return "", fmt.Errorf("unable to checkout commit: %w", err) +func (sess *StackReconcilerSession) setupWorkspaceFromGitSource(ctx context.Context, commit string) error { + gs := sess.stack.GitSource + if gs == nil { + return fmt.Errorf("missing gitSource") + } + + sess.ws.Spec.Git = &autov1alpha1.GitSource{ + Ref: commit, + URL: gs.ProjectRepo, + Dir: gs.RepoDir, + Shallow: gs.Shallow, + } + auth := &autov1alpha1.GitAuth{} + + if sess.stack.GitAuthSecret != "" { + auth.SSHPrivateKey = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: sess.stack.GitAuthSecret, + }, + Key: "sshPrivateKey", + Optional: ptr.To(true), + } + auth.Password = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: sess.stack.GitAuthSecret, + }, + Key: "password", + Optional: ptr.To(true), + } + auth.Username = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: sess.stack.GitAuthSecret, + }, + Key: "username", + Optional: ptr.To(true), + } + auth.Token = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: sess.stack.GitAuthSecret, + }, + Key: "accessToken", + Optional: ptr.To(true), } } - var relPath string - if repoArgs.ProjectPath != "" { - relPath = repoArgs.ProjectPath + if gs.GitAuth != nil { + if gs.GitAuth.SSHAuth != nil { + auth.SSHPrivateKey = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: gs.GitAuth.SSHAuth.SSHPrivateKey.SecretRef.Name, + }, + Key: gs.GitAuth.SSHAuth.SSHPrivateKey.SecretRef.Key, + } + if gs.GitAuth.SSHAuth.Password != nil { + auth.Password = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: gs.GitAuth.SSHAuth.Password.SecretRef.Name, + }, + Key: gs.GitAuth.SSHAuth.Password.SecretRef.Key, + } + } + } + if gs.GitAuth.BasicAuth != nil { + auth.Username = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: gs.GitAuth.BasicAuth.UserName.SecretRef.Name, + }, + Key: gs.GitAuth.BasicAuth.UserName.SecretRef.Key, + } + auth.Password = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: gs.GitAuth.BasicAuth.Password.SecretRef.Name, + }, + Key: gs.GitAuth.BasicAuth.Password.SecretRef.Key, + } + } + if gs.GitAuth.PersonalAccessToken != nil { + auth.Token = &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: gs.GitAuth.PersonalAccessToken.SecretRef.Name, + }, + Key: gs.GitAuth.PersonalAccessToken.SecretRef.Key, + } + } } - workDir = filepath.Join(workDir, relPath) - return workDir, nil + sess.ws.Spec.Git.Auth = auth + + return sess.setupWorkspace(ctx) } diff --git a/operator/internal/controller/pulumi/git_test.go b/operator/internal/controller/pulumi/git_test.go new file mode 100644 index 00000000..64c02077 --- /dev/null +++ b/operator/internal/controller/pulumi/git_test.go @@ -0,0 +1,134 @@ +// Copyright 2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pulumi + +import ( + "context" + "testing" + + "github.com/pulumi/pulumi-kubernetes-operator/operator/api/pulumi/shared" + "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const _pko = "https://github.com/pulumi/pulumi-kubernetes-operator.git" + +func TestValidation(t *testing.T) { + tests := []struct { + name string + source shared.GitSource + wantErr string + }{ + { + name: "missing projectRepo", + source: shared.GitSource{ + Branch: "$$$", + }, + wantErr: `missing "projectRepo"`, + }, + { + name: "missing branch and commit", + source: shared.GitSource{ + ProjectRepo: _pko, + }, + wantErr: `missing "commit" or "branch"`, + }, + { + name: "branch and commit", + source: shared.GitSource{ + ProjectRepo: _pko, + Branch: "master", + Commit: "55d7ace59a14b8ac7d4def0065040f1c31c90cd3", + }, + wantErr: `only one of "commit" or "branch"`, + }, + { + name: "invalid url", + source: shared.GitSource{ + ProjectRepo: "hhtp://github.com/pulumi", + Branch: "master", + }, + wantErr: "invalid URL scheme: hhtp", + }, + } + + for _, tt := range tests { + _, err := NewGitSource(tt.source, nil /* auth */) + assert.ErrorContains(t, err, tt.wantErr) + + } +} + +func TestCurrentCommit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source shared.GitSource + auth *auto.GitAuth + wantErr string + want string + eq assert.ValueAssertionFunc + }{ + { + name: "branch", + source: shared.GitSource{ + ProjectRepo: "https://github.com/git-fixtures/basic.git", + Branch: "master", + }, + want: "6ecf0ef2c2dffb796033e5a02219af86ec6584e5", + }, + { + name: "tag", + source: shared.GitSource{ + ProjectRepo: _pko, + Branch: "refs/tags/v1.16.0", + }, + want: "2ca775387e522fd5c29668a85bfba2f8fd791848", + }, + { + name: "commit", + source: shared.GitSource{ + ProjectRepo: _pko, + Commit: "55d7ace59a14b8ac7d4def0065040f1c31c90cd3", + }, + want: "55d7ace59a14b8ac7d4def0065040f1c31c90cd3", + }, + { + name: "non-existent ref", + source: shared.GitSource{ + ProjectRepo: _pko, + Branch: "doesntexist", + }, + wantErr: "no commits found for ref", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gs, err := NewGitSource(tt.source, nil /* auth */) + require.NoError(t, err) + + commit, err := gs.CurrentCommit(context.Background()) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.Equal(t, tt.want, commit) + }) + } +} diff --git a/operator/internal/controller/pulumi/session_test.go b/operator/internal/controller/pulumi/session_test.go index 084f8adc..665c695f 100644 --- a/operator/internal/controller/pulumi/session_test.go +++ b/operator/internal/controller/pulumi/session_test.go @@ -1,4 +1,16 @@ -// Copyright 2021, Pulumi Corporation. All rights reserved. +// Copyright 2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. package pulumi @@ -6,11 +18,10 @@ import ( "context" "errors" "fmt" - "io/ioutil" - "os" "testing" "github.com/go-logr/logr/testr" + "github.com/pulumi/pulumi-kubernetes-operator/operator/api/auto/v1alpha1" autov1alpha1 "github.com/pulumi/pulumi-kubernetes-operator/operator/api/auto/v1alpha1" "github.com/pulumi/pulumi-kubernetes-operator/operator/api/pulumi/shared" v1 "github.com/pulumi/pulumi-kubernetes-operator/operator/api/pulumi/v1" @@ -18,7 +29,6 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/auto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -32,38 +42,8 @@ const ( namespace = "test" ) -type GitAuthTestSuite struct { - suite.Suite - f string -} - -func (suite *GitAuthTestSuite) SetupTest() { - f, err := ioutil.TempFile("", "") - suite.NoError(err) - defer f.Close() - f.WriteString("super secret") - suite.f = f.Name() - os.Setenv("SECRET3", "so secret") -} - -func (suite *GitAuthTestSuite) AfterTest() { - if suite.f != "" { - os.Remove(suite.f) - } - os.Unsetenv("SECRET3") - suite.T().Log("Cleaned up") -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(GitAuthTestSuite)) -} - -func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { - t := suite.T() - t.Skip() // https://github.com/pulumi/pulumi-kubernetes-operator/pull/658 - log := testr.New(t).WithValues("Request.Test", "TestSetupGitAuthWithSecrets") - - sshPrivateKey := &corev1.Secret{ +var ( + _sshPrivateKey = &corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", APIVersion: "apps/v1", @@ -77,7 +57,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, Type: "Opaque", } - sshPrivateKeyWithPassword := &corev1.Secret{ + _sshPrivateKeyWithPassword = &corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", APIVersion: "apps/v1", @@ -92,7 +72,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, Type: "Opaque", } - accessToken := &corev1.Secret{ + _accessToken = &corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", APIVersion: "apps/v1", @@ -106,7 +86,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, Type: "Opaque", } - basicAuth := &corev1.Secret{ + _basicAuth = &corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", APIVersion: "apps/v1", @@ -121,7 +101,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, Type: "Opaque", } - basicAuthWithoutPassword := &corev1.Secret{ + _basicAuthWithoutPassword = &corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", APIVersion: "apps/v1", @@ -135,17 +115,20 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, Type: "Opaque", } +) +func TestSetupGitAuthWithSecrets(t *testing.T) { client := fake.NewClientBuilder(). WithScheme(scheme.Scheme). - WithObjects(sshPrivateKey, sshPrivateKeyWithPassword, accessToken, basicAuth, basicAuthWithoutPassword). + WithObjects(_sshPrivateKey, _sshPrivateKeyWithPassword, _accessToken, _basicAuth, _basicAuthWithoutPassword). Build() for _, test := range []struct { - name string - gitAuth *shared.GitAuthConfig - expected *auto.GitAuth - err error + name string + gitAuth *shared.GitAuthConfig + gitAuthSecret string + expected *auto.GitAuth + err error }{ { name: "InvalidSecretName", @@ -164,6 +147,11 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { }, err: fmt.Errorf("secrets \"MISSING\" not found"), }, + { + name: "InvalidSecretName (gitAuthSecret)", + gitAuthSecret: "MISSING", + err: fmt.Errorf("secrets \"MISSING\" not found"), + }, { name: "ValidSSHPrivateKey", gitAuth: &shared.GitAuthConfig{ @@ -173,7 +161,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: sshPrivateKey.Name, + Name: _sshPrivateKey.Name, Key: "sshPrivateKey", }, }, @@ -193,7 +181,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: sshPrivateKeyWithPassword.Name, + Name: _sshPrivateKeyWithPassword.Name, Key: "sshPrivateKey", }, }, @@ -203,7 +191,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: sshPrivateKeyWithPassword.Name, + Name: _sshPrivateKeyWithPassword.Name, Key: "password", }, }, @@ -215,6 +203,14 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { Password: "moar secret password", }, }, + { + name: "ValidSSHPrivateKeyWithPassword (gitAuthSecret)", + gitAuthSecret: _sshPrivateKeyWithPassword.Name, + expected: &auto.GitAuth{ + SSHPrivateKey: "very secret key", + Password: "moar secret password", + }, + }, { name: "ValidAccessToken", gitAuth: &shared.GitAuthConfig{ @@ -223,7 +219,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: accessToken.Name, + Name: _accessToken.Name, Key: "accessToken", }, }, @@ -233,6 +229,13 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { PersonalAccessToken: "super secret access token", }, }, + { + name: "ValidAccessToken (gitAuthSecret)", + gitAuthSecret: _accessToken.Name, + expected: &auto.GitAuth{ + PersonalAccessToken: "super secret access token", + }, + }, { name: "ValidBasicAuth", gitAuth: &shared.GitAuthConfig{ @@ -242,7 +245,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: basicAuth.Name, + Name: _basicAuth.Name, Key: "username", }, }, @@ -252,7 +255,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: basicAuth.Name, + Name: _basicAuth.Name, Key: "password", }, }, @@ -264,6 +267,14 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { Password: "very secret password", }, }, + { + name: "ValidBasicAuth (gitAuthSecret)", + gitAuthSecret: _basicAuth.Name, + expected: &auto.GitAuth{ + Username: "not so secret username", + Password: "very secret password", + }, + }, { name: "BasicAuthWithoutPassword", gitAuth: &shared.GitAuthConfig{ @@ -273,7 +284,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: basicAuthWithoutPassword.Name, + Name: _basicAuthWithoutPassword.Name, Key: "username", }, }, @@ -283,21 +294,30 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithSecrets() { ResourceSelector: shared.ResourceSelector{ SecretRef: &shared.SecretSelector{ Namespace: namespace, - Name: basicAuthWithoutPassword.Name, + Name: _basicAuthWithoutPassword.Name, Key: "password", }, }, }, }, }, - err: errors.New("No key \"password\" found in secret test/basicAuthWithoutPassword"), + err: errors.New("no key \"password\" found in secret test/basicAuthWithoutPassword"), + }, + { + name: "BasicAuthWithoutPassword (gitAuthSecret)", + gitAuthSecret: _basicAuthWithoutPassword.Name, + err: errors.New(`no key "password" found in secret test/basicAuthWithoutPassword`), }, } { t.Run(test.name, func(t *testing.T) { + log := testr.New(t).WithValues("Request.Test", t.Name()) session := newStackReconcilerSession(log, shared.StackSpec{ - GitSource: &shared.GitSource{GitAuth: test.gitAuth}, + GitSource: &shared.GitSource{ + GitAuth: test.gitAuth, + GitAuthSecret: test.gitAuthSecret, + }, }, client, scheme.Scheme, namespace) - gitAuth, err := session.SetupGitAuth(context.TODO()) + gitAuth, err := session.resolveGitAuth(context.TODO()) if test.err != nil { require.Error(t, err) assert.Contains(t, err.Error(), test.err.Error()) @@ -434,9 +454,228 @@ func TestSetupWorkspace(t *testing.T) { } } -func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { - t := suite.T() - t.Skip() // https://github.com/pulumi/pulumi-kubernetes-operator/pull/658 +func TestSetupWorkspaceFromGitSource(t *testing.T) { + scheme.Scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "pulumi.com", Version: "v1", Kind: "Stack"}, + &v1.Stack{}, + ) + client := fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithObjects(_sshPrivateKey, _sshPrivateKeyWithPassword, _accessToken, _basicAuth, _basicAuthWithoutPassword). + Build() + + for _, test := range []struct { + name string + gitAuth *shared.GitAuthConfig + gitAuthSecret string + expected *v1alpha1.WorkspaceSpec + err error + }{ + { + name: "SSHPrivateKeyWithPassword", + gitAuth: &shared.GitAuthConfig{ + SSHAuth: &shared.SSHAuth{ + SSHPrivateKey: shared.ResourceRef{ + SelectorType: "Secret", + ResourceSelector: shared.ResourceSelector{ + SecretRef: &shared.SecretSelector{ + Namespace: namespace, + Name: _sshPrivateKeyWithPassword.Name, + Key: "sshPrivateKey", + }, + }, + }, + Password: &shared.ResourceRef{ + SelectorType: "Secret", + ResourceSelector: shared.ResourceSelector{ + SecretRef: &shared.SecretSelector{ + Namespace: namespace, + Name: _sshPrivateKeyWithPassword.Name, + Key: "password", + }, + }, + }, + }, + }, + expected: &v1alpha1.WorkspaceSpec{ + PodTemplate: &v1alpha1.EmbeddedPodTemplateSpec{ + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "pulumi"}}, + }, + }, + Git: &v1alpha1.GitSource{ + Ref: "commit-hash", + Auth: &v1alpha1.GitAuth{ + SSHPrivateKey: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _sshPrivateKeyWithPassword.Name, + }, + Key: "sshPrivateKey", + }, + Password: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _sshPrivateKeyWithPassword.Name, + }, + Key: "password", + }, + }, + }, + }, + }, + { + name: "AccessToken", + gitAuth: &shared.GitAuthConfig{ + PersonalAccessToken: &shared.ResourceRef{ + SelectorType: "Secret", + ResourceSelector: shared.ResourceSelector{ + SecretRef: &shared.SecretSelector{ + Namespace: namespace, + Name: _accessToken.Name, + Key: "accessToken", + }, + }, + }, + }, + expected: &v1alpha1.WorkspaceSpec{ + PodTemplate: &v1alpha1.EmbeddedPodTemplateSpec{ + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "pulumi"}}, + }, + }, + Git: &v1alpha1.GitSource{ + Ref: "commit-hash", + Auth: &v1alpha1.GitAuth{ + Token: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _accessToken.Name, + }, + Key: "accessToken", + }, + }, + }, + }, + }, + { + name: "BasicAuth", + gitAuth: &shared.GitAuthConfig{ + BasicAuth: &shared.BasicAuth{ + UserName: shared.ResourceRef{ + SelectorType: "Secret", + ResourceSelector: shared.ResourceSelector{ + SecretRef: &shared.SecretSelector{ + Namespace: namespace, + Name: _basicAuth.Name, + Key: "username", + }, + }, + }, + Password: shared.ResourceRef{ + SelectorType: "Secret", + ResourceSelector: shared.ResourceSelector{ + SecretRef: &shared.SecretSelector{ + Namespace: namespace, + Name: _basicAuth.Name, + Key: "password", + }, + }, + }, + }, + }, + expected: &v1alpha1.WorkspaceSpec{ + PodTemplate: &v1alpha1.EmbeddedPodTemplateSpec{ + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "pulumi"}}, + }, + }, + Git: &v1alpha1.GitSource{ + Ref: "commit-hash", + Auth: &v1alpha1.GitAuth{ + Password: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _basicAuth.Name, + }, + Key: "password", + }, + Username: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _basicAuth.Name, + }, + Key: "username", + }, + }, + }, + }, + }, + { + name: "GitAuthSecret", + gitAuthSecret: _accessToken.Name, + expected: &v1alpha1.WorkspaceSpec{ + PodTemplate: &v1alpha1.EmbeddedPodTemplateSpec{ + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "pulumi"}}, + }, + }, + Git: &v1alpha1.GitSource{ + Ref: "commit-hash", + Auth: &v1alpha1.GitAuth{ + SSHPrivateKey: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _accessToken.Name, + }, + Key: "sshPrivateKey", + Optional: ptr.To(true), + }, + Password: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _accessToken.Name, + }, + Key: "password", + Optional: ptr.To(true), + }, + Username: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _accessToken.Name, + }, + Key: "username", + Optional: ptr.To(true), + }, + Token: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: _accessToken.Name, + }, + Key: "accessToken", + Optional: ptr.To(true), + }, + }, + }, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + log := testr.New(t).WithValues("Request.Test", t.Name()) + session := newStackReconcilerSession(log, shared.StackSpec{ + GitSource: &shared.GitSource{ + GitAuth: test.gitAuth, + GitAuthSecret: test.gitAuthSecret, + }, + }, client, scheme.Scheme, namespace) + require.NoError(t, session.NewWorkspace(&v1.Stack{ + Spec: session.stack, + })) + + err := session.setupWorkspaceFromGitSource(context.TODO(), "commit-hash") + if test.err != nil { + require.Error(t, err) + assert.Contains(t, err.Error(), test.err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, test.expected, &session.ws.Spec) + }) + } +} + +func TestSetupGitAuthWithRefs(t *testing.T) { log := testr.New(t).WithValues("Request.Test", "TestSetupGitAuthWithSecrets") secret := &corev1.Secret{ @@ -493,22 +732,6 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { PersonalAccessToken: "very secret", }, }, - { - name: "GitAuthValidFileReference", - gitAuth: &shared.GitAuthConfig{ - PersonalAccessToken: &shared.ResourceRef{ - SelectorType: shared.ResourceSelectorFS, - ResourceSelector: shared.ResourceSelector{ - FileSystem: &shared.FSSelector{ - Path: suite.f, - }, - }, - }, - }, - expected: &auto.GitAuth{ - PersonalAccessToken: "super secret", - }, - }, { name: "GitAuthInvalidFileReference", gitAuth: &shared.GitAuthConfig{ @@ -521,7 +744,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { }, }, }, - err: fmt.Errorf("open /tmp/!@#@!#: no such file or directory"), + err: fmt.Errorf("FS selectors are no longer supported in v2, please use a secret reference instead"), }, { name: "GitAuthValidEnvVarReference", @@ -535,9 +758,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { }, }, }, - expected: &auto.GitAuth{ - PersonalAccessToken: "so secret", - }, + err: fmt.Errorf("Env selectors are no longer supported in v2, please use a secret reference instead"), }, { name: "GitAuthInvalidEnvReference", @@ -551,7 +772,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { }, }, }, - err: fmt.Errorf("missing value for environment variable: MISSING"), + err: fmt.Errorf("Env selectors are no longer supported in v2, please use a secret reference instead"), }, { name: "GitAuthValidSSHAuthWithoutPassword", @@ -630,7 +851,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { }, }, }, - err: fmt.Errorf("resolving gitAuth SSH password: No key \"MISSING\" found in secret test/fake-secret"), + err: fmt.Errorf("resolving gitAuth SSH password: no key \"MISSING\" found in secret test/fake-secret"), }, { name: "GitAuthValidBasicAuth", @@ -677,7 +898,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { }, }, }, - err: fmt.Errorf("resolving gitAuth personal access token: No key \"MISSING\" found in secret test/fake-secret"), + err: fmt.Errorf("resolving gitAuth personal access token: no key \"MISSING\" found in secret test/fake-secret"), }, } { t.Run(test.name, func(t *testing.T) { @@ -686,7 +907,7 @@ func (suite *GitAuthTestSuite) TestSetupGitAuthWithRefs() { GitAuth: test.gitAuth, }, }, client, scheme.Scheme, namespace) - gitAuth, err := session.SetupGitAuth(context.TODO()) + gitAuth, err := session.resolveGitAuth(context.TODO()) if test.err != nil { require.Error(t, err) assert.Contains(t, err.Error(), test.err.Error()) diff --git a/operator/internal/controller/pulumi/stack_controller.go b/operator/internal/controller/pulumi/stack_controller.go index f7cab7c1..79782279 100644 --- a/operator/internal/controller/pulumi/stack_controller.go +++ b/operator/internal/controller/pulumi/stack_controller.go @@ -385,6 +385,7 @@ var errProgramNotFound = fmt.Errorf("unable to retrieve program for stack") //+kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=ocirepositories,verbs=get;list;watch //+kubebuilder:rbac:groups=auto.pulumi.com,resources=workspaces,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=auto.pulumi.com,resources=updates,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // Reconcile reads that state of the cluster for a Stack object and makes changes based on the state read // and what is in the Stack.Spec @@ -553,7 +554,7 @@ func (r *StackReconciler) Reconcile(ctx context.Context, request ctrl.Request) ( // Step 1. Set up the workspace, select the right stack and populate config if supplied. - // Check swhich kind of source we have. + // Check which kind of source we have. switch { case !exactlyOneOf(stack.GitSource != nil, stack.FluxSource != nil, stack.ProgramRef != nil): @@ -561,48 +562,32 @@ func (r *StackReconciler) Reconcile(ctx context.Context, request ctrl.Request) ( instance.Status.MarkStalledCondition(pulumiv1.StalledSpecInvalidReason, err.Error()) return reconcile.Result{}, nil - // case stack.GitSource != nil: - // gitSource := stack.GitSource - // // Validate that there is enough specified to be able to clone the git repo. - // if gitSource.ProjectRepo == "" || (gitSource.Commit == "" && gitSource.Branch == "") { - - // msg := "Stack git source needs to specify 'projectRepo' and either 'branch' or 'commit'" - // r.emitEvent(instance, pulumiv1.StackConfigInvalidEvent(), msg) - // reqLogger.Info(msg) - // r.markStackFailed(sess, instance, errors.New(msg), "", "") - // instance.Status.MarkStalledCondition(pulumiv1.StalledSpecInvalidReason, msg) - // // this object won't be processable until the spec is changed, so no reason to requeue - // // explicitly - // return reconcile.Result{}, nil - // } - - // gitAuth, err := sess.SetupGitAuth(ctx) // TODO be more explicit about what's being fed in here - // if err != nil { - // r.emitEvent(instance, pulumiv1.StackGitAuthFailureEvent(), "Failed to setup git authentication: %v", err.Error()) - // reqLogger.Error(err, "Failed to setup git authentication", "Stack.Name", stack.Stack) - // r.markStackFailed(sess, instance, err, "", "") - // instance.Status.MarkStalledCondition(pulumiv1.StalledSourceUnavailableReason, err.Error()) - // return reconcile.Result{}, nil - // } - - // if gitAuth.SSHPrivateKey != "" { - // // Add the project repo's public SSH keys to the SSH known hosts - // // to perform the necessary key checking during SSH git cloning. - // sess.addSSHKeysToKnownHosts(sess.stack.ProjectRepo) - // } - - // if currentCommit, err = sess.SetupWorkdirFromGitSource(ctx, gitAuth, gitSource); err != nil { - // r.emitEvent(instance, pulumiv1.StackInitializationFailureEvent(), "Failed to initialize stack: %v", err.Error()) - // reqLogger.Error(err, "Failed to setup Pulumi workspace", "Stack.Name", stack.Stack) - // r.markStackFailed(sess, instance, err, "", "") - // if isStalledError(err) { - // instance.Status.MarkStalledCondition(pulumiv1.StalledCrossNamespaceRefForbiddenReason, err.Error()) - // return reconcile.Result{}, nil - // } - // instance.Status.MarkReconcilingCondition(pulumiv1.ReconcilingRetryReason, err.Error()) - // // this can fail for reasons which might go away without intervention; so, retry explicitly - // return reconcile.Result{Requeue: true}, nil - // } + case stack.GitSource != nil: + auth, err := sess.resolveGitAuth(ctx) + if err != nil { + r.emitEvent(instance, pulumiv1.StackConfigInvalidEvent(), err.Error()) + instance.Status.MarkStalledCondition(pulumiv1.StalledSpecInvalidReason, err.Error()) + return reconcile.Result{}, saveStatus() + } + + gs, err := NewGitSource(*stack.GitSource, auth) + if err != nil { + r.emitEvent(instance, pulumiv1.StackConfigInvalidEvent(), err.Error()) + instance.Status.MarkStalledCondition(pulumiv1.StalledSpecInvalidReason, err.Error()) + return reconcile.Result{}, saveStatus() + } + + currentCommit, err = gs.CurrentCommit(ctx) + if err != nil { + instance.Status.MarkStalledCondition(pulumiv1.StalledSourceUnavailableReason, err.Error()) + return reconcile.Result{}, saveStatus() + } + + err = sess.setupWorkspaceFromGitSource(ctx, currentCommit) + if err != nil { + log.Error(err, "Failed to setup Pulumi workspace") + return reconcile.Result{}, err + } case stack.FluxSource != nil: fluxSource := stack.FluxSource @@ -690,7 +675,7 @@ func (r *StackReconciler) Reconcile(ctx context.Context, request ctrl.Request) ( (!sess.stack.ContinueResyncOnCommitMatch || time.Since(instance.Status.LastUpdate.LastResyncTime.Time) < resyncFreq))) if synced { - // transition to ready, and requeue reconcilation as necessary to detect + // transition to ready, and requeue reconciliation as necessary to detect // branch updates and resyncs. instance.Status.MarkReadyCondition() @@ -1020,6 +1005,38 @@ func (sess *StackReconcilerSession) resolveResourceRefAsConfigItem(ctx context.C } } +// resolveSecretResourceRef reads a referenced object and returns its value as +// a string. The v1 controller allowed env and filesystem references which no +// longer make sense in the v2 agent/manager model, so only secret refs are +// currently supported. +func (sess *StackReconcilerSession) resolveSecretResourceRef(ctx context.Context, ref *shared.ResourceRef) (string, error) { + switch ref.SelectorType { + case shared.ResourceSelectorSecret: + if ref.SecretRef == nil { + return "", errors.New("missing secret reference in ResourceRef") + } + var config corev1.Secret + namespace := ref.SecretRef.Namespace + if namespace == "" { + namespace = sess.namespace + } + if namespace != sess.namespace { + return "", errNamespaceIsolation + } + + if err := sess.kubeClient.Get(ctx, types.NamespacedName{Name: ref.SecretRef.Name, Namespace: namespace}, &config); err != nil { + return "", fmt.Errorf("namespace=%s Name=%s: %w", ref.SecretRef.Namespace, ref.SecretRef.Name, err) + } + secretVal, ok := config.Data[ref.SecretRef.Key] + if !ok { + return "", fmt.Errorf("no key %q found in secret %s/%s", ref.SecretRef.Key, ref.SecretRef.Namespace, ref.SecretRef.Name) + } + return string(secretVal), nil + default: + return "", fmt.Errorf("%s selectors are no longer supported in v2, please use a secret reference instead", ref.SelectorType) + } +} + func nameForWorkspace(stack *metav1.ObjectMeta) string { return stack.Name }