Skip to content

Component contracts

fluxlint reads what a controller needs off the manifests it renders: the CRDs of the custom resources you apply, the Secrets its pods mount, the API groups its RBAC names. What it cannot see is the controller's code — which of those APIs it refuses to start without, which environment variables it reads and dies on.

A contract closes that gap. It is a small file that ships with the component, at the tag you pin, written by the people who know:

# fluxlint-contract.yaml — next to the kustomization a Flux Kustomization renders,
# at the root of the repository, or beside Chart.yaml in a Helm chart (subcharts'
# contracts are merged into the release's)
requires:
  crds:
    - {group: example.io, kind: Widget, version: v1}     # version optional
    - {group: cert-manager.io, kind: Certificate}
  secrets:
    - name: operator-credentials        # in the namespace the component's workloads run in
      keys: [SERVICE_URL, SERVICE_TOKEN]
    - {name: registry-ca, namespace: kube-system}
  configMaps:
    - {name: operator-settings, keys: [region]}

For every Kustomization that renders from that source, fluxlint then reports FL-C001 when

  • a required CRD is installed by nothing in the repository (error), or served only in other versions (error);
  • a required CRD is installed, but nothing orders the installer before the component (warning: the controller crash-loops until it appears);
  • a required Secret or ConfigMap has no producer, or its producer — a manifest, an ExternalSecret, a ClusterExternalSecret selecting the namespace, a cert-manager Certificate — does not define a required key (error).

CRDs that only exist at runtime are declared in the consuming repository's externals.runtimeCRDs, and count as installed by the component named there.

Environment variables and flags

The entries above say what must exist in the cluster. requires.containers says what must reach the process: the environment variables it reads and the flags it parses.

requires:
  containers:
    - name: manager                     # the container, by name, image, or both
      env: [WATCH_NAMESPACE, SERVICE_TOKEN]
      args:
        required: [--namespace]
        accepted:                       # every flag this version parses
          - --namespace
          - --leader-elect
          - --metrics-bind-address

For each container the entry selects, fluxlint reports FL-C001 when

  • a variable in env is not set. Set means a literal value, a valueFrom, or an envFrom source that defines the key (with its prefix). A literal "" counts as unset; it is what Flux leaves behind for an undefined ${var}, and most binaries treat it the same as absent. Whether a secretKeyRef resolves is FL-R001's job.
  • a flag in args.required is not on the command line (command plus args). --namespace, -namespace and --namespace=x are the same flag.
  • args.accepted is set and the command line passes a flag outside it. The binary exits on an unknown flag, so this is the check that catches a flag removed in the version you just pinned.

A container that sets neither command nor args runs the image's own command line, which fluxlint cannot see, so its flags are not checked. The same goes for a sh -c script, also behind env or an init wrapper. A bare --, as tini -- passes, is skipped. A flag the image's ENTRYPOINT passes is invisible too, so do not list it under required.

image uses the same pattern syntax as contracts.images and ignores the tag. A contract attached to an image may leave name and image out: the entry applies to the containers that run that image. A contract in a Git repository or chart must set one of them, or the entry is ignored with a note. Applying it to every container would hold a sidecar such as kube-rbac-proxy to the operator's flags.

Where a contract lives

fluxlint looks in three places and merges what it finds.

Place Use it when
next to the manifests, in the Git repository or OCI artifact a Kustomization renders you publish the manifests
beside Chart.yaml you publish a chart. Subchart contracts are merged in.
attached to the container image you publish the image, and others write the manifests

A contract in a Flux OCI artifact needs nothing special. flux push artifact packs the directory, and fluxlint-contract.yaml goes with it.

Attach the contract to the image

The image is where the code is, so it is the most reliable carrier. A contract attached to operator:v1.2.3 cannot describe another version, and it reaches everyone who deploys the image, whether they use your chart, your kustomize base or their own manifests.

Push it from the pipeline that pushes the image:

docker push registry.example.com/platform/operator:v1.2.3
fluxlint contract push -f fluxlint-contract.yaml registry.example.com/platform/operator:v1.2.3
attached fluxlint-contract.yaml to registry.example.com/platform/operator:v1.2.3
registry.example.com/platform/operator@sha256:6c3c…

The contract is stored as an OCI artifact that refers to the image, the same mechanism cosign signatures and SBOMs use. It does not change the image or its digest. Registries without the referrers API work too. Credentials come from your Docker config. fluxlint contract push refuses a file it could not read back.

Read one with:

fluxlint contract pull registry.example.com/platform/operator:v1.2.3

In the repository that deploys the image, say which images to ask about:

