Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Decoupling application sync using impersonation #17403

Merged
merged 19 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e85cf79
Implementation of app sync with impersonation support
anandf May 4, 2024
efa1a37
negation test
Mangaal May 8, 2024
3ac9506
Update doc comments to remove server name as its not supported.
anandf May 23, 2024
fef7b51
Update glob pattern check for matching destinations.
anandf May 23, 2024
1d5f82b
Corrected the code comments for namespace field and destination match…
anandf May 23, 2024
29f727e
Added missing generated files
anandf Jun 21, 2024
82a364a
Fixed golint errors caused due to to gofumpt validations
anandf Jul 1, 2024
a60e378
Fix golint errors with unit test code
anandf Jul 1, 2024
30356d5
Updated the go import ordering with local packages at the end
anandf Jul 1, 2024
a2a58fa
Addressed review comments
anandf Jul 25, 2024
9071a47
Fixed ES lint error caused due to missing class
anandf Jul 25, 2024
2b1e246
Updated the documentation to address the review comments
anandf Aug 6, 2024
7c22d40
Simplified the sync code and improved logs and error handling
anandf Aug 9, 2024
1997414
Fixed E2E tests to fail when no sa is configured
anandf Aug 20, 2024
f85208c
Updated help message generated for CLI commands
anandf Aug 20, 2024
3aaed27
Fixed failing tests due to default service account not used for sync …
anandf Aug 21, 2024
3786109
Fixed the error message when sync fails due to no matching sa
anandf Aug 21, 2024
78fe21b
Removed repeating logs and added impersonation fields to logger
anandf Aug 22, 2024
080b5ce
Made changes in the proposal to match the behaviour when no matching …
anandf Aug 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions assets/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

136 changes: 133 additions & 3 deletions cmd/argocd/commands/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
Expand Down Expand Up @@ -80,6 +81,8 @@ func NewProjectCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
command.AddCommand(NewProjectRemoveOrphanedIgnoreCommand(clientOpts))
command.AddCommand(NewProjectAddSourceNamespace(clientOpts))
command.AddCommand(NewProjectRemoveSourceNamespace(clientOpts))
command.AddCommand(NewProjectAddDestinationServiceAccountCommand(clientOpts))
command.AddCommand(NewProjectRemoveDestinationServiceAccountCommand(clientOpts))
return command
}

Expand Down Expand Up @@ -799,7 +802,7 @@ func printProjectNames(projects []v1alpha1.AppProject) {
// Print table of project info
func printProjectTable(projects []v1alpha1.AppProject) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "NAME\tDESCRIPTION\tDESTINATIONS\tSOURCES\tCLUSTER-RESOURCE-WHITELIST\tNAMESPACE-RESOURCE-BLACKLIST\tSIGNATURE-KEYS\tORPHANED-RESOURCES\n")
fmt.Fprintf(w, "NAME\tDESCRIPTION\tDESTINATIONS\tSOURCES\tCLUSTER-RESOURCE-WHITELIST\tNAMESPACE-RESOURCE-BLACKLIST\tSIGNATURE-KEYS\tORPHANED-RESOURCES\tDESTINATION-SERVICE-ACCOUNTS\n")
for _, p := range projects {
printProjectLine(w, &p)
}
Expand Down Expand Up @@ -855,7 +858,7 @@ func formatOrphanedResources(p *v1alpha1.AppProject) string {
}

