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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 34 additions & 19 deletions cmd/oci-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ var flOneTime = flag.Bool("one-time", util.EnvBool("OCI_SYNC_ONE_TIME", false),
"exit after the first sync")
var flMaxSyncFailures = flag.Int("max-sync-failures", util.EnvInt("OCI_SYNC_MAX_SYNC_FAILURES", 0),
"the number of consecutive failures allowed before aborting (the first sync must succeed, -1 will retry forever after the initial sync)")
var flUsername = flag.String("username", util.EnvString("OCI_SYNC_USERNAME", ""),
"the username to use for oci authentication")
var flPassword = flag.String("password", util.EnvString("OCI_SYNC_PASSWORD", ""),
"the password or personal access token to use for oci authentication")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the password needs nil check is username is set?


func main() {
utillog.Setup()
Expand Down Expand Up @@ -89,25 +93,9 @@ func main() {
pollPeriod := util.WaitTime(*flWait)
backoff := util.SyncContainerBackoff(pollPeriod)

var authenticator authn.Authenticator
switch configsync.AuthType(*flAuth) {
case configsync.AuthNone:
authenticator = authn.Anonymous
case configsync.AuthGCPServiceAccount, configsync.AuthK8sServiceAccount, configsync.AuthGCENode:
authenticator = &oci.CredentialAuthenticator{
CredentialProvider: &auth.CachingCredentialProvider{
Scopes: auth.OCISourceScopes(),
},
}
default:
utillog.HandleError(log, true, "ERROR: --auth type must be one of %#v, but found %q",
[]configsync.AuthType{
configsync.AuthNone,
configsync.AuthGCPServiceAccount,
configsync.AuthK8sServiceAccount,
configsync.AuthGCENode,
},
*flAuth)
authenticator, err := getAuthenticator(configsync.AuthType(*flAuth))
if err != nil {
utillog.HandleError(log, true, "failed to create authenticator: %v", err)
}

fetcher := &oci.Fetcher{
Expand Down Expand Up @@ -170,3 +158,30 @@ func sleepForever() {
<-c
os.Exit(0)
}

func getAuthenticator(authType configsync.AuthType) (authn.Authenticator, error) {
switch authType {
case configsync.AuthNone:
return authn.Anonymous, nil
case configsync.AuthToken:
return &authn.Basic{
Username: *flUsername,
Password: *flPassword,
}, nil
case configsync.AuthGCPServiceAccount, configsync.AuthK8sServiceAccount, configsync.AuthGCENode:
return &oci.CredentialAuthenticator{
CredentialProvider: &auth.CachingCredentialProvider{
Scopes: auth.OCISourceScopes(),
},
}, nil
default:
return nil, fmt.Errorf("--auth type must be one of %#v, but found %q",
[]configsync.AuthType{
configsync.AuthNone,
configsync.AuthGCPServiceAccount,
configsync.AuthK8sServiceAccount,
configsync.AuthGCENode,
configsync.AuthToken,
}, authType)
}
}
93 changes: 93 additions & 0 deletions cmd/oci-sync/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2025 Google LLC
//
// 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 main

import (
"testing"

"github.com/GoogleContainerTools/config-sync/pkg/api/configsync"
"github.com/GoogleContainerTools/config-sync/pkg/auth"
"github.com/GoogleContainerTools/config-sync/pkg/oci"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/stretchr/testify/assert"
)

func TestGetAuthenticator(t *testing.T) {
username := "username"
password := "password"
flUsername = &username
flPassword = &password

testCases := []struct {
name string
auth configsync.AuthType
expected authn.Authenticator
hasError bool
}{
{
name: "none",
auth: configsync.AuthNone,
expected: authn.Anonymous,
},
{
name: "token",
auth: configsync.AuthToken,
expected: &authn.Basic{
Username: username,
Password: password,
},
},
{
name: "k8sserviceaccount",
auth: configsync.AuthK8sServiceAccount,
expected: &oci.CredentialAuthenticator{
CredentialProvider: &auth.CachingCredentialProvider{
Scopes: auth.OCISourceScopes(),
},
},
},
{
name: "gcenode",
auth: configsync.AuthGCENode,
expected: &oci.CredentialAuthenticator{
CredentialProvider: &auth.CachingCredentialProvider{
Scopes: auth.OCISourceScopes(),
},
},
},
{
name: "gcpserviceaccount",
auth: configsync.AuthGCPServiceAccount,
expected: &oci.CredentialAuthenticator{
CredentialProvider: &auth.CachingCredentialProvider{
Scopes: auth.OCISourceScopes(),
},
},
},
{
name: "invalid auth",
auth: "invalid",
hasError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
auth, err := getAuthenticator(tc.auth)
assert.Equal(t, tc.hasError, err != nil)
assert.Equal(t, tc.expected, auth)
})
}
}
119 changes: 119 additions & 0 deletions e2e/testcases/oci_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,74 @@ func TestPublicOCI(t *testing.T) {
kustomizecomponents.ValidateAllTenants(nt, string(declared.RootScope), "../base", tenant)
}

// TestOCIARTokenAuth verifies Config Sync can pull an OCI image from a private
// Artifact Registry with Token auth type.
//
// Test pre-requisites:
// - Google service account
// `e2e-test-ar-reader@${GCP_PROJECT}.iam.gserviceaccount.com` is created
// with `roles/artifactregistry.reader` for accessing images in Artifact
// Registry.
// - A JSON key file is generated for this service account and stored in
// Secret Manager
//
// Test handles service account key rotation.
func TestOCIARTokenAuth(t *testing.T) {
nt := nomostest.New(t,
nomostesting.SyncSourceOCI,
ntopts.SyncWithGitSource(nomostest.DefaultRootSyncID, ntopts.Unstructured),
ntopts.RequireGKE(t),
ntopts.RequireOCIArtifactRegistry(t),
)
rootSyncID := nomostest.DefaultRootSyncID
rootSyncKey := rootSyncID.ObjectKey

gsaKeySecretID := "config-sync-ci-ar-key"
gsaEmail := registryproviders.ArtifactRegistryReaderEmail()
gsaName := registryproviders.ArtifactRegistryReaderName
gsaKeyFilePath, err := fetchServiceAccountKeyFile(nt, *e2e.GCPProject, gsaKeySecretID, gsaEmail, gsaName)
if err != nil {
nt.T.Fatal(err)
}

nt.T.Log("Creating kubernetes secret for authentication")
_, err = nt.Shell.Kubectl("create", "secret", "generic", "foo",
"--namespace", configsync.ControllerNamespace,
"--from-literal", "username=_json_key",
"--from-file", fmt.Sprintf("password=%s", gsaKeyFilePath))
if err != nil {
nt.T.Fatalf("failed to create secret, err: %v", err)
}
nt.T.Cleanup(func() {
nt.MustKubectl("delete", "secret", "foo", "-n", configsync.ControllerNamespace, "--ignore-not-found")
})

// OCI image will only contain the bookinfo-admin role
bookinfoRole := k8sobjects.RoleObject(core.Name("bookinfo-admin"))
image, err := nt.BuildAndPushOCIImage(rootSyncKey, registryproviders.ImageInputObjects(nt.Scheme, bookinfoRole))
if err != nil {
nt.T.Fatalf("failed to push oci image: %v", err)
}

nt.T.Log("Update RootSync to sync from a private Artifact Registry")
rs := nt.RootSyncObjectOCI(configsync.RootSyncName, image.OCIImageID().WithoutDigest(), "", image.Digest)
rs.Spec.Oci = &v1beta1.Oci{
Image: rs.Spec.Oci.Image,
Auth: configsync.AuthToken,
SecretRef: &v1beta1.SecretReference{
Name: "foo",
},
Period: metav1.Duration{Duration: 5 * time.Second},
}
nt.Must(nt.KubeClient.Apply(rs))

nt.T.Log("Wait for RootSync to sync from an oci image chart")
nt.Must(nt.WatchForAllSyncs())

nt.T.Log("Validate Role from OCI image exists")
nt.Must(nt.Validate(bookinfoRole.Name, "default", &rbacv1.Role{}))
}

func TestSwitchFromGitToOciCentralized(t *testing.T) {
namespace := testNs
rootSyncID := nomostest.DefaultRootSyncID
Expand Down Expand Up @@ -303,6 +371,57 @@ func TestOciSyncWithDigest(t *testing.T) {
}
}

// TestOCILocalRegistryTokenAuth can run only run on KinD clusters.
// It tests RootSync can pull from a private OCI registry using basic auth.
func TestOCILocalRegistryTokenAuth(t *testing.T) {
rootSyncID := nomostest.DefaultRootSyncID
nt := nomostest.New(t, nomostesting.SyncSourceOCI,
ntopts.SyncWithGitSource(rootSyncID, ntopts.Unstructured),
ntopts.RequireLocalOCIProvider)

nt.T.Log("Create OCI credentials secret")
secretName := "oci-creds"
secret := k8sobjects.SecretObject(
secretName,
core.Namespace(configsync.ControllerNamespace),
)
secret.Type = corev1.SecretTypeBasicAuth
secret.StringData = map[string]string{
corev1.BasicAuthUsernameKey: nomostest.RegistryUsername,
corev1.BasicAuthPasswordKey: nomostest.RegistryPassword,
}
nt.Must(nt.KubeClient.Create(secret))
nt.T.Cleanup(func() {
nt.Must(nt.KubeClient.Delete(secret))
})

// OCI image will only contain the bookinfo-admin role
bookinfoRole := k8sobjects.RoleObject(core.Name("bookinfo-admin"))
image, err := nt.BuildAndPushOCIImage(rootSyncID.ObjectKey, registryproviders.ImageInputObjects(nt.Scheme, bookinfoRole))
if err != nil {
nt.T.Fatalf("failed to push oci image: %v", err)
}

// Get the image URL and replace the registry address with the authenticated one
authImageID := image.OCIImageID().WithoutDigest()
authImageID.Registry = fmt.Sprintf("%s.%s", nomostest.TestRegistryServerAuthenticated, nomostest.TestRegistryNamespace)

nt.T.Log("Set the RootSync to sync from the authenticated registry without providing credentials")
rs := nt.RootSyncObjectOCI(rootSyncID.Name, authImageID, "", image.Digest)
rs.Spec.Oci.Auth = configsync.AuthNone
nt.Must(nt.KubeClient.Apply(rs))
nt.Must(nt.Watcher.WatchForRootSyncSourceError(rootSyncID.Name, status.SourceErrorCode, "Authorization Required"))

nt.T.Log("Set the RootSync to sync from the authenticated registry with credentials")
nt.MustMergePatch(rs, fmt.Sprintf(`{"spec": {"oci": {"auth": "token", "secretRef": {"name": "%s"}}}}`,
secretName))

nt.Must(nt.WatchForAllSyncs())

nt.T.Log("Validate Role from OCI image exists")
nt.Must(nt.Validate(bookinfoRole.Name, "default", &rbacv1.Role{}))
}

/*
// TestDigestUpdateInAR tests if the oci-sync container can pull new digests with the same tag.
// The test requires permission to push new image to `config-sync-test-public` in the Artifact Registry,
Expand Down
26 changes: 24 additions & 2 deletions manifests/reposync-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,13 @@ spec:
auth:
description: |-
auth is the type of secret configured for access to the OCI package.
Must be one of gcenode, gcpserviceaccount, k8sserviceaccount, or none.
Must be one of gcenode, gcpserviceaccount, k8sserviceaccount, token, or none.
The validation of this is case-sensitive. Required.
enum:
- gcenode
- gcpserviceaccount
- k8sserviceaccount
- token
- none
type: string
caCertSecretRef:
Expand Down Expand Up @@ -343,6 +344,16 @@ spec:
granularity, and it is easy to introduce a bug where it looks like the
code is dealing with seconds but its actually nanoseconds (or vice versa).
type: string
secretRef:
description: |-
secretRef holds the authentication secret for accessing
the OCI repository.
nullable: true
properties:
name:
description: name represents the secret name.
type: string
type: object
required:
- auth
- image
Expand Down Expand Up @@ -1405,12 +1416,13 @@ spec:
auth:
description: |-
auth is the type of secret configured for access to the OCI package.
Must be one of gcenode, gcpserviceaccount, k8sserviceaccount, or none.
Must be one of gcenode, gcpserviceaccount, k8sserviceaccount, token, or none.
The validation of this is case-sensitive. Required.
enum:
- gcenode
- gcpserviceaccount
- k8sserviceaccount
- token
- none
type: string
caCertSecretRef:
Expand Down Expand Up @@ -1456,6 +1468,16 @@ spec:
granularity, and it is easy to introduce a bug where it looks like the
code is dealing with seconds but its actually nanoseconds (or vice versa).
type: string
secretRef:
description: |-
secretRef holds the authentication secret for accessing
the OCI repository.
nullable: true
properties:
name:
description: name represents the secret name.
type: string
type: object
required:
- auth
- image
Expand Down
Loading