# .fluxlint.yaml
contracts:
  images:
    - registry.example.com/platform/*

fluxlint then looks for a contract on every rendered container image that matches a pattern. It asks only about matching images, so it queries your registry about your images and nobody else's. The answer is cached with the other sources, so later runs and --offline runs need no network. A tag can move, so --refresh asks again. An image pinned by digest is never asked about twice.

A Secret or ConfigMap that such a contract names without a namespace is expected in the namespace where the image runs.

Keeping a contract true

A contract is only worth having if it cannot drift from the code. The component's own tests are the place to hold it. fluxlint publishes a small Go module for this:

go get github.com/phoban01/fluxlint/contract

It is a separate module with few dependencies. Importing it does not pull fluxlint's Kubernetes and Helm versions into your operator.

With a fake client

contracttest builds a controller-runtime fake client that contains exactly what the contract declares. It needs no API server and no test binaries, so it runs anywhere go test runs.

func TestReconcileWithinContract(t *testing.T) {
    c, err := contract.Load("../fluxlint-contract.yaml")
    if err != nil {
        t.Fatal(err)
    }
    env := contracttest.New(t, c,
        contracttest.WithScheme(scheme),                 // your API types
        contracttest.InNamespace("operator-system"),
        contracttest.WithObjects(&platformv1.Widget{ /* the object to reconcile */ }),
    )

    r := &WidgetReconciler{Client: env.Client}
    if _, err := r.Reconcile(ctx, req); err != nil {
        t.Fatal(err)
    }
}

In this cluster:

  • every Secret and ConfigMap the contract names exists, with the keys it names and no others. Code that reads a key the contract leaves out gets nothing, and fails the way it would in production.
  • a custom resource kind the contract does not name is missing. The call returns the "no matches for kind" error a cluster gives when nobody installed the CRD, and the test fails when it ends:
outside the contract: platform.example.test Sprocket is used, but requires.crds does not declare it
  • a kind declared at v1 and used at v1beta1 fails the same way.

Someone who adds a new kind or a new Secret key to the reconciler must add it to the contract to get the test green. From then on, every repository that deploys the new tag learns about the requirement from fluxlint, in its merge request, before anything is applied.

With envtest

A fake client sees the calls a reconciler makes. It does not see the watches a manager sets up when it starts, and a watch on a missing CRD is the most common reason a controller crash-loops. envtest covers that: a real API server with only the declared CRDs installed.

func TestManagerStartsWithinContract(t *testing.T) {
    c, err := contract.Load("../fluxlint-contract.yaml")
    if err != nil {
        t.Fatal(err)
    }

    // an API server with the CRDs the contract names, and no others
    env := &envtest.Environment{CRDDirectoryPaths: crdDirsFor(c.Requires.CRDs)}
    cfg, err := env.Start()
    if err != nil {
        t.Fatal(err)
    }
    defer env.Stop()

    // the real manager must start and its caches must sync
    mgr := newManager(t, cfg) // the constructor main() uses
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    go func() {
        if err := mgr.Start(ctx); err != nil {
            t.Error(err)
        }
    }()
    if !mgr.GetCache().WaitForCacheSync(ctx) {
        t.Fatal("the manager did not start with only what the contract requires")
    }
}

Use the fake client for every reconciler test, since it costs nothing. Add one envtest test for the manager if your CI can run it.

Flags and environment

Flags holds the binary's flag set to the contract. args.required must all be registered. args.accepted, when set, must equal the registered flags exactly, so a flag added to the binary and not to the contract fails, and so does one removed from the binary and kept in the contract.

func TestFlagsWithinContract(t *testing.T) {
    c, err := contract.Load("../fluxlint-contract.yaml")
    if err != nil {
        t.Fatal(err)
    }
    fs := flag.NewFlagSet("manager", flag.ContinueOnError)
    registerFlags(fs) // the function main() uses
    contracttest.Flags(t, c, "manager", contracttest.StdFlagNames(fs))
}

With pflag, collect the names with fs.VisitAll and pass them instead. The second argument selects the entry by its name or, for one written to attach to an image, by its image pattern.

WithContainer("manager") on New sets every variable the entry lists for the test, to contract-test unless WithEnvValue says otherwise. It uses t.Setenv, so the test must not call t.Parallel. A process cannot have variables taken away from it, so this shows the declared ones are enough only when the test runs in a clean environment. To prove a required flag is required, call your run(args) function with that flag removed and expect an error.

  • FL-R007 module-skew: both the component and whatever installs its CRDs are Go modules rendered from Git. If the component's go.mod requires a newer version of the API module than the tag pinned for the CRDs, fluxlint says so.
  • RBAC as a hint: API groups named in the RBAC bound to a workload's ServiceAccount count as a reason for a dependsOn on whatever installs them, so such dependencies are not reported as unjustified. RBAC also lists optional integrations, so it never demands an ordering — that is what the contract is for.