func printProjectLine(w io.Writer, p *v1alpha1.AppProject) {
var destinations, sourceRepos, clusterWhitelist, namespaceBlacklist, signatureKeys string
var destinations, destinationServiceAccounts, sourceRepos, clusterWhitelist, namespaceBlacklist, signatureKeys string
switch len(p.Spec.Destinations) {
case 0:
destinations = "<none>"
Expand All @@ -864,6 +867,14 @@ func printProjectLine(w io.Writer, p *v1alpha1.AppProject) {
default:
destinations = fmt.Sprintf("%d destinations", len(p.Spec.Destinations))
}
switch len(p.Spec.DestinationServiceAccounts) {
case 0:
destinationServiceAccounts = "<none>"
anandf marked this conversation as resolved.
Show resolved Hide resolved
case 1:
destinationServiceAccounts = fmt.Sprintf("%s,%s,%s", p.Spec.DestinationServiceAccounts[0].Server, p.Spec.DestinationServiceAccounts[0].Namespace, p.Spec.DestinationServiceAccounts[0].DefaultServiceAccount)
default:
destinationServiceAccounts = fmt.Sprintf("%d destinationServiceAccounts", len(p.Spec.DestinationServiceAccounts))
}
switch len(p.Spec.SourceRepos) {
case 0:
sourceRepos = "<none>"
Expand Down Expand Up @@ -892,7 +903,7 @@ func printProjectLine(w io.Writer, p *v1alpha1.AppProject) {
default:
signatureKeys = fmt.Sprintf("%d key(s)", len(p.Spec.SignatureKeys))
}
fmt.Fprintf(w, "%s\t%s\t%v\t%v\t%v\t%v\t%v\t%v\n", p.Name, p.Spec.Description, destinations, sourceRepos, clusterWhitelist, namespaceBlacklist, signatureKeys, formatOrphanedResources(p))
fmt.Fprintf(w, "%s\t%s\t%v\t%v\t%v\t%v\t%v\t%v\t%v\n", p.Name, p.Spec.Description, destinations, sourceRepos, clusterWhitelist, namespaceBlacklist, signatureKeys, formatOrphanedResources(p), destinationServiceAccounts)
}

func printProject(p *v1alpha1.AppProject, scopedRepositories []*v1alpha1.Repository, scopedClusters []*v1alpha1.Cluster) {
Expand Down Expand Up @@ -1082,3 +1093,122 @@ func NewProjectEditCommand(clientOpts *argocdclient.ClientOptions) *cobra.Comman
}
return command
}

// NewProjectAddDestinationServiceAccountCommand returns a new instance of an `argocd proj add-destination-service-account` command
func NewProjectAddDestinationServiceAccountCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
var serviceAccountNamespace string

buildApplicationDestinationServiceAccount := func(destination string, namespace string, serviceAccount string, serviceAccountNamespace string) v1alpha1.ApplicationDestinationServiceAccount {
if serviceAccountNamespace != "" {
return v1alpha1.ApplicationDestinationServiceAccount{
Server: destination,
Namespace: namespace,
DefaultServiceAccount: fmt.Sprintf("%s:%s", serviceAccountNamespace, serviceAccount),
}
} else {
return v1alpha1.ApplicationDestinationServiceAccount{
Server: destination,
Namespace: namespace,
DefaultServiceAccount: serviceAccount,
}
}
}

command := &cobra.Command{
Use: "add-destination-service-account PROJECT SERVER NAMESPACE SERVICE_ACCOUNT",
Short: "Add project destination's default service account",
Example: templates.Examples(`
# Add project destination service account (SERVICE_ACCOUNT) for a server URL (SERVER) in the specified namespace (NAMESPACE) on the project with name PROJECT
jgwest marked this conversation as resolved.
Show resolved Hide resolved
argocd proj add-destination-service-account PROJECT SERVER NAMESPACE SERVICE_ACCOUNT

# Add project destination service account (SERVICE_ACCOUNT) from a different namespace
argocd proj add-destination PROJECT SERVER NAMESPACE SERVICE_ACCOUNT --service-account-namespace <service_account_namespace>
`),
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()

if len(args) != 4 {
c.HelpFunc()(c, args)
os.Exit(1)
}
projName := args[0]
server := args[1]
namespace := args[2]
serviceAccount := args[3]

if strings.Contains(serviceAccountNamespace, "*") {
jgwest marked this conversation as resolved.
Show resolved Hide resolved
log.Fatal("service-account-namespace for DestinationServiceAccount must not contain wildcards")
}

if strings.Contains(serviceAccount, "*") {
log.Fatal("ServiceAccount for DestinationServiceAccount must not contain wildcards")
}

destinationServiceAccount := buildApplicationDestinationServiceAccount(server, namespace, serviceAccount, serviceAccountNamespace)
conn, projIf := headless.NewClientOrDie(clientOpts, c).NewProjectClientOrDie()
defer argoio.Close(conn)

proj, err := projIf.Get(ctx, &projectpkg.ProjectQuery{Name: projName})
errors.CheckError(err)

for _, dest := range proj.Spec.DestinationServiceAccounts {
dstServerExist := destinationServiceAccount.Server != "" && dest.Server == destinationServiceAccount.Server
dstServiceAccountExist := destinationServiceAccount.DefaultServiceAccount != "" && dest.DefaultServiceAccount == destinationServiceAccount.DefaultServiceAccount
if dest.Namespace == destinationServiceAccount.Namespace && dstServerExist && dstServiceAccountExist {
log.Fatal("Specified destination service account is already defined in project")
}
}
proj.Spec.DestinationServiceAccounts = append(proj.Spec.DestinationServiceAccounts, destinationServiceAccount)
_, err = projIf.Update(ctx, &projectpkg.ProjectUpdateRequest{Project: proj})
errors.CheckError(err)
},
}
command.Flags().StringVar(&serviceAccountNamespace, "service-account-namespace", "", "Use service-account-namespace as namespace where the service account is present")
return command
}

