[Backport release-1.7] fix(marketplace): do not install a connected repository's components on connect (#5117)

# Description
Backport of #5071 to `release-1.7`.
This commit is contained in:
myasnikovdaniil authored and GitHub committed 2026-10-27 12:14:59 +05:00
commit 315b335cf7
8 files changed
+124 -176

No files matched your search

+7 -6
View File
@@ -529,12 +529,13 @@ func installPackage(ctx context.Context, k8sClient client.Client, packageSourceN
return fmt.Errorf("failed to create Package %s: %w", pkgName, err)
}
if !created {
// The materializer auto-created the registration Package between the
// List above and this Create, so this run did not take ownership of it
// (its markers are not shed). Re-run so the reopen path pins it -- for a
// non-default variant to apply the choice, and for the default variant so
// a later privileged flip does not de-register this deliberate install.
_, _ = fmt.Fprintf(os.Stderr, "Package %s was just auto-registered; re-run 'cozypkg add %s' to install variant %q as your own\n", pkgName, pkgName, variant)
// A Package of this name appeared between the List above and this Create
// (a concurrent add, or a tap-managed registration left by an older
// Cozystack), so this run did not take ownership of it (its markers are
// not shed). Re-run so the reopen path pins it -- for a non-default
// variant to apply the choice, and for the default variant so a later
// privileged flip does not de-register this deliberate install.
_, _ = fmt.Fprintf(os.Stderr, "Package %s already exists; re-run 'cozypkg add %s' to install variant %q as your own\n", pkgName, pkgName, variant)
continue
}
+4 -3
View File
@@ -157,12 +157,13 @@ cozypkg validate .
cozypkg push oci://<registry>/%s:<tag>
`+"```"+`
## Connect (one command, on a Cozystack cluster)
## Connect and install (on a Cozystack cluster)
`+"```bash"+`
cozypkg tap oci://<registry>/%s:<tag>
cozypkg tap oci://<registry>/%s:<tag> # connect the repository; installs nothing
cozypkg add %s # install the app
`+"```"+`
`, psName, app, app, app, app, app),
`, psName, app, app, app, app, app, psName),
},
}
}
+33 -31
View File
@@ -183,9 +183,9 @@ func rewritePackageSourceForTap(ps *cozyv1alpha1.PackageSource, sourceName, sour
// clearMaterializedRevision removes the operator's materialized-revision
// annotation from the tap's OCIRepository, so the next reconcile does not
// short-circuit on the unchanged artifact revision and re-materializes (which
// re-creates a registration removed out-of-band). A no-op when the source or the
// annotation is absent.
// short-circuit on the unchanged artifact revision and re-materializes its
// PackageSources (recovering one removed out-of-band). A no-op when the source or
// the annotation is absent.
func clearMaterializedRevision(ctx context.Context, k8sClient client.Client, srcName string) error {
reg := &sourcev1.OCIRepository{}
if err := k8sClient.Get(ctx, client.ObjectKey{Name: srcName, Namespace: cozySystemNamespace}, reg); err != nil {
@@ -228,12 +228,11 @@ var tapCmd = &cobra.Command{
creates a Flux OCIRepository pointing at the artifact and the PackageSource(s)
the artifact carries, under the names the repository declares. A name that
clashes with a core component (or another tap) is rejected, so an external
package can never silently shadow an official one. The repository's applications
register in the catalog automatically, unless the repository declares no default
variant (then nothing is registered). A privileged install component of a
registered app is not installed until the operator confirms it with 'cozypkg add
--allow-privileged'. Tapping is idempotent. Creating cluster-scoped resources
requires cluster-admin.
package can never silently shadow an official one. Tapping installs nothing: it
makes the repository's apps available as PackageSources. Install an app with
'cozypkg add <name>'; a privileged install component is not installed until you
confirm it with 'cozypkg add --allow-privileged'. Tapping is idempotent. Creating
cluster-scoped resources requires cluster-admin.
With --secret the OCIRepository is given a pull-credential secretRef (the
admin pre-creates the Secret in cozy-system), so a private repository taps in
@@ -243,8 +242,8 @@ Trust: tap validates the artifact's structure but does NOT verify its cosign
signature. Signature verification is the community index's job at publication
time ('cozypkg validate --require-signature' in the index CI gate, pinned to
the entry's recorded identity), and can additionally be enforced at pull time
via Flux OCIRepository verification. Tapping a third-party repository runs its
charts in your management cluster: tap only sources you trust.`,
via Flux OCIRepository verification. Installing an app from a tapped repository
runs its charts in your management cluster: tap only sources you trust.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
@@ -348,13 +347,10 @@ charts in your management cluster: tap only sources you trust.`,
// Force a fresh materialization on this tap: clear the materialized-revision
// annotation so the operator does not short-circuit on the unchanged
// artifact revision. This makes a re-tap the supported recovery for a
// registration Package (or PackageSource) removed out-of-band since the last
// materialization -- e.g. garbage-collected when its PackageSource was
// deleted. Note this re-registers ANY currently-absent app the artifact
// declares, so a re-tap also undoes a `cozypkg del` of a tap-registered app;
// the two are indistinguishable (both leave the registration absent), and
// there is no per-app opt-out. A first tap has no such annotation, so this is
// a no-op then.
// PackageSource removed out-of-band since the last materialization. Tapping
// no longer installs anything, so a re-tap does NOT re-install an app removed
// with `cozypkg del`; re-install it with `cozypkg add`. A first tap has no
// such annotation, so this is a no-op then.
if err := clearMaterializedRevision(ctx, k8sClient, srcName); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not reset materialization state on OCIRepository/%s: %v\n", srcName, err)
}
@@ -363,7 +359,11 @@ charts in your management cluster: tap only sources you trust.`,
for _, n := range names {
fmt.Fprintf(cmd.OutOrStdout(), " PackageSource/%s\n", n)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The repository's applications register in the dashboard catalog automatically. A privileged install component is not installed until you confirm it with 'cozypkg add --allow-privileged'. Check the source's events if an app does not appear.\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Tapping installs nothing. Install an app with:\n")
for _, n := range names {
fmt.Fprintf(cmd.OutOrStdout(), " cozypkg add %s\n", n)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "A privileged install component is not installed until you confirm it with 'cozypkg add --allow-privileged'.\n")
return nil
},
}
@@ -371,11 +371,13 @@ charts in your management cluster: tap only sources you trust.`,
var untapCmd = &cobra.Command{
Use: "untap <packagesource-name>",
Short: "Remove a tapped repository",
Long: `Untap removes a tapped PackageSource, its Flux source, and the
tap-managed registration Package that de-registers the repository's apps from
the catalog. Running application instances are left in place but become
unmanaged until the repository is tapped again. Only tapped sources (marked
with the marketplace-tap label) can be untapped; official sources are refused.`,
Long: `Untap removes a tapped PackageSource and its Flux source. A tap-managed
registration Package left by an older Cozystack (one that registered apps on
connect) is removed too, de-registering those apps. An app you installed yourself
with 'cozypkg add' is left installed: untap refuses without --yes while such a
Package is present. Running application instances are left in place but become
unmanaged until the repository is tapped again. Only tapped sources (marked with
the marketplace-tap label) can be untapped; official sources are refused.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
k8sClient, err := newClusterClient(tapCmdFlags.kubeconfig)
@@ -404,13 +406,13 @@ func runUntap(ctx context.Context, k8sClient client.Client, name string, allowYe
srcName = ps.Spec.SourceRef.Name
}
// The registration Package (auto-created on connect, tap-managed)
// de-registers the repository's apps and is removed with the untap. Deleting
// it garbage-collects its registration HelmReleases and the
// ApplicationDefinitions they render. It is removed only when it belongs to
// THIS source (label AND source annotation); a Package a later tap created
// under a reused name, or a foreign Package, is left in place and blocks the
// untap without --yes.
// A tap-managed registration Package (left by an older Cozystack that
// registered apps on connect) de-registers the repository's apps and is
// removed with the untap. Deleting it garbage-collects its registration
// HelmReleases and the ApplicationDefinitions they render. It is removed only
// when it belongs to THIS source (label AND source annotation); a Package a
// later tap created under a reused name, or a foreign Package, is left in place
// and blocks the untap without --yes.
//
// The Package is handled BEFORE the PackageSource: a transient error here must
// abort the untap, not fall through to delete the PackageSource and OCIRepository
+1 -1
View File
@@ -42,4 +42,4 @@ Cosign verification is **mandatory** on the gate for both lanes: the trust ancho
## Trust model
Signature verification happens here, at publication time, not at tap time. `cozypkg tap` and the dashboard connect flow validate an artifact's structure but do not verify its cosign signature: a cluster that taps a listed repository trusts that this index already verified it. The verification points are this CI gate (which pins each release to the entry's recorded identity) and, optionally, Flux `OCIRepository` verification at pull time. Tapping a third-party repository runs its charts in the management cluster, so operators should tap only sources they trust or that appear in a curated index like this one.
Signature verification happens here, at publication time, not at tap time. `cozypkg tap` and the dashboard connect flow validate an artifact's structure but do not verify its cosign signature: a cluster that taps a listed repository trusts that this index already verified it. The verification points are this CI gate (which pins each release to the entry's recorded identity) and, optionally, Flux `OCIRepository` verification at pull time. Installing an app from a tapped repository runs its charts in the management cluster, so operators should tap only sources they trust or that appear in a curated index like this one.
@@ -30,7 +30,6 @@ import (
sourcev1 "github.com/fluxcd/source-controller/api/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
@@ -165,22 +164,14 @@ func (r *TapMaterializerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if err := r.Patch(ctx, ps, client.Apply, client.FieldOwner(tapFieldOwner), client.ForceOwnership); err != nil {
return ctrl.Result{}, fmt.Errorf("materialize PackageSource %s: %w", ps.GetName(), err)
}
// Register the repository's apps on connect (not on a later `cozypkg
// add`) via a tap-managed Package whose install-marked components (the
// ApplicationDefinition registrations) deploy so the apps appear in the
// catalog, mirroring how the platform ships a Package per built-in app.
// The app components carry no install block and are instantiated per user
// resource. Privileged install components are refused for a tap-labelled
// (unconfirmed) Package on several best-effort layers: the Package
// reconciler skips their HelmRelease and prunes a lingering one at the
// install site, and the PackageSource reconciler withholds their
// ExternalArtifact (collision.PrivilegedConfirmed). A benign component
// flipped to privileged in a new revision can still be materialised by
// source-watcher before these observe the flip; fully closing that race
// needs the catalog registration decoupled from install (issue #4359).
if err := r.ensureRegistration(ctx, ps, &repo); err != nil {
return ctrl.Result{}, fmt.Errorf("register apps for %s: %w", ps.GetName(), err)
}
// Connecting a repository materializes its PackageSources and nothing
// else: no Package, HelmRelease or ApplicationDefinition is created here.
// Auto-registering on connect (removed) created a tap-managed Package whose
// install-marked components Flux deployed under its cluster-admin service
// account, so a publisher's install chart ran on connect with no operator
// step (issue #4978). Installing is now explicit: `cozypkg add` creates the
// Package. Populating the catalog from PackageSource metadata without an
// install is the long-term shape in issue #4359.
applied[ps.GetName()] = true
logger.Info("materialized PackageSource from tap", "name", ps.GetName(), "tap", repo.Name)
}
@@ -230,52 +221,6 @@ func (r *TapMaterializerReconciler) failMaterialize(ctx context.Context, repo *s
return ctrl.Result{}, nil
}
// registrationOwnerRef builds the ownerReference from a registration Package to
// its PackageSource, so the Package is garbage-collected if the PackageSource is
// deleted directly.
func registrationOwnerRef(ps *cozyv1alpha1.PackageSource) metav1.OwnerReference {
return metav1.OwnerReference{
APIVersion: cozyv1alpha1.GroupVersion.String(),
Kind: "PackageSource",
Name: ps.GetName(),
UID: ps.GetUID(),
}
}
// autoRegisterSkipReason returns why a PackageSource's apps cannot be
// auto-registered on connect, or "" when they can. Only a source with no
// "default" variant is skipped (nothing to register by default). Privileged
// components are NOT a reason to skip: the source is registered and its
// privileged components are refused at install time (collision.PrivilegedConfirmed)
// until the operator confirms with 'cozypkg add --allow-privileged'.
func autoRegisterSkipReason(ps *cozyv1alpha1.PackageSource) string {
if !hasDefaultVariant(ps) {
return `it declares no "default" variant; register it manually with 'cozypkg add'`
}
return ""
}
// warnNotAutoRegistered records on the source, as a Warning Event, that a
// materialized PackageSource's apps were not auto-registered, with the reason.
// This Event is the immediate, human-facing signal.
func (r *TapMaterializerReconciler) warnNotAutoRegistered(repo *sourcev1.OCIRepository, ps *cozyv1alpha1.PackageSource, reason string) {
if r.Recorder != nil {
r.Recorder.Event(repo, corev1.EventTypeWarning, "AppsNotAutoRegistered",
fmt.Sprintf("PackageSource %s: %s", ps.GetName(), reason))
}
}
// hasDefaultVariant reports whether the PackageSource declares a variant named
// "default".
func hasDefaultVariant(ps *cozyv1alpha1.PackageSource) bool {
for i := range ps.Spec.Variants {
if ps.Spec.Variants[i].Name == "default" {
return true
}
}
return false
}
// deleteMaterialized removes every PackageSource materialized from the given tap
// source, and each one's tap-managed registration Package. A user's own Package
// (no tap label) is left in place.
@@ -445,29 +390,3 @@ func (r *TapMaterializerReconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&sourcev1.OCIRepository{}, builder.WithPredicates(isTap)).
Complete(r)
}
// ensureRegistration creates the tap-managed registration Package for a
// materialized PackageSource so its apps appear in the catalog, unless the source
// has no "default" variant (nothing to register), in which case a Warning Event
// says why. Privileged components are not gated here: the Package reconciler
// refuses them for an unconfirmed tap Package at the install site. The Package is
// created only when absent; an existing registration, or a user's own or pinned
// Package, is left as is.
func (r *TapMaterializerReconciler) ensureRegistration(ctx context.Context, ps *cozyv1alpha1.PackageSource, repo *sourcev1.OCIRepository) error {
if skip := autoRegisterSkipReason(ps); skip != "" {
r.warnNotAutoRegistered(repo, ps, "not auto-registered because "+skip)
return nil
}
pkg := &cozyv1alpha1.Package{
ObjectMeta: metav1.ObjectMeta{
Name: ps.GetName(),
Labels: map[string]string{tapconst.Label: "true"},
Annotations: map[string]string{tapconst.SourceAnnotation: repo.Name},
OwnerReferences: []metav1.OwnerReference{registrationOwnerRef(ps)},
},
}
if err := r.Create(ctx, pkg); err != nil && !apierrors.IsAlreadyExists(err) {
return err
}
return nil
}
@@ -299,24 +299,6 @@ func TestDeleteRegistrationPackageLosesRaceToHandover(t *testing.T) {
}
}
func TestHasDefaultVariant(t *testing.T) {
with := &cozyv1alpha1.PackageSource{Spec: cozyv1alpha1.PackageSourceSpec{
Variants: []cozyv1alpha1.Variant{{Name: "big"}, {Name: "default"}},
}}
if !hasDefaultVariant(with) {
t.Error("expected a default variant to be detected")
}
without := &cozyv1alpha1.PackageSource{Spec: cozyv1alpha1.PackageSourceSpec{
Variants: []cozyv1alpha1.Variant{{Name: "only"}, {Name: "second"}},
}}
if hasDefaultVariant(without) {
t.Error("expected no default variant")
}
if hasDefaultVariant(&cozyv1alpha1.PackageSource{}) {
t.Error("expected no default variant for an empty source")
}
}
func TestPrivilegedInstallComponents(t *testing.T) {
ps := &cozyv1alpha1.PackageSource{Spec: cozyv1alpha1.PackageSourceSpec{
Variants: []cozyv1alpha1.Variant{{
@@ -337,18 +319,20 @@ func TestPrivilegedInstallComponents(t *testing.T) {
}
}
// TestReconcileRegistersApps asserts the happy path: a materialized tap creates
// a tap-managed registration Package so the repository's apps register in the
// catalog on connect, without a manual `cozypkg add`.
func TestReconcileRegistersApps(t *testing.T) {
// TestReconcileCreatesNoPackageOnConnect asserts that connecting a repository
// materializes its PackageSource and nothing else: no Package (and so no
// HelmRelease or ApplicationDefinition) is created on connect. Auto-registering
// on connect ran a publisher's install chart under Flux's cluster-admin account
// with no operator step (issue #4978); installing is now explicit via
// `cozypkg add`.
func TestReconcileCreatesNoPackageOnConnect(t *testing.T) {
scheme := tapScheme(t)
data, digest := tarGz(t, map[string]string{
// samplePS declares PackageSource "example.hello" with a default variant.
"packages/core/platform/sources/hello.yaml": samplePS,
})
repo := tapRepo(true)
cl := fake.NewClientBuilder().WithScheme(scheme).
WithObjects(repo).
WithObjects(tapRepo(true)).
WithStatusSubresource(&sourcev1.OCIRepository{}).
Build()
@@ -374,25 +358,57 @@ func TestReconcileRegistersApps(t *testing.T) {
if err := cl.Get(context.Background(), client.ObjectKey{Name: "example.hello"}, &cozyv1alpha1.PackageSource{}); err != nil {
t.Fatalf("PackageSource not materialized: %v", err)
}
// A tap-managed registration Package was created for it.
// No Package is created: connect installs nothing.
var pkg cozyv1alpha1.Package
if err := cl.Get(context.Background(), client.ObjectKey{Name: "example.hello"}, &pkg); err != nil {
t.Fatalf("registration Package not created: %v", err)
if err := cl.Get(context.Background(), client.ObjectKey{Name: "example.hello"}, &pkg); !apierrors.IsNotFound(err) {
t.Fatalf("connect must create no Package, got err=%v pkg=%+v", err, pkg)
}
if pkg.GetLabels()[tapconst.Label] != "true" {
t.Errorf("registration Package must be tap-managed (labelled), got %v", pkg.GetLabels())
}
// TestReconcileDoesNotRecreateDeletedPackage asserts that a Package the admin
// removed (e.g. with `cozypkg del`) stays removed across a new source revision.
// Auto-registration used to re-create it on every revision (the second effect in
// issue #4978); with it gone the materializer never creates a Package at all.
func TestReconcileDoesNotRecreateDeletedPackage(t *testing.T) {
scheme := tapScheme(t)
data, digest := tarGz(t, map[string]string{
"packages/core/platform/sources/hello.yaml": samplePS,
})
cur := data
cl := fake.NewClientBuilder().WithScheme(scheme).
WithObjects(tapRepo(true)).
WithStatusSubresource(&sourcev1.OCIRepository{}).
Build()
r := materializerFor(scheme, cl, record.NewFakeRecorder(10), &cur)
// Connect (rev1): materializes the PackageSource, creates no Package.
setArtifact(t, cl, digest, "rev1")
if _, err := r.Reconcile(context.Background(), req()); err != nil {
t.Fatalf("reconcile rev1: %v", err)
}
if pkg.GetAnnotations()[tapconst.SourceAnnotation] != repo.Name {
t.Errorf("registration Package must record its source, got %v", pkg.GetAnnotations())
// Admin installs then removes the app: a plain user Package appears and is
// then deleted with `cozypkg del`.
userPkg := &cozyv1alpha1.Package{ObjectMeta: metav1.ObjectMeta{Name: "example.hello"}}
if err := cl.Create(context.Background(), userPkg); err != nil {
t.Fatalf("create user Package: %v", err)
}
// Variant is left empty so the Package reconciler resolves it to "default"
// deterministically, rather than pinning whichever variant was listed first.
if pkg.Spec.Variant != "" {
t.Errorf("registration Package must leave Variant empty, got %q", pkg.Spec.Variant)
if err := cl.Delete(context.Background(), userPkg); err != nil {
t.Fatalf("delete user Package: %v", err)
}
// Owned by its PackageSource so a direct delete of the PackageSource GCs it.
if len(pkg.OwnerReferences) != 1 || pkg.OwnerReferences[0].Kind != "PackageSource" || pkg.OwnerReferences[0].Name != "example.hello" {
t.Errorf("registration Package must own-ref its PackageSource, got %+v", pkg.OwnerReferences)
// A new revision must not resurrect the deleted Package.
data2, digest2 := tarGz(t, map[string]string{
"packages/core/platform/sources/hello.yaml": samplePS,
})
cur = data2
setArtifact(t, cl, digest2, "rev2")
if _, err := r.Reconcile(context.Background(), req()); err != nil {
t.Fatalf("reconcile rev2: %v", err)
}
var pkg cozyv1alpha1.Package
if err := cl.Get(context.Background(), client.ObjectKey{Name: "example.hello"}, &pkg); !apierrors.IsNotFound(err) {
t.Fatalf("a removed Package must stay removed across revisions, got err=%v pkg=%+v", err, pkg)
}
}
@@ -422,10 +438,10 @@ func setArtifact(t *testing.T, cl client.Client, digest, rev string) {
}
// TestReconcileLeavesUnmarkedPackage: an unmarked Package of the same name (a
// user's `cozypkg add`, or one from before this version) means registration is
// already satisfied. The reconcile must NOT error (which would hot-loop the whole
// tap and re-pull the artifact every backoff), NOT adopt it, and NOT delete it;
// the revision is stamped so the loop does not recur.
// user's `cozypkg add`, or one from before this version) must be left untouched
// by materialization. The reconcile must NOT error (which would hot-loop the
// whole tap and re-pull the artifact every backoff), NOT adopt it, and NOT delete
// it; the revision is stamped so the loop does not recur.
func TestReconcileLeavesUnmarkedPackage(t *testing.T) {
scheme := tapScheme(t)
data, digest := tarGz(t, map[string]string{"packages/core/platform/sources/hello.yaml": samplePS})
@@ -62,8 +62,9 @@ export function TapsPage() {
</div>
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Connecting a repository runs its charts in the management cluster. Connect only sources you
trust. The index verifies signatures at publication time; connecting does not verify them.
Connecting a repository makes its apps available but installs nothing. Install an app with{" "}
<code>cozypkg add</code>. Connect only sources you trust. The index verifies signatures at
publication time; connecting does not verify them.
</div>
<div className="mb-6 flex flex-wrap items-end gap-2 rounded-lg border border-slate-200 bg-white p-4">
@@ -206,6 +207,12 @@ function TapCard({ tap, onDisconnect }: { tap: Tap; onDisconnect: (name: string)
))}
</ul>
)}
{packages.length === 0 && (
<p className="mt-3 text-xs text-slate-500">
No apps installed from this repository yet. Install one with <code>cozypkg add</code>.
</p>
)}
</div>
)
}
+4 -2
View File
@@ -258,8 +258,10 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
// Update only the fields this API owns (spec, the tap label and name
// annotation) on the FETCHED object, so the operator's finalizer survives.
// Clear the materialized-revision annotation so a re-connect re-materializes
// and recovers a registration Package (or PackageSource) removed out-of-band
// since the last materialization, the same recovery `cozypkg tap` performs.
// and recovers a PackageSource removed out-of-band since the last
// materialization, the same recovery `cozypkg tap` performs. Connecting
// installs nothing, so this does not re-install an app removed with
// `cozypkg del`.
cur.Object["spec"] = repo.Object["spec"]
labels := cur.GetLabels()
if labels == nil {