Skip to content

Add the join key and host resources - #7

Open
johnmaguire wants to merge 1 commit into
mainfrom
hosts-join-keys-nocode
Open

johnmaguire wants to merge 1 commit into
mainfrom
hosts-join-keys-nocode

Conversation

@johnmaguire

Copy link
Copy Markdown
Member

definednet_join_key manages a reusable enrollment secret scoped to a network,
role and tags. The shape is immutable, so changing it replaces the key; name
and expires_at update in place. The token is returned only on create and is
stored sensitive in state. Hosts enrolled with a key stay outside Terraform.

definednet_host manages a host record for lighthouses, relays and other hosts
whose identity must exist before they boot. ipv4_address can be set at create
or added in place to a host without one; ip_addresses mirrors the API's list.
Enrollment codes are minted outside Terraform.

A plan-time check on both the join key and the endpoint OIDC provider rejects
an empty auto_assign_cidrs on a certificate version 1 network, where the API
always assigns IPv4. For a join key the check matters because a replace
deletes the old key before the API could reject the new one.

The host is created through POST /v2/host-and-enrollment-code so its
single-use enrollment code lands in state (sensitive) and can feed the
machine's user data. A change to the enrollment_code_triggers map mints a
new code; the triggers of an existing host must be known at plan time,
because Terraform re-plans during apply and a value resolving equal to the
stored map would keep a consumed code after the dependent machine was
destroyed. Update sends the PUT only when a field it owns changed, since the API
writes an audit entry for every PUT, and commits the add-IP and PUT results
with the previous code and triggers, so a failed mint on a blocked host
leaves the trigger change pending. GET never returns the code, so Read carries it forward and an
imported host has none until its triggers change. The lifetime is stored
only as configured; omitted, the API applies its one-day default. The
combined route needs the hosts:enroll scope as well.

Verification

  • go test ./... green (fake API).
  • TF_ACC=1 suite passed live against the test org, 26/26, including the host and join key tests: the combined
    create route mints codes, a trigger change mints a fresh one, add-IP in place, out-of-band deletes, and join
    keys (the JOIN_KEYS flag is on for the test org).
  • Review panel: three rounds on the join key and host resources, one round plus a design check and a second
    round on the enrollment code, and a light review of the PUT skip.

Notes for the reviewer

  • The API key needs hosts:enroll in addition to the host scopes, because POST /v2/host-and-enrollment-code
    requires both.
  • Terraform re-plans during apply, so enrollment_code_triggers must be fully known on an existing host; the plan
    modifier rejects unknown values with an error that says how to fix it.
  • Join key expires_at is a UTC-only ...Z string compared as text; the API always returns UTC whole seconds.

definednet_join_key manages a reusable enrollment secret scoped to a network,
role and tags. The shape is immutable, so changing it replaces the key; name
and expires_at update in place. The token is returned only on create and is
stored sensitive in state. Hosts enrolled with a key stay outside Terraform.

definednet_host manages a host record for lighthouses, relays and other hosts
whose identity must exist before they boot. ipv4_address can be set at create
or added in place to a host without one; ip_addresses mirrors the API's list.
Enrollment codes are minted outside Terraform.

A plan-time check on both the join key and the endpoint OIDC provider rejects
an empty auto_assign_cidrs on a certificate version 1 network, where the API
always assigns IPv4. For a join key the check matters because a replace
deletes the old key before the API could reject the new one.

The host is created through POST /v2/host-and-enrollment-code so its
single-use enrollment code lands in state (sensitive) and can feed the
machine's user data. A change to the enrollment_code_triggers map mints a
new code; the triggers of an existing host must be known at plan time,
because Terraform re-plans during apply and a value resolving equal to the
stored map would keep a consumed code after the dependent machine was
destroyed. Update sends the PUT only when a field it owns changed, since the API
writes an audit entry for every PUT, and commits the add-IP and PUT results
with the previous code and triggers, so a failed mint on a blocked host
leaves the trigger change pending. GET never returns the code, so Read carries it forward and an
imported host has none until its triggers change. The lifetime is stored
only as configured; omitted, the API applies its one-day default. The
combined route needs the hosts:enroll scope as well.
@johnmaguire
johnmaguire marked this pull request as ready for review November 6, 2026 20:35

