Skip to content

Commit 67a8674

Browse files
authored
feat(marketplace): community marketplace for External-Apps repositories (#3940)
## What this PR does Implements the community marketplace for Cozystack External-Apps repositories, per the approved design proposals **community#18** and **community#23**, building on the **community#12** authoring workflow. Anyone can publish an External-Apps repository over OCI, discover it, connect it to a cluster, and install its apps the same way official packages are installed, with a publication gate that keeps the index safe. Additive by construction: the existing PackageSource / External-Apps pipeline is untouched, no CRD is added or migrated, and existing installs and upgrades are unaffected. Per-package version pinning stays out of scope, siding with #18. Components: - **cozypkg CLI** — new `validate`, `init`, `push`, `tap`/`untap`, `search`. Scaffold a repository, publish it as an OCI artifact, register it on a cluster in one command (optional pull secret for private repositories), and discover packages through a metadata-only community index. `validate` lints a repository offline (schema, chart paths, `helm lint`, dependsOn, ApplicationDefinition wiring, privileged components); with `--require-signature` it verifies a keyless cosign signature. - **Marketplace backend** — a read/write, cluster-scoped `Tap` resource in the existing `core.cozystack.io` group (a computed virtual resource: no new group, no CRD, no etcd). Lists connected repositories and the packages they expose, connects a repository (recording intent as a labeled Flux `OCIRepository`), and disconnects one. Catalog metadata only, never a pull credential. - **Tap materializer** — a controller in cozystack-controller that, once source-controller has pulled a connected repository's artifact, fetches it over the Flux artifact URL (digest-verified, size-bounded, path-traversal-guarded) and materializes the PackageSource(s) it carries under the `community.` prefix, so a dashboard-connected tap becomes installable without the apiserver pulling artifacts. - **Publication gate** — a template for a `cozystack/packages-index` repository: a metadata-only entry format and a GitHub Actions gate that validates every changed entry with `cozypkg validate` (mandatory cosign against the entry's recorded identity) under a two-lane merge policy (owner version bump auto-merges on signed validation; a new entry or a security-relevant edit goes to maintainer review). - **Dashboard** — a Repositories view in the console marketplace, backed by `Tap`, to browse and connect/disconnect repositories. Access control: reading the catalog is open to authenticated users (metadata only); connecting and disconnecting require cluster-admin via the delegated apiserver RBAC. Verification: `go build` / `go vet` / `go test`, dashboard `pnpm typecheck` + eslint, and the gate's `shellcheck` are all green. Server-side authz denial and the end-to-end connect→materialize→disconnect round-trip are not exercised here (hermetic change) and are the main follow-up. ### Screenshots The dashboard Repositories view is included, but screenshots are not attached: rendering it needs a live cluster running the new `Tap` apiserver, which this static change cannot stand up. Tracked as a follow-up (a cozystack-pr-test run on a disposable cluster); this is one reason the PR is a draft. ### Downstream repositories Walked the trigger map against the diff. This is additive tooling plus a new virtual resource; it does not force a change in any listed repository, and `external-apps-example` keeps working on the existing chart model. Left every box empty (rather than ticking "No downstream repository is affected") so a maintainer can confirm — in particular whether `external-apps-example` should later gain a PackageSource-based example that `cozypkg init` now scaffolds. - [ ] No downstream repository is affected by this change - [ ] [cozystack/website](https://github.com/cozystack/website) - follow-up: - [ ] [cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack) - follow-up: - [ ] [cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack) - follow-up: - [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up: - [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up: - [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) - follow-up: - [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) - follow-up: - [ ] [cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server) - follow-up: - [ ] [cozystack/external-apps-example](https://github.com/cozystack/external-apps-example) - follow-up: - [ ] [cozystack/examples](https://github.com/cozystack/examples) - follow-up: ### Release note ```release-note feat(marketplace): add a community marketplace for External-Apps repositories — cozypkg validate/init/push/tap/untap/search, a cluster-scoped Tap API in core.cozystack.io with an operator-side artifact materializer, keyless cosign verification, a two-lane publication gate, and a dashboard Repositories view ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added Marketplace repository connections (“Taps”) with dashboard management, readiness status, package catalogs, and optional credentials. - Added repository search by name, source, and package metadata. - Added a searchable community package index with version-pinned resolution. - Added `cozypkg init`, `push`, `tap`, and `untap` commands. - Added offline repository and artifact validation with optional chart linting and signature checks. - Added safeguards requiring confirmation before installing packages with privileged components. - Added automatic synchronization of tapped package catalogs. - **Documentation** - Added community package index guidance, validation workflow, and an example entry. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents cd675e1 + 109ee1d commit 67a8674

53 files changed

Lines changed: 6475 additions & 54 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎api/api-rules/cozystack_api_violation_exceptions.list‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1,ApplicationStatus,Conditions
22
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/core/v1alpha1,OptionSpec,Items
3+
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/core/v1alpha1,TapPackage,Tags
4+
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/core/v1alpha1,TapSpec,Packages
35
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/core/v1alpha1,TenantModuleStatus,Conditions
46
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/sdn/v1alpha1,EgressRule,ToApp
57
API rule violation: list_type_missing,github.com/cozystack/cozystack/pkg/apis/sdn/v1alpha1,EgressRule,ToCIDR

‎cmd/cozypkg/cmd/add.go‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,25 @@ import (
2525
"strconv"
2626
"strings"
2727

28-
"github.com/spf13/cobra"
2928
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
29+
"github.com/spf13/cobra"
3030
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3131
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3232
"k8s.io/apimachinery/pkg/runtime"
3333
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
3434
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
3535
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
36+
_ "k8s.io/client-go/plugin/pkg/client/auth"
3637
"k8s.io/client-go/rest"
3738
"k8s.io/client-go/tools/clientcmd"
3839
ctrl "sigs.k8s.io/controller-runtime"
3940
"sigs.k8s.io/controller-runtime/pkg/client"
40-
_ "k8s.io/client-go/plugin/pkg/client/auth"
4141
)
4242

4343
var addCmdFlags struct {
44-
files []string
45-
kubeconfig string
44+
files []string
45+
kubeconfig string
46+
allowPrivileged bool
4647
}
4748

4849
var addCmd = &cobra.Command{
@@ -59,7 +60,7 @@ Multiple -f flags can be specified, and they can point to files or directories.`
5960
// Collect package names from arguments and files
6061
packageNames := make(map[string]bool)
6162
packagesFromFiles := make(map[string]string) // packageName -> filePath
62-
63+
6364
for _, arg := range args {
6465
packageNames[arg] = true
6566
}
@@ -119,7 +120,7 @@ Multiple -f flags can be specified, and they can point to files or directories.`
119120
}
120121
// If failed, fall back to interactive installation
121122
}
122-
123+
123124
// Interactive installation from PackageSource
124125
if err := installPackage(ctx, k8sClient, packageName); err != nil {
125126
return err
@@ -178,7 +179,7 @@ func readPackagesFromYAMLFile(filePath string) ([]string, error) {
178179

179180
// Split YAML documents (in case of multiple resources)
180181
documents := strings.Split(string(data), "---")
181-
182+
182183
for _, doc := range documents {
183184
doc = strings.TrimSpace(doc)
184185
if doc == "" {
@@ -240,7 +241,7 @@ func buildDependencyTree(ctx context.Context, k8sClient client.Client, rootName
240241
tree := make(map[string][]string)
241242
dependencyRequesters := make(map[string]string) // dep -> requester
242243
visited := make(map[string]bool)
243-
244+
244245
// Ensure root is in tree even if it has no dependencies
245246
tree[rootName] = []string{}
246247

@@ -354,7 +355,7 @@ func createPackageFromFile(ctx context.Context, k8sClient client.Client, filePat
354355

355356
// Split YAML documents
356357
documents := strings.Split(string(data), "---")
357-
358+
358359
for _, doc := range documents {
359360
doc = strings.TrimSpace(doc)
360361
if doc == "" {
@@ -462,6 +463,18 @@ func installPackage(ctx context.Context, k8sClient client.Client, packageSourceN
462463
return fmt.Errorf("failed to select variant for %s: %w", pkgName, err)
463464
}
464465

466+
// A privileged component runs with elevated access; require an explicit
467+
// confirmation (or --allow-privileged) before installing it.
468+
if privileged := privilegedComponents(ps, variant); len(privileged) > 0 && !addCmdFlags.allowPrivileged {
469+
ok, err := confirmPrivileged(pkgName, variant, privileged)
470+
if err != nil {
471+
return err
472+
}
473+
if !ok {
474+
return fmt.Errorf("aborted: %s variant %s has privileged component(s) %v; re-run with --allow-privileged to install", pkgName, variant, privileged)
475+
}
476+
}
477+
465478
packageVariants[pkgName] = variant
466479
}
467480

@@ -541,9 +554,44 @@ func selectVariantInteractive(ps *cozyv1alpha1.PackageSource) (string, error) {
541554
}
542555
}
543556

557+
// privilegedComponents returns the names of components in the given variant
558+
// that declare install.privileged: true.
559+
func privilegedComponents(ps *cozyv1alpha1.PackageSource, variantName string) []string {
560+
var names []string
561+
for _, v := range ps.Spec.Variants {
562+
if v.Name != variantName {
563+
continue
564+
}
565+
for _, c := range v.Components {
566+
if c.Install != nil && c.Install.Privileged {
567+
names = append(names, c.Name)
568+
}
569+
}
570+
}
571+
return names
572+
}
573+
574+
// confirmPrivileged prompts the operator to confirm installing privileged
575+
// components. It defaults to No.
576+
func confirmPrivileged(pkgName, variant string, components []string) (bool, error) {
577+
fmt.Fprintf(os.Stderr, "\n⚠ %s variant %s installs privileged component(s): %s\n", pkgName, variant, strings.Join(components, ", "))
578+
fmt.Fprint(os.Stderr, "These run with elevated cluster access. Install anyway? [y/N]: ")
579+
reader := bufio.NewReader(os.Stdin)
580+
input, err := reader.ReadString('\n')
581+
if err != nil {
582+
return false, fmt.Errorf("failed to read confirmation: %w", err)
583+
}
584+
switch strings.ToLower(strings.TrimSpace(input)) {
585+
case "y", "yes":
586+
return true, nil
587+
default:
588+
return false, nil
589+
}
590+
}
591+
544592
func init() {
545593
rootCmd.AddCommand(addCmd)
594+
addCmd.Flags().BoolVar(&addCmdFlags.allowPrivileged, "allow-privileged", false, "Install privileged components without an interactive confirmation")
546595
addCmd.Flags().StringArrayVarP(&addCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
547596
addCmd.Flags().StringVar(&addCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
548597
}
549-

‎cmd/cozypkg/cmd/add_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Copyright 2025 The Cozystack Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cmd
18+
19+
import (
20+
"testing"
21+
22+
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
23+
)
24+
25+
func TestPrivilegedComponents(t *testing.T) {
26+
ps := &cozyv1alpha1.PackageSource{
27+
Spec: cozyv1alpha1.PackageSourceSpec{
28+
Variants: []cozyv1alpha1.Variant{
29+
{
30+
Name: "default",
31+
Components: []cozyv1alpha1.Component{
32+
{Name: "app", Path: "apps/app"},
33+
{Name: "risky", Path: "apps/risky", Install: &cozyv1alpha1.ComponentInstall{Privileged: true}},
34+
},
35+
},
36+
{
37+
Name: "safe",
38+
Components: []cozyv1alpha1.Component{{Name: "app", Path: "apps/app"}},
39+
},
40+
},
41+
},
42+
}
43+
44+
if got := privilegedComponents(ps, "default"); len(got) != 1 || got[0] != "risky" {
45+
t.Errorf("default variant privileged = %v, want [risky]", got)
46+
}
47+
if got := privilegedComponents(ps, "safe"); len(got) != 0 {
48+
t.Errorf("safe variant should have no privileged components, got %v", got)
49+
}
50+
if got := privilegedComponents(ps, "nonexistent"); len(got) != 0 {
51+
t.Errorf("unknown variant should return none, got %v", got)
52+
}
53+
}

0 commit comments

Comments
 (0)