// NewProjectRemoveDestinationCommand returns a new instance of an `argocd proj remove-destination-service-account` command
func NewProjectRemoveDestinationServiceAccountCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
command := &cobra.Command{
Use: "remove-destination-service-account PROJECT SERVER NAMESPACE SERVICE_ACCOUNT",
Short: "Remove default destination service account from the project",
Example: templates.Examples(`
# Remove the destination service account (SERVICE_ACCOUNT) from the specified destination (SERVER and NAMESPACE combination) on the project with name PROJECT
argocd proj remove-destination-service-account PROJECT SERVER NAMESPACE SERVICE_ACCOUNT
`),
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()

if len(args) != 4 {
c.HelpFunc()(c, args)
os.Exit(1)
}
projName := args[0]
server := args[1]
namespace := args[2]
serviceAccount := args[3]
conn, projIf := headless.NewClientOrDie(clientOpts, c).NewProjectClientOrDie()
defer argoio.Close(conn)

proj, err := projIf.Get(ctx, &projectpkg.ProjectQuery{Name: projName})
errors.CheckError(err)

originalLength := len(proj.Spec.DestinationServiceAccounts)
proj.Spec.DestinationServiceAccounts = slices.DeleteFunc(proj.Spec.DestinationServiceAccounts,
func(destServiceAccount v1alpha1.ApplicationDestinationServiceAccount) bool {
return destServiceAccount.Namespace == namespace &&
destServiceAccount.Server == server &&
destServiceAccount.DefaultServiceAccount == serviceAccount
},
)
if originalLength != len(proj.Spec.DestinationServiceAccounts) {
_, err = projIf.Update(ctx, &projectpkg.ProjectUpdateRequest{Project: proj})
errors.CheckError(err)
} else {
log.Fatal("Specified destination service account does not exist in project")
}
},
}

return command
}
30 changes: 25 additions & 5 deletions cmd/util/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import (
)