@nbrownus nbrownus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this with repro tests against the fake API. Three behavior problems (1-3 inline, all reproduced), plus a few small cleanups.

  1. The cert v1 plan check breaks existing OIDC provider configs, including destroy.
  2. Any changed plan with auto_assign_cidrs = [] needs networks:read, even when the change has nothing to do with CIDRs.
  3. Unknown enrollment_code_triggers are rejected on a host that is being replaced anyway.

The rest are nits, take them or leave them.

}
// No apply follows an unchanged plan, so a failed read must not block it
// (terraform destroy runs one before the destroy plan).
if !req.State.Raw.IsNull() && req.Plan.Raw.Equal(req.State.Raw) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return doesn't save existing configs. On main the API (and the fake) accepted auto_assign_cidrs = [] on a cert v1 network and stored IPv4 anyway, and the docs called that a perpetual diff. Config [] vs state [v4] never matches, so this check now fails every plan for those users, and terraform destroy too since it runs a normal plan first.

Repro: create the provider with [] on a v2 network, flip the fake network to CertVersion = 1 and set the stored AutoAssignCIDRs to the v4 prefix, then plan and destroy. Both fail with "Empty auto_assign_cidrs on a certificate version 1 network".

Could we only error when the resource is new or auto_assign_cidrs / network_id actually changed? Either way this needs a breaking-change note.

return
}
id := networkID.ValueString()
n, err := c.GetNetwork(ctx, id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the join key test asserts this on purpose, but it's broader than the check needs. Renaming a provider (or rotating its secret) with auto_assign_cidrs = [] now reads the network, so a key without networks:read, a transient API error, or a network deleted out of band fails an update that never touches CIDRs. Reproduced with a rename after setting missingScopes["networks:read"].

Same suggestion as above: only do the read on create, or when auto_assign_cidrs / network_id changed. For the join key that's create/replace only, since nothing else updates in place.

}

func (enrollmentCodeUseStateUnlessReminted) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
if req.State.Raw.IsNull() || !req.PlanValue.IsUnknown() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rejects unknown triggers even when the host is being replaced (say network_id changes), where Create always mints a fresh code and the consumed-code hazard can't happen. Repro: host with enrollment_code_triggers = { a = "1" }, then move it to a new definednet_network.m with enrollment_code_triggers = { a = definednet_network.m.id }. That fails with "Unknown enrollment_code_triggers". That's exactly the AMI-copy-plus-replace shape people will hit.

An attribute modifier can't easily see the pending replace, so this probably wants to move to resource-level ModifyPlan, after resp.RequiresReplace is known, or skip the error when any RequiresReplace attribute differs from state.


// samePUTFields reports whether two PUT bodies describe the same host. Sets
// carry no order, and override values are JSON whose spelling may differ.
func samePUTFields(a, b client.HostUpdateRequest) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: comparing plan to state field by field with .Equal() would replace samePUTFields, optionalStringEqual and mapSlice. Sets are order-free and flattenConfigOverrides already keeps the configured spelling. The one thing you'd give up is skipping the PUT when someone re-spells override JSON in config, which seems fine to me.

}

ipv4 := types.StringNull()
for _, a := range h.IPAddresses {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is ipv4Only again (keeping the last match instead of all of them). if v4 := ipv4Only(h.IPAddresses); len(v4) > 0 keeps the family test in one place.

n, err := r.client.GetNetwork(ctx, body.NetworkID)
if err != nil {
resp.Diagnostics.AddError("Unable to create host",
fmt.Sprintf("host %q: reading network %q to assign an IPv4 address: %s", body.Name, body.NetworkID, err))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: describeError(err) here like the CreateHostAndEnrollmentCode error below, so a missing networks:read gets the same hint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants