Skip to content
Merged
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
30 changes: 30 additions & 0 deletions docs/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,36 @@ a branch's dev channel (`versionRange` + `semverFilter`) wins over the lab's
source and must go — it selected a chart from the registry, not the
checkout's.

### An unreleased component chart

Any other component's chart (kagent, Substrate, muster, …) is proven the
same way by hand: push it into the lab registry through its host port and
point the component at it with a `platform.valuesFiles` overlay.

```sh
helm package /path/to/kagent/helm/kagent --version 0.9.0-dev.1
helm push kagent-0.9.0-dev.1.tgz oci://localhost:5001/charts --plain-http
```

```yaml
components:
kagent:
repository: oci://agentlab-registry:5000/charts # <clusterName>-registry, its kind-network name
insecure: true
versionRange: "0.9.0-dev.1"
```

The HelmRelease keeps the kind-network name, the one source-controller
reaches. The boot's offline render of the chart, which no resolver on the
host can name that way, goes through the port the registry publishes on the
host loopback (`platform.devRegistryPort`, 5001 by default) over plain HTTP,
and the boot log names both (`read on this host as
oci://localhost:5001/charts/kagent`). A registry that does not answer there
stops the boot before the install, naming the container to check. The lab
registry exists once a chartPath or a dev image created it, and goes with
`agentlab down`. Removing the overlay and re-running `agentlab platform`
puts the released chart back.

## Dev images

To run a component from a build of your own, build the image, and name it
Expand Down
108 changes: 94 additions & 14 deletions internal/lab/fluxreleases.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@ type fluxRelease struct {
// URL names the registry by its kind-network name, out of the host's
// reach. Empty for a chart the registry serves to the host too.
LocalChart string
// HostURL is the chart repository as this host reaches it when the
// OCIRepository names the lab registry by its kind-network name — an
// overlay's components.<name>.repository pointing at a chart pushed
// there (localizeLabRegistry): the same repository through the port the
// registry container publishes on the host loopback. The HelmRelease
// keeps the kind-network URL, the one source-controller reaches. Empty
// for a chart the host reaches as the URL names it.
HostURL string
// LabRegistry is the lab registry's container when HostURL is set, for
// the refusal of a registry that does not answer.
LabRegistry string
// PlainHTTP is the OCIRepository's spec.insecure: the registry speaks
// plain HTTP, to the host's render as to source-controller.
PlainHTTP bool
}

// hostURL is the chart repository the host renders from: HostURL where the
// URL names a registry only the cluster resolves, the URL otherwise.
func (rel fluxRelease) hostURL() string {
if rel.HostURL != "" {
return rel.HostURL
}
return rel.URL
}

// chartLabel names the chart a render of the release was for: the
Expand All @@ -73,6 +96,9 @@ func (rel fluxRelease) chartLabel(resolved string) string {
return "the local chart at " + rel.LocalChart + ", pushed as " + rel.URL + ":" + rel.Version
}
label := rel.URL + " " + rel.Version
if rel.HostURL != "" {
label += ", read on this host as " + rel.HostURL
}
if resolved != "" && resolved != rel.Version {
label += ", resolved to " + resolved
}
Expand All @@ -93,6 +119,7 @@ type fluxDoc struct {
SemverFilter string `yaml:"semverFilter"`
Tag string `yaml:"tag"`
} `yaml:"ref"`
Insecure bool `yaml:"insecure"`
ReleaseName string `yaml:"releaseName"`
ValuesFrom []yaml.Node `yaml:"valuesFrom"`
TargetNamespace string `yaml:"targetNamespace"`
Expand All @@ -117,7 +144,10 @@ type fluxDoc struct {
// resolve offline.
func fluxReleases(manifests string) ([]fluxRelease, error) {
dec := yaml.NewDecoder(strings.NewReader(manifests))
type source struct{ url, version, filter string }
type source struct {
url, version, filter string
insecure bool
}
sources := map[string]source{}
var releases []fluxDoc
for {
Expand All @@ -134,7 +164,7 @@ func fluxReleases(manifests string) ([]fluxRelease, error) {
if version == "" {
version = doc.Spec.Ref.Tag
}
sources[doc.Metadata.Namespace+"/"+doc.Metadata.Name] = source{doc.Spec.URL, version, doc.Spec.Ref.SemverFilter}
sources[doc.Metadata.Namespace+"/"+doc.Metadata.Name] = source{doc.Spec.URL, version, doc.Spec.Ref.SemverFilter, doc.Spec.Insecure}
case "HelmRelease":
releases = append(releases, doc)
}
Expand All @@ -152,7 +182,7 @@ func fluxReleases(manifests string) ([]fluxRelease, error) {
if !ok {
continue
}
rel := fluxRelease{Name: hr.Spec.ReleaseName, Namespace: hr.Spec.TargetNamespace, URL: src.url, Version: src.version, Filter: src.filter}
rel := fluxRelease{Name: hr.Spec.ReleaseName, Namespace: hr.Spec.TargetNamespace, URL: src.url, Version: src.version, Filter: src.filter, PlainHTTP: src.insecure}
if rel.Name == "" {
rel.Name = hr.Metadata.Name
}
Expand Down Expand Up @@ -306,21 +336,22 @@ func renderFluxRelease(rel fluxRelease, apiVersions []string) (rendered, version
}
ref := rel.LocalChart
if ref == "" {
ref = rel.URL
if version, err = resolveComponentVersion(rel.URL, rel.Version, rel.Filter); err != nil {
ref = rel.hostURL()
if version, err = resolveComponentVersion(ref, rel.Version, rel.Filter, rel.PlainHTTP); err != nil {
return "", "", err
}
}
rendered, resolved, err := helmTemplate(rel.Namespace, rel.Name, ref, version, vals, apiVersions)
rendered, resolved, err := helmTemplateFrom(rel.Namespace, rel.Name, ref, version, rel.PlainHTTP, vals, apiVersions)
if resolved != "" {
version = resolved
}
return rendered, version, err
}

// listChartTags lists a chart repository's tags for the component
// resolution (helmChartTags); a variable so tests answer for the registry.
var listChartTags = helmChartTags
// resolution (helmChartTagsFrom); a variable so tests answer for the
// registry.
var listChartTags = helmChartTagsFrom

// resolveComponentVersion is the version a component chart is rendered at,
// resolved the way source-controller resolves the OCIRepository. Without a
Expand All @@ -331,11 +362,11 @@ var listChartTags = helmChartTags
// pick is made here: the repository's tags, those the filter's regexp
// matches, those the range admits, the highest. No such tag is an error the
// caller reports and skips: the node pulls that component's images itself.
func resolveComponentVersion(url, versionRange, filter string) (string, error) {
func resolveComponentVersion(url, versionRange, filter string, plainHTTP bool) (string, error) {
if filter == "" {
return versionRange, nil
}
tags, err := listChartTags(url)
tags, err := listChartTags(url, plainHTTP)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -395,6 +426,7 @@ func renderPlatformRoster(chart platformChart, values map[string]any) (*platform
return nil, err
}
localizeConnectivity(releases, chart)
localizeLabRegistry(releases, chart.labRegistry)
return &platformRoster{chart: chart, manifest: meta, releases: releases}, nil
}

Expand All @@ -416,6 +448,43 @@ func localizeConnectivity(releases []fluxRelease, chart platformChart) {
}
}

// labRegistryAddress is the lab registry (devimages.go) under its two
// names: the kind-network endpoint pods and source-controller reach it by
// (<cluster>-registry:5000), which no resolver on the host knows, and the
// port its container publishes on the host loopback
// (platform.devRegistryPort) — the address the host pushes charts and
// images through.
type labRegistryAddress struct {
container, endpoint, host string
}

func labRegistryAddressFor(cfg *config.Config) labRegistryAddress {
return labRegistryAddress{container: devRegistryName(cfg), endpoint: devRegistryEndpoint(cfg), host: devRegistryHost(cfg)}
}

// localizeLabRegistry points the host's render of every release whose
// OCIRepository names the lab registry by its kind-network name at the
// registry's host-published port, over plain HTTP — the registry speaks
// nothing else — the way the connectivity chart of a chartPath lab is pushed
// (connectivity.go). That is how an unreleased component chart is proven: an
// overlay's components.<name>.repository naming
// oci://<cluster>-registry:5000/charts with insecure: true, the chart pushed
// there. The release's URL stays as it is, the HelmRelease's.
func localizeLabRegistry(releases []fluxRelease, registry labRegistryAddress) {
if registry.endpoint == "" {
return
}
for i := range releases {
rel := &releases[i]
if rel.LocalChart != "" || registryHost(rel.URL) != registry.endpoint {
continue
}
rel.HostURL = strings.Replace(rel.URL, registry.endpoint, registry.host, 1)
rel.LabRegistry = registry.container
rel.PlainHTTP = true
}
}

// has reports whether the roster carries a component release of that name.
func (r *platformRoster) has(release string) bool {
if r == nil {
Expand Down Expand Up @@ -668,14 +737,25 @@ func retryUnreachable(what string, render func() error) error {
func unreachableComponentsError(unreachable []renderFailure) error {
var b strings.Builder
fmt.Fprintf(&b, "%d component chart(s) could not be rendered, the registry did not answer through the retries:\n", len(unreachable))
var hosts []string
var hosts, labRegistries []string
for _, f := range unreachable {
fmt.Fprintf(&b, "\n%s\n", indent(strings.TrimSpace(f.err.Error()), " "))
if f.rel.LabRegistry != "" {
labRegistries = append(labRegistries, fmt.Sprintf("%s (read on this host as %s)", f.rel.LabRegistry, registryHost(f.rel.HostURL)))
continue
}
hosts = append(hosts, registryHost(f.rel.URL))
}
slices.Sort(hosts)
fmt.Fprintf(&b, "\nThe install is not started: the lab's patches for a component (the %s sidecar, platform.devImages) are read off its render, and without one it would run unpatched. Check this host's network and DNS for %s, then run `agentlab platform` again",
dexLocalhostContainer, strings.Join(slices.Compact(hosts), ", "))
fmt.Fprintf(&b, "\nThe install is not started: the lab's patches for a component (the %s sidecar, platform.devImages) are read off its render, and without one it would run unpatched.", dexLocalhostContainer)
if len(hosts) > 0 {
slices.Sort(hosts)
fmt.Fprintf(&b, " Check this host's network and DNS for %s.", strings.Join(slices.Compact(hosts), ", "))
}
if len(labRegistries) > 0 {
slices.Sort(labRegistries)
fmt.Fprintf(&b, " The lab registry %s does not answer: check that its container runs (`docker ps -a --filter name=<container>`) and publishes platform.devRegistryPort, and that the chart was pushed there.", strings.Join(slices.Compact(labRegistries), ", "))
}
b.WriteString(" Then run `agentlab platform` again")
return errors.New(b.String())
}

Expand Down
10 changes: 5 additions & 5 deletions internal/lab/fluxreleases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ func TestResolveComponentVersion(t *testing.T) {
"not-a-version",
}
var listed []string
t.Cleanup(func() { listChartTags = helmChartTags })
listChartTags = func(url string) ([]string, error) {
t.Cleanup(func() { listChartTags = helmChartTagsFrom })
listChartTags = func(url string, _ bool) ([]string, error) {
listed = append(listed, url)
if strings.HasSuffix(url, "/unreachable") {
return nil, errors.New("registry down")
Expand All @@ -180,7 +180,7 @@ func TestResolveComponentVersion(t *testing.T) {
{name: "the registry cannot be listed", url: repo + "/unreachable", versionRange: devRange, filter: filter, wantErr: "registry down"},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveComponentVersion(tc.url, tc.versionRange, tc.filter)
got, err := resolveComponentVersion(tc.url, tc.versionRange, tc.filter, false)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("got %q, %v; want an error containing %q", got, err, tc.wantErr)
Expand Down Expand Up @@ -534,11 +534,11 @@ func TestLocalizeConnectivity(t *testing.T) {
func TestRenderFluxReleaseFromTheLocalChart(t *testing.T) {
dir := isolateHelm(t)
chartDir := writeClosedSchemaChart(t, dir)
listChartTags = func(string) ([]string, error) {
listChartTags = func(string, bool) ([]string, error) {
t.Fatal("the registry was listed for a chart rendered from a directory")
return nil, nil
}
t.Cleanup(func() { listChartTags = helmChartTags })
t.Cleanup(func() { listChartTags = helmChartTagsFrom })

rel := fluxRelease{
Name: "closedchart", Namespace: "default",
Expand Down
33 changes: 27 additions & 6 deletions internal/lab/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func newHelmOp(namespace string) (*helmOp, error) {
if err := cfg.Init(labRESTClientGetter(namespace), namespace, helmDriver); err != nil {
return nil, fmt.Errorf("initialising the embedded Helm: %w", err)
}
rc, err := newHelmRegistryClient(settings, log)
rc, err := newHelmRegistryClient(settings, log, false)
if err != nil {
return nil, err
}
Expand All @@ -215,13 +215,19 @@ func newHelmOp(namespace string) (*helmOp, error) {
// registry cache on, credentials from Helm's registry config file (anonymous
// where there are none — gsoci and ghcr hand out pull and tag-list tokens
// without any).
func newHelmRegistryClient(settings *cli.EnvSettings, log *helmLog) (*registry.Client, error) {
rc, err := registry.NewClient(
// A plain-HTTP client is for a registry without TLS (an OCIRepository's
// spec.insecure, the lab registry).
func newHelmRegistryClient(settings *cli.EnvSettings, log *helmLog, plainHTTP bool) (*registry.Client, error) {
opts := []registry.ClientOption{
registry.ClientOptDebug(settings.Debug),
registry.ClientOptEnableCache(true),
registry.ClientOptWriter(log),
registry.ClientOptCredentialsFile(settings.RegistryConfig),
)
}
if plainHTTP {
opts = append(opts, registry.ClientOptPlainHTTP())
}
rc, err := registry.NewClient(opts...)
if err != nil {
return nil, fmt.Errorf("creating the OCI registry client: %w", err)
}
Expand All @@ -233,10 +239,14 @@ func newHelmRegistryClient(settings *cli.EnvSettings, log *helmLog) (*registry.C
// does for an OCIRepository too): every tag that parses as a semver version,
// highest first — a `+` build metadata separator spelled `_` in the registry
// comes back as `+`. Needs no cluster: only the registry is contacted.
func helmChartTags(ref string) ([]string, error) {
func helmChartTags(ref string) ([]string, error) { return helmChartTagsFrom(ref, false) }

// helmChartTagsFrom is helmChartTags, over plain HTTP where the registry
// speaks no TLS.
func helmChartTagsFrom(ref string, plainHTTP bool) ([]string, error) {
log := newHelmLog()
settings := helmSettings()
rc, err := newHelmRegistryClient(settings, log)
rc, err := newHelmRegistryClient(settings, log, plainHTTP)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -713,10 +723,21 @@ func chartDirVersion(dir string) string {
// version reported is the loaded chart's — the tag a range resolved to —
// known from the moment the chart is loaded, so a failed render names it too.
func helmTemplate(namespace, releaseName, ref, version string, vals map[string]any, apiVersions []string) (rendered, resolved string, err error) {
return helmTemplateFrom(namespace, releaseName, ref, version, false, vals, apiVersions)
}

// helmTemplateFrom is helmTemplate, pulling the chart over plain HTTP where
// the registry speaks no TLS (`--plain-http`).
func helmTemplateFrom(namespace, releaseName, ref, version string, plainHTTP bool, vals map[string]any, apiVersions []string) (rendered, resolved string, err error) {
h, err := newHelmOp(namespace)
if err != nil {
return "", "", err
}
if plainHTTP {
if h.cfg.RegistryClient, err = newHelmRegistryClient(h.settings, h.log, true); err != nil {
return "", "", err
}
}
invocation := fmt.Sprintf("template %s %s -n %s", releaseName, helmChartLabel(ref, version), namespace)
install := action.NewInstall(h.cfg)
install.DryRunStrategy = action.DryRunClient
Expand Down
Loading
Loading