Key takeaways
- provider-ovh 2.19.1 drops the Terraform CLI entirely. All 168 managed resources per API scope reconcile in-process, in the controller, as Go function calls.
- The image no longer carries the
terraformbinary, the native OVHcloud provider binary,bash, or a writable/terraformtree. Noterraform init, no workspace on disk, no registry egress at runtime.- It is a patch number carrying breaking CRD changes. Read the migration section before you upgrade — one of them will reject existing objects.
Version 2.19.1 of provider-ovh, our Crossplane provider for OVHcloud, was released on 3 September 2026. It tracks the same OVHcloud Terraform provider 2.19.0 as the release before it, and brings the provider to 168 managed resources per API scope and 341 CRDs.
The upstream surface did not move. What moved is how the provider talks to OVHcloud at all — and it is the largest architectural change the project has had.
What a Crossplane provider built on upjet actually did
upjet generates a Crossplane provider from a Terraform provider. Historically it did that by running Terraform. For every managed object, on every reconcile, the controller would:
- Create a workspace directory on disk and write a
.tffile and a state file into it. - Fork and exec
terraform init, resolving the OVHcloud provider from a local filesystem mirror baked into the image. - Fork and exec
terraform plan, then parse the JSON output to decide whether the object was up to date. - Fork and exec
terraform applywhen it was not.
That works. It also means a Kubernetes controller whose real execution engine is a subprocess, whose real state is a directory tree, and whose image has to ship two extra binaries to function.
Here is what the 2.19.0 image needed just to boot:
FROM alpine:3.17.1
RUN apk --no-cache add ca-certificates bash
ENV PLUGIN_DIR=/terraform/provider-mirror/registry.terraform.io/${TERRAFORM_PROVIDER_SOURCE}/...
ENV TF_CLI_CONFIG_FILE=/terraform/.terraformrc
ADD https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_..._${TARGETOS}_${TARGETARCH}.zip /tmp
ADD ${TERRAFORM_PROVIDER_DOWNLOAD_URL_PREFIX}/..._${TARGETOS}_${TARGETARCH}.zip /tmp
RUN unzip ... -d /usr/local/bin \
&& chmod +x /usr/local/bin/terraform \
&& unzip ... -d ${PLUGIN_DIR} \
&& chown -R ${USER_ID}:${USER_ID} /terraform
A shell, a Terraform distribution, a vendored provider plugin, and a writable tree owned by the runtime user.
What 2.19.1 does instead
upjet has supported two in-process reconciliation modes for a while: one that calls a Terraform Plugin SDK v2 provider's CRUD functions directly, and one that speaks the Plugin Framework's interfaces in the same process. 2.19.1 moves every resource onto them.
106 resource types go through the SDKv2 path. 70 go through the Plugin Framework path. Zero go through the CLI.
There is no subprocess. Observe, Create, Update and Delete are Go calls into the OVHcloud provider's own code, linked into the controller binary. The whole Dockerfile is now this:
FROM alpine:3.24.1
RUN apk --no-cache add ca-certificates
ADD "bin/${TARGETOS}_${TARGETARCH}/provider" /usr/local/bin/provider
## Terraform provider metadata consumed by the controller at startup.
## All resources are reconciled in-process (Terraform Plugin SDK / Framework);
## no Terraform CLI or native provider binary is shipped in this image.
ENV TERRAFORM_VERSION=${TERRAFORM_VERSION}
ENV TERRAFORM_PROVIDER_SOURCE=${TERRAFORM_PROVIDER_SOURCE}
ENV TERRAFORM_PROVIDER_VERSION=${TERRAFORM_PROVIDER_VERSION}
USER ${USER_ID}
EXPOSE 8080
ENTRYPOINT ["provider"]
One Go binary and a CA bundle. Even bash is gone.
What that buys you in production
No writable working directory. The reason the provider needed one was the workspace tree. It doesn't have one now, which removes the obstacle to running the controller with a read-only root filesystem, one of the baseline hardening steps we apply as part of platform engineering work.
No registry egress at runtime. The CLI path resolved a provider plugin on every init, from a mirror that had to exist in the image. There is nothing to resolve any more. On a cluster with egress policy on the control plane namespace, that is one fewer exception to justify.
A smaller thing to scan. Two third-party binaries left the image. Every CVE either of them accumulates used to be your CVE, whether or not the code path was reachable.
No fork/exec per reconcile. A terraform init plus a plan for every object on every reconcile loop is process creation, filesystem I/O and JSON parsing you were paying for on top of the API call you actually wanted. We are not publishing a benchmark here — a fair one needs a real fleet and real OVHcloud rate limits — but the work removed is not subtle.
The bug this shook out, and why no test caught it
Wiring the SDKv2 path exposed something worth writing down, because it is a trap for anyone doing the same migration on their own upjet provider.
On the Plugin Framework path, upjet configures the provider itself from terraform.Setup.Configuration. On the SDKv2 path it does not. It passes terraform.Setup's Meta field straight through as the provider meta to Diff, RefreshWithoutUpgrade and Apply, and never populates it. Every OVHcloud SDKv2 resource begins with a meta.(*Config) type assertion.
Leave Meta nil, and all 106 SDK-backed resources panic on their first Observe:
interface conversion: interface {} is nil, not *ovh.Config
Not a crash loop — controller-runtime recovers the panic — just every SDK resource permanently failing to reconcile, quietly, with a stack trace in the logs. No CI job in the repository would have caught it: the CRD diff job compares schemas, the build job compiles, and the deploy job installs the provider without credentials, so it never reconciles anything.
2.19.1 configures the provider and sets ps.Meta, and caches the result per ProviderConfig and per effective configuration. The caching is not premature optimisation: configuring the OVHcloud provider runs loadAndValidate and calls GET /auth/details, and Connect runs on every reconcile of every object. A rotated OAuth access token produces a different cache key, so a cached client is never reused with credentials that have since changed.
Migrating: the patch number is lying to you
2.19.1 is a patch release with breaking CRD changes in it. That was a judgement call about not stranding people on a vulnerable image, and it means the version number will not warn you. These are the three changes that can bite.
1. Integer fields are now integer, not number (506 properties)
terraform providers schema -json renders Terraform's TypeInt as number. That is what the generated CRDs carried, across 506 properties. None of those fields is ever fractional, so declaring them as integer is a correction rather than a behaviour change.
Manifests and stored objects are unaffected: 3 was valid as a number and is valid as an integer.
The real break is in the generated Go types, where the corresponding fields move from *float64 to *int64. This matters only if you import apis/{cluster,namespaced}/... directly — in a composition function, an operator, or a test harness. If you write YAML, there is nothing to do.
2. Two string maps become atomic for server-side apply
x-kubernetes-map-type: granular is no longer set on User.openstackRc (in the cloud group) and PrivateNetwork.regionsOpenstackIds (in the network group) — across forProvider, initProvider and atProvider, in both API groups, eight paths in total.
Those maps are now atomic. A client that applies the map takes ownership of it whole. Two appliers can no longer each own individual keys, and a partial apply replaces the map instead of merging into it. If you have a controller and a human both writing keys into one of those maps, that arrangement stops working the way you expect.
3. vrack CloudProject now requires projectId — and will reject existing objects
This is the one that will actually page someone. CloudProject in the vrack group gains a CEL validation rule making spec.forProvider.projectId required whenever managementPolicies includes Create, Update or *.
Objects that do not set it and carry either policy are rejected on their next update. They keep running until something touches them, which is exactly the kind of latency that turns an upgrade into an incident three weeks later.
Find them before you upgrade:
# cluster-scoped
kubectl get cloudprojects.vrack.ovh.edixos.io -o json \
| jq -r '.items[]
| select((.spec.forProvider.projectId // .spec.initProvider.projectId) == null)
| .metadata.name'
# namespaced
kubectl get cloudprojects.vrack.ovh.m.edixos.io -A -o json \
| jq -r '.items[]
| select((.spec.forProvider.projectId // .spec.initProvider.projectId) == null)
| "\(.metadata.namespace)/\(.metadata.name)"'
Anything printed needs projectId set before the upgrade, or it will fail its next write.
Security
2.19.1 also remediates everything Trivy reported against the 2.19.0 image. Go moves from 1.25.6 to 1.25.13, and the Alpine base from 3.17.1 — which is end of life — to 3.24.1, along with the affected Go dependencies.
A Trivy scan of the resulting image reports no HIGH or CRITICAL findings. govulncheck reports the provider code as affected by none.
One advisory stays visible and is worth naming rather than hiding: golang.org/x/crypto/openpgp is flagged module-wide as GO-2026-5932. There is no fixed version, and the affected package is neither imported nor called by this provider.
The build-time Terraform pin moves from 1.8.1 to 1.15.9. It is only used to generate config/schema.json and is not shipped; regenerating with 1.15.9 produces byte-identical output, so no CRD change follows from it.
Also worth knowing, from 2.19.0
If you are coming from 2.18.0, 2.19.0 added two resources in both API groups:
Instance wraps the new upstream ovh_cloud_instance, covering flavorId (which resizes in place), imageId, powerState, networks, securityGroupIds, volumeIds and shares. Two sharp edges: changing imageId rebuilds the instance and wipes the root disk, and omitting securityGroupIds applies the project's default group while an explicit empty list applies none — an instance that accepts no inbound traffic at all.
InstanceGroup wraps ovh_cloud_instance_group: a placement group with an AFFINITY or ANTI_AFFINITY policy. It is immutable — changing name, region or policy replaces it — and membership is set only through an Instance's groupId.
This is the first of the two OVHcloud instance APIs exposed as a distinct CRD. The pre-existing ProjectInstance is untouched and keeps its own schema.
Upgrading
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-ovh
spec:
package: xpkg.upbound.io/edixos/provider-ovh:v2.19.1
EOF
A sequence that avoids surprises:
- Run the two
kubectl/jqcommands above and fix everyCloudProjectthey print. - Check whether anything applies keys into
User.openstackRcorPrivateNetwork.regionsOpenstackIdsalongside another writer. - If you import the generated Go types, rebuild against 2.19.1 — the compiler will show you every
*float64that is now*int64. - Upgrade the provider.
- Confirm your managed objects return to
SYNCEDandREADY. Because reconciliation moved in-process, this is the step that validates the new path against your real credentials.
If your cluster policy pinned the provider's working directory or allowed egress specifically for the Terraform registry, both can now be removed.
Full changelog: v2.19.0…v2.19.1
Thanks
Every change in this release came from the community rather than from me.
@ekarlso contributed #65 — the move to in-process reconciliation, the removal of the Terraform CLI from the image, and the SDKv2 provider meta fix and its cache. It is a large, careful change to the most load-bearing part of the provider.
@VeSeWe made their first contribution with #63 — the Go, Alpine and dependency updates that take the image to zero HIGH and CRITICAL findings. Welcome, and thank you.
If you run provider-ovh, issues and pull requests are the fastest way to shape where it goes next.
Where to go next
If you are new to the provider, the step-by-step provider-ovh guide covers installation, credentials and a first managed cluster. Upgrading from 2.17.0 or earlier? Read 2.18.0 and 2.17.0 first — both remove fields, which is a heavier migration than this one.
We maintain provider-ovh and build OVHcloud control planes on it: Crossplane consulting · platform engineering · talk to us.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.