type ProjectOpts struct {
Description string
destinations []string
Sources []string
SignatureKeys []string
SourceNamespaces []string
Description string
destinations []string
destinationServiceAccounts []string
Sources []string
SignatureKeys []string
SourceNamespaces []string

orphanedResourcesEnabled bool
orphanedResourcesWarn bool
Expand Down Expand Up @@ -93,6 +94,23 @@ func (opts *ProjectOpts) GetDestinations() []v1alpha1.ApplicationDestination {
return destinations
}

func (opts *ProjectOpts) GetDestinationServiceAccounts() []v1alpha1.ApplicationDestinationServiceAccount {
destinationServiceAccounts := make([]v1alpha1.ApplicationDestinationServiceAccount, 0)
for _, destStr := range opts.destinationServiceAccounts {
parts := strings.Split(destStr, ",")
if len(parts) != 2 {
jgwest marked this conversation as resolved.
Show resolved Hide resolved
log.Fatalf("Expected destination of the form: server,namespace. Received: %s", destStr)
} else {
destinationServiceAccounts = append(destinationServiceAccounts, v1alpha1.ApplicationDestinationServiceAccount{
Server: parts[0],
Namespace: parts[1],
DefaultServiceAccount: parts[2],
})
}
}
return destinationServiceAccounts
}

// GetSignatureKeys TODO: Get configured keys and emit warning when a key is specified that is not configured
func (opts *ProjectOpts) GetSignatureKeys() []v1alpha1.SignatureKey {
signatureKeys := make([]v1alpha1.SignatureKey, 0)
Expand Down Expand Up @@ -166,6 +184,8 @@ func SetProjSpecOptions(flags *pflag.FlagSet, spec *v1alpha1.AppProjectSpec, pro
spec.NamespaceResourceBlacklist = projOpts.GetDeniedNamespacedResources()
case "source-namespaces":
spec.SourceNamespaces = projOpts.GetSourceNamespaces()
case "dest-service-accounts":
jgwest marked this conversation as resolved.
Show resolved Hide resolved
spec.DestinationServiceAccounts = projOpts.GetDestinationServiceAccounts()
}
})
if flags.Changed("orphaned-resources") || flags.Changed("orphaned-resources-warn") {
Expand Down
48 changes: 48 additions & 0 deletions controller/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"sync/atomic"
"time"

Expand All @@ -23,13 +24,15 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/managedfields"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/kubectl/pkg/util/openapi"

"github.com/argoproj/argo-cd/v2/controller/metrics"
"github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
listersv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1"
"github.com/argoproj/argo-cd/v2/util/argo"
"github.com/argoproj/argo-cd/v2/util/argo/diff"
"github.com/argoproj/argo-cd/v2/util/glob"
logutils "github.com/argoproj/argo-cd/v2/util/log"
"github.com/argoproj/argo-cd/v2/util/lua"
"github.com/argoproj/argo-cd/v2/util/rand"
Expand Down Expand Up @@ -284,6 +287,23 @@ func (m *appStateManager) SyncAppState(app *v1alpha1.Application, state *v1alpha
}
trackingMethod := argo.GetTrackingMethod(m.settingsMgr)

if m.settingsMgr.IsImpersonationEnabled() {
serviceAccountToImpersonate, err := deriveServiceAccountName(proj, app)
jgwest marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
state.Phase = common.OperationError
state.Message = fmt.Sprintf("failed to find a matching service account to impersonate: %v", err)
return
}
logEntry = logEntry.WithFields(log.Fields{"impersonationEnabled": "true", "serviceAccount": serviceAccountToImpersonate})
// set the impersonation headers.
rawConfig.Impersonate = rest.ImpersonationConfig{
jgwest marked this conversation as resolved.
Show resolved Hide resolved
UserName: serviceAccountToImpersonate,
}
restConfig.Impersonate = rest.ImpersonationConfig{
UserName: serviceAccountToImpersonate,
}
}

opts := []sync.SyncOpt{
sync.WithLogr(logutils.NewLogrusLogger(logEntry)),
sync.WithHealthOverride(lua.ResourceHealthOverrides(resourceOverrides)),
Expand Down Expand Up @@ -536,3 +556,31 @@ func syncWindowPreventsSync(app *v1alpha1.Application, proj *v1alpha1.AppProject
}
return !window.CanSync(isManual)
}

// deriveServiceAccountName determines the service account to be used for impersonation for the sync operation.
// The returned service account will be fully qualified including namespace and the service account name in the format system:serviceaccount:<namespace>:<service_account>
func deriveServiceAccountName(project *v1alpha1.AppProject, application *v1alpha1.Application) (string, error) {
// spec.Destination.Namespace is optional. If not specified, use the Application's
// namespace
jgwest marked this conversation as resolved.
Show resolved Hide resolved
serviceAccountNamespace := application.Spec.Destination.Namespace
if serviceAccountNamespace == "" {
serviceAccountNamespace = application.Namespace
}
// Loop through the destinationServiceAccounts and see if there is any destination that is a candidate.
// if so, return the service account specified for that destination.
for _, item := range project.Spec.DestinationServiceAccounts {
dstServerMatched := glob.Match(item.Server, application.Spec.Destination.Server)
jgwest marked this conversation as resolved.
Show resolved Hide resolved
dstNamespaceMatched := glob.Match(item.Namespace, application.Spec.Destination.Namespace)
if dstServerMatched && dstNamespaceMatched {
jgwest marked this conversation as resolved.
Show resolved Hide resolved
if strings.Contains(item.DefaultServiceAccount, ":") {
// service account is specified along with its namespace.
return fmt.Sprintf("system:serviceaccount:%s", item.DefaultServiceAccount), nil
} else {
// service account needs to be prefixed with a namespace
return fmt.Sprintf("system:serviceaccount:%s:%s", serviceAccountNamespace, item.DefaultServiceAccount), nil
}
}
}
// if there is no match found in the AppProject.Spec.DestinationServiceAccounts, use the default service account of the destination namespace.
return "", fmt.Errorf("no matching service account found for destination server %s and namespace %s", application.Spec.Destination.Server, serviceAccountNamespace)
}
Loading