Welcome & Orientation
~2 minCoreWhat is this?
An onboarding guide for engineers joining as PR reviewers on the megaport/terraform-provider-megaport Terraform provider.
It is in two parts, and the split decides how to read it. 5 core sections, about 40 minutes, are what you read before your first review. The other 12 are reference: do not read them front to back, open the one that matches the PR in front of you. Reading all 17 in one sitting is 156 minutes and is not the intended path.
If you have already had the walkthrough, the ordering below is not for you. Start at the PR Review Cheat Sheet and open the reference sections its tabs name when a real PR needs them. The core exists for whoever finds this page cold.
What your review is for
There is a mechanical layer to reviewing this provider: a missing nil check, an unhandled error, a typo in a description, a field in the schema but not the model. It matters, and it is also the layer the compiler, the linter and an automated reviewer already cover well. Your attention adds more elsewhere.
The findings that matter here are the ones no code-reading reviewer can reach. They need one of four things: knowing what the fabric does, applying the branch against staging, having read the SDK in the other repository, or recognizing a defect shape this codebase has seen before. This guide is built around those four, and every section exists to make one of them cheaper for you.
Who this is for
Engineers who will review this provider without having written it, coming from a JVM background. The guide assumes no Terraform and no Go, so if you write Go daily, skip Go, for JVM Reviewers and take the other four core sections.
Time
~40 minutes for the core, but a first real review costs 57 to 85 depending on the PR: every cheat sheet tab sends you into reference sections, and a dependency bump sends you into the two longest. 156 to read the lot, a PR at a time.
Goal
Be able to take a real PR and know what to check, in what order, and when to stop and escalate.
Format
Annotated real code, interactive diagrams, and quizzes built on defect shapes this codebase has produced. Progress is saved in your browser.
Read these first
~40 minEnough to follow a PR and know what to ask. Stop here on day one. The cheat sheet will send you into reference when a real PR arrives.
Then, when a PR needs it
Reference. The cheat sheet at the end links back into these, so you do not have to remember which one covers what.
Terraform, for JVM Engineers
~11 minCoreThe rest of this tutorial assumes you know what Terraform does. If you arrive from a JVM background, you may know it only as “the thing that makes the infra”. That is close enough to get by and not close enough to review a provider. This section is the prerequisite. Five ideas, and no HCL you have to be able to write yourself.
The one idea
Terraform is not a deploy script. It is a reconciler. You describe the world you want, it reads the world that exists, and it computes the calls needed to close the gap. Nothing you write is a verb. There is no createPort() to call. You declare that a port exists with these properties, and the provider works out whether that means POST, PUT, DELETE or nothing at all.
A Spring analogy that mostly holds: it is closer to a Kubernetes controller reconcile loop, or to JPA flushing a dirty entity, than to a script. You mutate a model, and something else diffs it and emits the statements.
Every plan has exactly three inputs
Almost every bug in the sections ahead is one of these three being wrong, or two of them disagreeing for a reason the provider invented.
A plan, read closely
Two lines matter. Refreshing state is Terraform calling the provider's Read for every resource in state, before it diffs anything. And ~ cost_centre = "eng-platform" -> "eng-network" is the diff: left side from state (as just refreshed), right side from config. By default a plan is that comparison and nothing more: it does not ask the API “is this change legal”. A resource can opt out by implementing ModifyPlan and rewriting the plan itself. Four resources here do, and one of those, the NAT gateway, calls the API at plan time, so on that one resource a plan really does ask. That is the exception. Know it exists.
Which is why -/+ destroy and then create replacement is the line to fear. On this provider a replacement is a real circuit being decommissioned and a new one ordered: minutes of downtime, a new UID, new billing. Terraform will print it calmly in the middle of forty other lines. A plan modifier in the provider decides when that happens.
The config is the whole desired state
Deleting a line from a config is a change to the desired state. It is not “stop managing this field”.
Why a reviewer cares
What Terraform hands the provider for a removed attribute is null. What the provider does with null is decided by three booleans in the schema, Required, Optional and Computed, plus the attribute's Default and whatever plan modifiers sit on it. Optional on its own: the user gets a diff and the value reverts. Optional plus Computed, the most common pairing in this provider, with a UseStateForUnknownmodifier on top: the plan comes back “No changes” and the value is stuck forever. Same config edit, opposite outcome, and the whole decision is four lines of schema you are reading in a diff.
Null, unknown, and known
Null is one of three states an attribute can be in, and the third has no JVM equivalent at all. A framework value is not a nullable Kotlin type with two cases. It has three, and provider code has to handle all of them.
Unknown appears during a plan, for a value nothing has computed yet, and it survives into the apply: Create and Update are handed the planned state with those unknowns still in it, and filling them in is their job. It matters in review because a guard that tests for null and stops there lets unknown straight through, and what reaches the API is the empty string. Go, for JVM Reviewers has the code shape and the failure it produces.
Refresh, and drift
Drift is the real world moving without Terraform. Someone renames a port in the portal, an API-side default changes, a service gets decommissioned by support. Refresh is how Terraform notices: it calls Read, updates state from what the API said, and only then diffs against config. So the contract on Read is strict and slightly counterintuitive:
- Read is a patch, not a replacement. Every one in this provider loads prior state first, overwrites the fields it maps from the API, and writes the result back. So a field the mapping forgets keeps whatever state already held. There is no error and no diff, so it goes unnoticed: the value only has to be wrong once for state to be lying from then on.
- Read is the only place a deletion outside Terraform can be detected. If the service is gone, Read must remove the resource from state so the next plan offers to create it. Miss that and the user gets a hard error on every plan, and cannot apply or destroy, because both start with a refresh.
- A Read that writes a value back from prior state instead of from the API response hides drift completely. That is not the forgotten field above: this field is mapped, just mapped from the wrong side, and any test that only asserts state matches config still passes.
Why import exists
Import is a one-attribute-wide test of your Read method, and a cheap one. Which is why the acceptance tests in this repo have an ImportState step with ImportStateVerify: true: it imports the resource it just created into a second state entry and asserts the two match attribute by attribute. Anything Read forgets shows up there as a mismatch. A PR that adds an attribute and no ImportState coverage has left that gap open.
The five ideas, as review questions
- Config, state, real world. Which one does this diff change, and do the other two still agree?
- Does this change make a plan destroy something that is billing?
- If a user deletes this attribute from their config, what happens? Can they undo it?
- Does Read write the API's value for every attribute the schema declares?
- Would import of this resource round-trip cleanly?
A port in a user's state no longer exists, and the API answers the GET for it with a 400 whose message says it could not find a service with that UID. The provider's Read treats not-found as a 404 only, so it reports the 400 as an error and returns. What does the user see on their next plan?
How to Read the CI Checks
~9 minCoreStart here, because it sets the value of every other check in this guide. A green checkmark tells you the code compiles, lints, and that the committed docs match the schema. Behavior against a live fabric is a separate gate, and that is the normal arrangement for a Terraform provider: acceptance tests provision real infrastructure and need credentials, so they gate themselves on TF_ACC and are run deliberately rather than on every push. So the useful thing to hold onto is which four jobs run on a pull request, and what each one is evidence for.
The same command, two environments
The unit-test job and an acceptance run are the same go test line. The difference is what is in the environment when it runs.
An acceptance test takes a slot through acquireAccTestSlot(t), and without TF_ACC that helper skips before doing anything else. The slot bounds how many resources provision at once when the test does run. Read the bottom line of the output carefully: a skip is not a failure, so a run that stepped over a test and a run that passed it both end in ok.
One local-only trap: if your checkout sits inside a Go workspace, a go.work alongside a local megaportgo, that command tests against your SDK working copy rather than the version go.mod pins, and depending on the workspace it may not build at all. Prefix it with GOWORK=off to test what CI tests.
You cannot tell an acceptance test by its name
The TestAcc* prefix is a convention, not a mechanism. Plenty of acceptance tests here never picked it up: TestFullEcosystem, TestMVE_AWS_VXC, TestDynamicLocation, TestOracleVXCWithProductUID, and others. A handful of staging diagnostics and cleanup helpers carry their own TF_ACCcheck instead. If you are judging which environment a PR's new test needs, look for acquireAccTestSlot, not for the function name.
The docs gate
The Generate job enforces a rule that is invisible in a diff: everything under docs/ is generated and committed. CI regenerates it and fails if the working tree moved.
What this means in review
- A PR that edits a schema
Descriptionwithout runninggo generate ./...fails. The fix is always to run it and commit, never to hand-patch the markdown. - A PR that hand-edits
docs/resources/*.mdordocs/data-sources/*.mdfails, because the next regeneration overwrites it. Prose that has to survive belongs in the schemaDescription, intemplates/, or inexamples/. - A PR that reformats
examples/by hand fails too. The firstgo:generatedirective isterraform fmt -recursive ./examples/.
What the gate does not prove is that the docs are any good. tfplugindocs pulls example usage from examples/<type>/<name>/, matched on the Terraform type name. A directory whose name does not match the type exactly (singular where the type is plural, for instance) is never picked up at all. The generated page ships with no Example Usage section and the gate still passes, because the empty page the generator produces matches the empty page that is committed. So when a PR adds a resource or data source, open its generated page in the diff and check the example is on it.
Two jobs worth reading precisely
When to ask for an acceptance run
Terraform acceptance tests provision real infrastructure and require credentials, so they are run deliberately rather than on every push. For anything touching resource lifecycle, CSP partner configs, or state handling, confirm the relevant acceptance test has been run and the output is on the PR. Either the author pastes it, or someone pulls the branch and runs it. Two reference sections pick it up from here: Acceptance Tests for what those tests assert, and Verifying a PR Locally for running one yourself.
The repo is public, so some of what you review will arrive from a fork, and an outside contributor may have no Megaport account to run against. Say in your comment which of the two happened, so the next reader knows what was exercised. Read the diff before you build it, too. A branch you check out and apply runs its Go and its HCL on your machine and against your credentials.
CI Review Checklist
A PR adds a new megaport_vxc attribute, updates the schema Description, adds a TestAccMegaportVXC_NewAttr acceptance test, and lands with all four CI jobs green. What has CI verified about the new attribute?
How to Review a PR
~8 minCoreEverything before this is rules. This is the procedure: how far to go on a given PR, what a finding is worth once you have it, and when to stop and ask instead. Read it once, then keep the cheat sheet open for the real thing.
Two passes, and the second one is optional
Decide which pass a PR needs before you start, rather than skimming everything or checking out everything. That decision is the first judgment call, and the run pass has a section of its own.
Block, comment, or let it go
A disagreement about a finding is often a disagreement about which column it belongs in rather than about the code. Name the column and the discussion gets shorter.
Blocking someone more senior than you
This needs saying plainly, because the first few times blocking does not feel available. A finding in the Block column is a Block whoever opened the PR, including whoever has maintained this provider longest. You are not claiming to know the codebase better than they do. You are saying one specific thing does not hold, and if you are wrong about it the cost is a comment and a correction.
The failure mode here is not blocking too often. It is approving with the concern written into the body: it is on the record, the merge button is unblocked, and the concern changes nothing. If you are not comfortable blocking, leave the review in Comment and say what would change your mind. Do not Approve.
Escalate because of what the PR does
You are not expected to be the last word on any of these. The PR in front of you will normally already have the people who own that code requested on it, so escalating is not a matter of finding someone. Name what you are unsure about in a comment, and leave your review in Comment rather than Approve. Do that when a PR:
- Adds or removes a
RequiresReplace(), anywhere - Changes a default, a validator range, or an attribute name
- Bumps the
megaportgodependency - Touches CSP partner config shapes (AWS, Azure, Google, Oracle, IBM)
- Only works if existing state entries are rewritten
- Claims an API behavior you cannot verify from the OpenAPI spec. Not the SDK, the spec: the SDK is a consumer and can drift.
That list is about the change. There is a second, shorter one about you: four situations where the problem is what you could not establish rather than what the PR touched. It lives in the PR Review Cheat Sheet, next to the comment template you would say it in.
One PR, walked through
A constructed PR rather than a real one: an attribute added to a resource, no test, and a description that does not quite match the diff. The steps are the read-pass sequence above, and the second column is what each one returned.
That is a Block, on step 3, because an attribute a user can set that changes nothing is the silently-wrong column rather than the incomplete one. The missing test, the missing generated page and the description mismatch are all real and none of them is the reason to block. Nothing here is a let-it-go. Written up in the template from the PR Review Cheat Sheet:
PR Review Cheat Sheet
~10 minCoreThe order on this page is the order to work in: the four questions first, then the tab matching the shape of the PR, read pass before run pass, then the comment template at the bottom. Ticks are saved in this browser.
Start here, not with the checklists
Most of the items below are things an automated reviewer already checks, and it does not get bored on the twentieth PR. Run them, but run them fast. These four questions are yours alone, because nothing that only reads the diff can answer them.
Does this match what the fabric does?
A field can be wired up perfectly and still be wrong for the product. Only you know an MCR has no physical port and that A-End and B-End are not symmetric. Megaport Domain Primer →
Did you apply it, and does the second plan say No changes?
Acceptance tests provision real infrastructure and need staging credentials, so they run deliberately rather than on every push. For lifecycle, partner config or state handling, confirm the run happened and the output is on the PR. Two minutes on staging beats an hour of reading. Verifying a PR Locally →
What does the SDK do with this value?
The tag that drops a legal zero, the wrapper that defeats a type assertion, and the field that never reaches the wire all live in megaportgo, not in the diff. Go, for JVM Reviewers →
Has this repo already made this mistake?
The Gotchas section is a catalog of mistakes this repo has already made and fixed. Recognizing a repeat is cheap once you have seen the shape. Gotchas Reference →
One PR, one pass through these boxes
Nothing is ticked. Tick as you go, and clear it before you open the next PR.
Read pass
Answerable from the diff and the files around it.
Run pass
Needs the branch built and applied. Skipping it is a legitimate choice; leaving that unsaid in your comment is not. When you cannot run it yourself, ask the author to paste their plan and apply output, and say in your review that you are relying on theirs.
Escalate because of what you could not establish
- •You cannot answer question 1 for the product involved, and nobody on the PR has.
- •It changes Delete behavior, renames or removes an attribute, or changes what an existing attribute means.
- •It needs an apply to judge, and you cannot get one (no credentials, or staging has no matching product).
- •The second plan is not clean and the author says that is expected.
Escalating is not a failed review, and it is not silence. Post the comment template with the unanswered question named, and leave the review in Comment, not Approve. The people who own that code are normally already on the PR, so this is about writing the question down rather than finding someone to hand it to. An open question nobody mentions reads as a closed one.
These four are about you. There is a longer list about the change itself, six things that warrant a second opinion whatever you managed to establish, in How to Review a PR.
Your review comment
Copy this, delete what does not apply, and keep the Verification table even when the answer is not run. What you did not check is worth as much to the next reviewer as what you did.
Quick Links
Go, for JVM Reviewers
~28 minYou do not need to write Go to review this provider. You need to read it without stopping, and then you need to know which of your instincts to switch off. Both halves are here: first the syntax that makes an experienced JVM engineer pause, in roughly the order you hit it going down a resource file, then the shorter list of habits that are correct in a transactional JVM service and actively wrong in a Terraform provider. Everything shown is real code from internal/provider.
Every file has the same shape
One package per directory, so there is no file-level namespace and no public class line. Visibility comes from capitalization: NewMCRPrefixFilterListResource is exported, mcrPrefixFilterListResource is package private. There is no public, private or protected keyword at all.
Declarations read the other way round
Name first, type second, everywhere: variables, fields, parameters, return values. Once that clicks, most of the unfamiliarity goes away.
Methods, receivers, and the star that matters
func (r *portResource) Create(...) means "method Create on *portResource, with the receiver bound to r". The receiver is this, except you name it yourself and it is one character by convention. The star is not decoration.
Reviewable in one glance
All 22 Configure implementations in this package use a pointer receiver. 21 of them carry the ProviderData == nil guard: the 12 resources and the 9 data sources. The odd one out is megaportProvider.Configure in provider.go, which is the thing that supplies ProviderData to the other 21, so it has nothing to guard. A method that stores anything on the receiver and is declared without the star is a bug with no compiler warning and no test failure until something dereferences the nil client. Check the star.
Errors are return values, and diagnostics accumulate
There is no throw, no try, no throws clause, and no stack unwinding. A function that can fail returns an extra error value and the caller checks it. So the caller can ignore it, and can carry on after finding one.
The return after an AddError is load-bearing. Omit it and the function keeps running with an error already recorded, usually straight into a nil dereference. Check for the return on every changed error branch.
The reason it is so easy to omit is that resp.Diagnostics.AddError(...) looks like throw and behaves like errors.add(...). It appends to a list on the response and returns normally. Control flow continues to the next statement.
Because there is no central handler, message quality is entirely a per-call-site decision, which makes it a legitimate review comment rather than someone else's problem. The user sees those two strings in their terminal with no stack trace behind them.
There is no null in Go, and three states in Terraform
Every Go value has a zero value and is readable the moment it is declared. Reading never throws, and a deliberate zero is indistinguishable from a value nobody set. Coming from Kotlin nullability or even Java null checks, this is the biggest loss of safety in the language.
The review consequence
When a PR adds an attribute, the failure mode is not a crash. The field is present in the schema, present in the model, and never copied into the API request, so it silently ships as "", 0 or false. The compiler, the linter and the unit tests all pass. Following one new attribute by hand from schema to request body and back is what catches it.
On top of that, the framework layer adds a state the JVM has no equivalent for. types.String is not String?. It is null, or unknown, or known, which Terraform, for JVM Engineers introduces. This is what the three states look like in code, and how the guard on them goes wrong.
Both checks in that last guard are load-bearing. A field can be unknown during create and null during update, so !IsNull() && !IsUnknown() is the shape you want to see. A lone !IsNull() lets an unknown through to ValueString(), which is total: it never panics and never signals, so it hands the empty string to the API. A bug report of the form "my optional field was ignored" is this shape.
Absent, or present. There is no null going out
An update DTO in a JVM service usually gets three states for free: field absent, field present and null, field present with a value. Resist mapping that onto an update here. Updates go out as PUT and every field on the request struct carries omitempty, so a nil pointer is dropped and anything non-nil is sent, including a pointer to the zero value. There is no way to spell "send null".
Requests only. Responses keep the third state, so do not carry this one into a Read. The API spec declares nullable fields, and diversityZone is a frequent one: it documents that only RED and BLUEare returned and that no diversity zone is expressed as a null on a field that is still present. A location's latitude and longitude are nullable too. Unmarshalled into a plain string a null becomes "", so the distinction disappears without erroring anywhere.
Note what is not a row. PtrTo("")sends an empty string, not JSON null, and there is no "clear it" case in the general vocabulary. Where clearing is supported it is a sentinel documented on that one field, which you look up rather than infer: on a VXC VLAN, 0 means "allocate one for me" and -1means "untag it". Two fields on the same struct can spell the same intent differently. The struct itself is asymmetric: CostCentre is a *string, but Name is a plain string with omitempty, so setting a name to empty is not expressible at all. The tag drops it.
Go has no address-of for literals, so &0 does not compile. megaport.PtrTo exists to work around that, is used 24 times in the non-test code, and is preferred over a local temporary.
The judgment call, live in the repo
In vxc_resource.go, five lines apart in the same function, two fields are handled two ways. RateLimit is assigned only when the plan differs from state. CostCentre is assigned unconditionally from ValueString(), which is "" when the user removed the line, under a comment saying that is deliberate. Neither is obviously wrong. A Terraform config is the complete desired state rather than a diff, so "the user deleted the line, therefore clear it" is a defensible reading, and it is the opposite of what a PATCH instinct would tell you. When you review an update path, the question is not whether all three cases are handled. It is whether absent-in-config was deliberately chosen to mean leave-alone or clear, and whether the same choice was made for neighboring fields.
Nothing rolls back
In the services you already work on, one request is one unit of work: an exception unwinds it, and the database is left consistent because the framework guarantees it. The provider has none of that. A single Create makes two or more calls against a live ordering and billing system, and there is no @Transactional, no compensating action, and no cleanup path.
The question to ask on every error branch
"What already exists in the customer's account at this line?" In this Create there are three return statements between the successful create call and the single resp.State.Set at the end. Each one leaves a real, billable resource that Terraform has no record of. This is not visible from reading the happy path, and a PR that adds a fourth validation step in that window widens the gap.
State is on the user's disk and you cannot migrate it
A bad row in a database you own is recoverable: write a migration, run it, done. The equivalent artifact here is a state file in the customer's S3 bucket or on their laptop, which you cannot read, cannot patch, and cannot see. The only remedies are asking the customer to run terraform state rm or shipping a StateUpgrader in a later release.
The severity ordering inverts
In a service, returning a 500 is worse than writing a slightly wrong row, because the row can be fixed and the 500 was seen by a customer. Here it is the other way around. An error is recoverable: the user reads it, fixes their config, applies again. Wrong state is not: it persists, it silently produces a destructive plan on the next apply, and the fix has to travel through a provider release. So when you are weighing "should this fail loudly or guess?", the answer on this repo is almost always fail loudly.
Interfaces are implicit, so assertions are manual
There is no implements. A type satisfies an interface by having the right methods, decided at the point of use. That is usually pleasant and occasionally dangerous, because the framework tests for optional interfaces at runtime.
The var (_ resource.ResourceWithImportState = &x{}) block at the top of the file is how this repo buys back the compile-time check, and it is the entire reason those lines exist. All 12 resource files and all 9 data source files have one. A new resource that arrives without it is a resource where a typo in a method name is a silent runtime failure, so ask for the block.
Struct tags are not annotations
tfsdk:"mcr_id" looks like @JsonProperty("mcr_id"), but an annotation is a typed declaration the compiler and your IDE understand. A struct tag is a string literal, read by reflection, at runtime, on first use.
Because the check is at runtime, a tag typo on a rarely exercised attribute survives compilation, the linter, and any test that does not hit that code path. Reading the schema and the model side by side is the only real check, and it is quick.
The nine symbols that carry most of this package, and the one genuine syntax trap in the test files, are a collapsible block on the PR Review Cheat Sheet, because that is where you want them: open next to the diff, not in a section you read once.
What you can safely skip
Goroutines and channels
Two go func() calls in the whole package, one of them in a test. Provider work is sequential request handling.
Generics
Not used in this package. The one generic you meet is megaport.PtrTo, and you only ever call it.
Embedding and inheritance
No base classes here. Every resource file repeats Metadata, Schema and Configure. That is deliberate, not a DRY failure, and the first card below explains why.
Review comments you will be tempted to write
Every one of these is good advice in a service you own and wrong here. Guess the reason before opening each.
On the verbosity, briefly
The provider will read as repetitive, under-abstracted and hand-rolled compared to what you are used to. Go takes that position deliberately: explicit repetition over abstraction, so that any reader can follow one file top to bottom without knowing the rest of the codebase. On a repo whose reviewers are mostly visiting from other teams, that trade pays off. A stranger can review one file correctly without having read the other eleven.
A PR adds a new resource. The constructor returns resource.Resource, the file has no var _ assertion block, and the author wrote func (r *natGatewayResource) ImportSate(...) with the typo shown. What happens?
A Create successfully orders a service, then the follow-up Get to read it back fails. The code does resp.Diagnostics.AddError(...); return, exactly as the linter and every review bot would want. What is the consequence for the user?
Schema Attributes & Plan Modifiers
~10 minThree booleans, an optional Default, and a slice of plan modifiers per attribute. That is what decides user-visible behavior, and none of it fails loudly. A wrong combination compiles, passes the linter, generates clean docs, and then does something surprising on someone else's eighteen-month-old config. This is the section to read before reviewing any diff that touches a Schema() function.
The four combinations
Read the last column first. It is the one that decides user-visible behavior.
The trap: unknown is not null
Optional plus Computed is the right shape for “defaults to whatever the API says”, and it is used constantly and correctly. The failure mode is what pairs with it. Here is asn on the MCR resource, which is a fair example of the shape rather than an outlier:
The Update is right. The plan never reaches it
A user sets asn = 65000, applies, and later deletes the line to get the default back. Config value is now absent. Because the attribute is Computed, the framework marks the planned value unknown rather than null. UseStateForUnknown()then fills it from prior state, so the plan equals state, no diff appears, and Update is never called. The user gets “No changes” and keeps 65000 forever. Changing 65000 to 65001 works fine, because a known config value does produce a diff. It is specifically the revert-to-default path that is closed.
Two consequences for review. First, this class of bug is invisible to reading: the schema looks idiomatic and the Update handler looks correct. You have to apply, then remove the line, then plan. Second, you cannot find it by grepping for something wrong, because UseStateForUnknown() is a common and usually correct line. The two code blocks above are the whole evidence, and they both look correct on their own.
The fix is the fourth field, not the modifier. A Default is applied at plan time whenever the config is null, before anything marks the attribute unknown, so the revert produces a real diff and Update runs. It works with UseStateForUnknown left in place, because there is no longer an unknown for the modifier to fill. Three attributes on the NAT gateway resources already do exactly this with int64default.StaticInt64(0), so it is a pattern to point a PR at rather than one to invent. Adding one to an existing attribute is itself a change every user sees once, which makes it a question for the Compatibility section as well as this one.
What UseStateForUnknown claims
It says: during plan, if this Computed attribute's value is unknown, use the value from prior state instead of showing (known after apply). Its purpose is cosmetic and real, keeping plans readable by not marking forty computed attributes unknown on every trivial change. Three things it deliberately does not do. It does nothing on create, where there is no prior state to copy, which is why the trap above only fires on update. It does nothing when the config value is itself unknown, because another resource is still to be applied. And a null in prior state is a value like any other, so it will copy that too.
RequiresReplace, and what it costs here
One line, and on this provider it is the highest-consequence line anyone can add to a schema. There is no cloud-style rebuild-in-place. A replacement is a real circuit decommissioned and a new one ordered: minutes to tens of minutes of downtime, a new UID that every dependent VXC references, a new contract term, and new billing.
Both directions are bugs, and they fail differently
Missing when it is needed: the user changes an attribute the API cannot modify in place. Plan says update-in-place, apply calls PUT, and either the API rejects it or, worse, accepts it and ignores the field. State now records a value the fabric does not have. Silent.
Present when it is not needed: every user who has that attribute in their config sees a destroy-and-create the next time anything nearby causes a re-plan. The MVE resource shows how it goes wrong: the API normalizes vendor and size to uppercase, so a plain Equal() comparison reported a change on casing alone and would have replaced live MVEs for nothing. That is why mve_resource.go compares with strings.EqualFold() before appending to RequiresReplace.
So a PR that adds RequiresReplace() needs an answer to one question: what does the API do if we send this field in a PUT instead? If nobody knows, that is the thing to go find out, not the thing to approve.
ModifyPlan, the escape hatch
When per-attribute modifiers cannot express the rule, a resource implements ModifyPlan and rewrites the plan itself. Four resources here do, and each solves a different problem:
The framework calls ModifyPlan on every plan, including the destroy plan, where the plan is null and the state is not, and the create plan, where it is the other way around. All four here open by returning early on those, and the excerpt below starts just after that guard. On a new one it is the first thing to look for, because a body that reads the plan into a model without it also runs on a destroy.
Two review hazards visible in that snippet. A replace rule stated in the schema's plan modifiers and restated in ModifyPlan is one rule in two places, so a PR that adds a RequiresReplace() to a resource that has a ModifyPlan needs the plan hook read as well as the schema. And ModifyPlan is calling the API, which means plan is now doing network I/O. The fail-open branch is deliberate and correct, because a transient lookup failure must not block every plan in every pipeline. A PR that turns that into a hard error is trading a rare wrong plan for a common outage.
Schema Review Checklist
A PR adds an Optional + Computed string attribute with UseStateForUnknown(), wires it through the model, the request builder and the fromAPI mapper, and adds an acceptance test with two steps: set the attribute, apply, assert state matches config; then an ImportStateVerify step. What behavior is still unexercised?
Anatomy of a Resource File
~10 minRead single_port_resource.go once and you can navigate all twelve resources, because they are built from the same set of framework methods: Metadata, Schema, Create, Read, Update, Delete, Configure and ImportState. Those belong to the Terraform Plugin Framework rather than to this repo: the provider is built on the framework, not the older SDKv2, which is why a resource here is a struct with lifecycle methods hanging off it and why the schema gets a section of its own.
The order is nearly fixed. Learn the exceptions, so you do not conclude a method is missing when it is only somewhere else. Metadata and Schema open every file, and Create, Read, Update, Delete always appear in that order. That is file layout and not execution order: Configure runs before any of them, and a single apply calls exactly one of Create, Update or Delete. In the file, Configure is the one that moves: it follows Delete in six resources, precedes Create in five, and comes last of all in ix_resource.go. VXC, MVE, LAG port and NAT gateway add a ninth method, ModifyPlan. Metadata and Configure are a few lines each, so below are the model struct the file maps onto, then Create and Read expanded, then the remaining three in a table.
Schema
Defines all attributes, types, validators, and plan modifiers. Terraform uses this to validate configs and generate plans.
Data In
None (static definition)
Data Out
Attribute definitions, descriptions, validators
The other three, in one table
Three things about the path a request takes
- The provider never makes an HTTP call itself. Every one goes out through
megaportgo, so a field that looks correctly assigned in the diff can still be dropped a layer down, and nothing in the diff will show you that. - Waiting is the SDK's job.
WaitForProvisionmakes it poll the product every 30 seconds until the status is ready, bounded by the timeout the provider passes in. A resource that orders without waiting writes state for something still being built. - Create re-reads the product it just ordered instead of trusting the plan, which is the only way the Computed attributes get real values. It is also why Create and Read share one
fromAPIPort: change that mapping and you have changed both paths.
Review Quizzes
Two scenarios, both built on shapes that occur in this repo. One is a defect. The other is a review comment you should decline.
A PR adds a new 'bandwidth_alert_threshold' field to the port model and schema (Optional + Computed), maps it in Create() and Update(), but doesn't touch Read(). What's the bug?
A PR adds optional 'notification_email' (types.String) to the port resource. In Create(), they use: buyPortReq.NotificationEmail = plan.NotificationEmail.ValueString(), and the SDK field is a plain string with json:",omitempty". Another reviewer has asked for ValueStringPointer(). What do you say?
Architecture Overview
~2 minTwo repositories. The provider holds schemas and CRUD methods. The megaportgo SDK holds the HTTP client, the API types and the provisioning polling. The SDK is also used by the Megaport CLI, so a change there has two consumers. Click the layers to expand them.
Every resource file follows the same shape: model struct, then Metadata(), Schema(), Create(), Read(), Update(), Delete(), Configure() and ImportState(), with Configure() turning up in a couple of different positions and four resources carrying a ModifyPlan() as well. Once you have read one you can navigate all of them, which is what Anatomy of a Resource File is for.
That is the entire public surface: twelve resources and nine data sources. A PR adding either has to register it here, and a new resource file with no line in this list is dead code that still passes CI.
Megaport Domain Primer
~2 minEnough domain knowledge to read a diff, and no more. Megaport sells software-defined connectivity: physical ports in data centers, virtual routers, and layer 2 circuits between them. Click a product in the topology to see where it sits. Keep the table open while you review.
Product to resource, and what to check
The only hierarchy that matters for review: a Port, MCR or MVE is an endpoint, and a VXC is a circuit between two endpoints. Endpoints are mostly independent resources with a flat schema. The VXC carries the nesting and the partner configs, so it has a section of its own.
State, Drift and Import
~12 minThere are three copies of the truth in play: the config the user wrote, the state file Terraform keeps, and what exists in the Megaport fabric. Every method in a resource is an attempt to keep those three aligned, and most bug reports on this provider trace back to those three drifting apart.
The most common bug report
"Provider produced inconsistent result after apply" is the most common bug report on this repo. The message is more useful than it looks: it names the exact attribute and tells you which method to open.
Terraform is not reporting an API failure. It is reporting a contract violation: during plan it recorded a value for vendor_config, the apply succeeded, and then the provider wrote something different into state. Terraform will not accept that, because if it did, the next plan would show a diff the user never asked for. The last line is Terraform's standard wording for this class of error, whatever the underlying cause turns out to be.
Attributes the API never returns have to be carried by hand
vendor_config on an MVE is the clearest example in the repo. It is sent on create, never sent on modify, and never returned by any read. So nothing in the API can tell the provider what it is, and every method that writes state has to source it from the plan or the prior state instead.
That one field is reconciled in three separate places in mve_resource.go: once in Update, and twice more inside ModifyPlan, which has to handle null-in-state, null-in-plan, and case-insensitive comparisons against size and vendor. A carry-forward is a rule spread across every method that writes state, so when a PR changes one of them, find the others.
The framework has a flag with a similar name and the opposite instruction. WriteOnly: true says the value must never be persisted at all: the framework nulls it out of the plan and out of refreshed state, so a hand-written carry-forward line is exactly the wrong fix. Two attributes in the repo declare it, admin_password inside an MVE's vendor_config and pre_shared_keyinside a VXC's ip_sec_tunnel_options. The second is Required, so it has to be in the config on every apply and is null in state in between, which is the intended behavior and not drift.
The review question for any new attribute
"Does a read of this product return this field?" If yes, the conversion function handles it and you are done. If no, there are two answers and they are opposites. Where the user is entitled to see the value back, every method that calls State.Set needs an explicit line carrying it forward, and the PR has to touch all of them. Where it is a secret the user supplies on every apply, WriteOnly: true is the answer and a carry-forward line is a bug. Either way this is a question about the API, not about the diff: it cannot be answered by reading the changed lines.
Import: whatever Read cannot recover is null
All 12 resources implement ImportState. Seven are a single call to ImportStatePassthroughID, which copies the import ID into one attribute and stops. Terraform then calls Read, and Read is what has to reconstruct the entire resource from nothing but that one identifier.
The rule that follows from that
A PR that touches Read is a PR that touches import, and a PR that adds an attribute has to answer whether import can recover it. The acceptance criterion is one command: terraform plan immediately after terraform importmust produce no changes. If it produces a diff, either import is incomplete or the schema's Computed flags do not match what Read can recover.
ImportStateVerifyIgnore is where an import test says “except”
The acceptance tests do check the import round trip, then exempt named attributes from the comparison. That exemption list is the one place a test states, out loud, what it is not comparing.
The bottom row is uncontroversial: those genuinely change on their own or are set by the provider itself. Ask two questions about anything else on such a list. Is this an attribute the API cannot return, in which case the exemption is honest? Or is it a round-trip gap that could be closed, in which case the exemption is hiding it? An exemption written by index, such as entries.0.foo and entries.1.foo, is worth a second look on its own, because it only covers as many elements as someone bothered to enumerate. So when a PR adds a name to one of these lists, ask which of the two it is.
Composite IDs, and how short ImportState should be
Child resources need two identifiers, so their import ID is parent_uid:child_id and they cannot use passthrough. They parse the ID themselves. This is the whole job:
Terraform calls Read straight after ImportState, so anything a longer ImportState fetches is fetched again moments later, and its state write is overwritten by what Read derives. Two code paths that read the same object have to keep agreeing with each other, in particular about what a 404 means: erroring in one and removing the resource from state in the other are different outcomes for the same fabric. So when you see a long ImportState, ask what it is doing that Read is not about to do anyway, and whether a value it stamps into state, a timestamp for instance, is one an import should invent.
Drift, and the exit Read must provide
If someone deletes a port in the Megaport Portal, the next Read has to notice and remove it from state without raising an error. Returning an error instead leaves the user wedged: they cannot apply, and they cannot destroy, because both start with a refresh that fails. Every resource calls RemoveResource somewhere in its Read for exactly this reason.
Three distinct triggers, and all three are needed. A deleted product usually returns 404, but sometimes returns 400 with the message Could not find a service with UID, so the status-code test needs two arms. A decommissioned product still reads successfully and reports STATUS_DECOMMISSIONED, so a resource that only inspects the status code keeps a dead service in state forever. The most complete version of all three is the Read method in Anatomy of a Resource File. When a PR touches a Read, compare its error branch against that, rather than against the file next to it.
State and Import Review Checklist
mve_resource.go Update starts by reading both plan and state, then assigns state.VendorConfig = plan.VendorConfig before any API call. A PR removes that line, correctly noting that vendor_config is never sent to ModifyMVE and never returned by a read. CI is green. What breaks?
The Complex Case: VXC
~12 minThe VXC (Virtual Cross Connect) resource is the most complex in the provider at ~2,950 lines, with another ~1,600 lines split across vxc_schemas.go and vxc_resource_utils.go, and ~5,000 lines of acceptance tests. It has A-End/B-End configuration, seven partner config variants, VLAN handling semantics, and partner port UID rotation.
Why VXC is complex
- Two endpoints (A-End and B-End) with independent configuration
- Seven partner config variants (AWS, Azure, Google, Oracle, IBM, vRouter, Transit) plus the deprecated a-end
- VLAN handling: ordered_vlan (what you request) vs vlan (what you get)
- Partner port UIDs can rotate, so requested_product_uid and current_product_uid diverge
Start by counting the ends
A VXC has two ends, and almost every code path that touches one has a near-identical twin that touches the other. The twin is a separate block of code, not a shared function, so a change applied to one end compiles, passes lint, passes CI, and is half-finished.
So a single new end attribute lands in ten places, and only two of them are shared. Treat ten as a floor rather than a total: an attribute with its own update rules picks up more, and inner_vlan also appears in the block after Update that checks the API applied the value it was sent. The partner config schemas are shared (they live as package-level vars in vxc_schemas.go and are referenced from both ends), but the code that reads them is not. Count the switch arms:
Four switch statements, twenty arms, no two of them the same. A PR adding a field to google_config has two Create arms and zero Update arms to worry about; one adding a field to vrouter_config has four. The Update switches are narrow because CSP partner configs are not mutable in place: they are set at order time, and changing them means a new circuit.
The check, as a command
VXC Schema Structure
End Configuration Model
Four of those eleven fields come in requested-versus-actual pairs, and the pairs are where to look when a VXC drifts.
Never compare the requested field to the actual field
It reads like the obvious diff and it produces permanent drift. The Update path already carries the comment explaining why, which makes it the best available description of the bug:
With ordered_vlan = 0 the API allocates, say, 2517. Diffing plan-ordered against state-actual then says 0 != 2517 forever, so every unrelated apply, a tag edit, a rate limit change, queues a VLAN mutation on a live circuit. The fix is to compare like with like: plan.OrderedVLAN against state.OrderedVLAN. When you see a diff add a comparison on any Computed field, this is the question to ask.
The supportVLANUpdates() guard on the same condition has its own rule: AWS and Transit connections reject VLAN changes outright, so the provider declines to send them. A PR that adds VLAN mutation to a new partner type has to decide which side of that function it belongs on.
Read cannot round-trip a VXC
Most resources in this provider have a Read that rebuilds state entirely from the API response. VXC cannot, because the fields in the left column above are user intent that the API never echoes back. So fromAPIVXC takes the plan as a fourth argument and copies those fields across by hand.
Two consequences for review. First, a new user-only attribute has to be added to the preservation logic or it silently vanishes from state on the next apply, which looks exactly like drift and is not. Second, Read passes nil because at refresh time there is no plan to preserve from, so importing a VXC leaves those attributes null.
That second point has a useful side effect: the acceptance test's ImportStateVerifyIgnore list is a maintained inventory of exactly which VXC attributes the API does not return.
If a PR adds an entry to that list, ask why. Sometimes the answer is correct, that the API genuinely does not return it. Sometimes it is the quickest way to silence a failing import test, and the real defect is a field that Read should have populated and does not.
This guide does not list what each partner config contains, because that documentation is generated from the schema and so cannot drift: read docs/resources/megaport_vxc.md on the branch, and examples/resources/megaport_vxc/ for a working config of each shape. The part to learn is which end each config belongs on, because both ends share one Go struct and so the schema offers you fields on an end where they do nothing.
Partner config: A-End vs B-End asymmetry
A-End and B-End are just labels for the two endpoints of a VXC. The A-Endis "where the connection starts" and must always be a Port, MCR, or MVE you own (your side); the B-Endis "the other side" and can be a Port, MCR, MVE, Internet Exchange, Marketplace partner, or cloud onramp. For a private Port-to-Port VXC they're effectively symmetric labels. The asymmetry appears when the far side is a CSP, IX, or partner port: those sit only on the B-End, never the A-End.
The a_end and b_end endpoint blocks are genuinely symmetric: same Go struct, same fields. The *_partner_config blocks look symmetric (both reuse vxcPartnerConfigurationModel, so Terraform exposes the same nested fields on each), but they aren't. The partner enum differs, and one value is a deprecated A-End-only legacy.
The MCR-side virtual-router config block has been renamed. The legacy name described where it attaches. The current one describes what it configures. Both still parse, which is the part that matters on review:
partner_a_end_config with partner = "a-end". Described where it attaches, not what it configures.vrouter_config with partner = "vrouter". Describes the actual virtual-router-style config (BGP, IP, BFD, NAT).Note what the rename did not do. Both blocks carry all seven config attributes, including both vrouter_config and the deprecated partner_a_end_config, because each block references the same shared schema definitions. The only trace the rename left in the schema is the row above: "a-end" is in the A-End partner enum and not the B-End one. Use the current name in new code, and treat the deprecated one as something you will only meet in an existing config.
Because both ends share the underlying struct, Terraform's schema also accepts b_end_partner_config.partner_a_end_config = {…}, but it's effectively dead. The B-End validator rejects partner = "a-end", and the B-End dispatch in Create switches on aws, azure, google, oracle, ibm, transit, and vrouter with no a-end case at all. Setting just the nested block does nothing. The Update path is narrower still: it only rebuilds B-End partner config for transit and vrouter, since CSP partner configs are not mutable in place.
When reviewing VXC PRs, check:
- Both ends. Grep the new identifier and count the hits against the ten places above. Run this before anything else on the list.
- Does the change handle all partner config variants, or just one?
- Is a field the API sets being compared against the one the user wrote? (ordered_vlan vs vlan, requested vs current UID)
- If a new user-only attribute is added, is it preserved in fromAPIVXC when a plan is passed?
- Any new entry in ImportStateVerifyIgnore, and is it justified or is it hiding a Read gap?
- Does the change account for partner port UID rotation (requested vs current)?
- Are CSP-specific fields only required when that partner is selected?
- Does the test cover the specific CSP variant being changed?
- Any new use of
partner_a_end_configorpartner = "a-end"? Replace withvrouter_config/partner = "vrouter". - CSP partner configs (
aws_config,azure_config, …) on A-End rather than B-End? Almost always wrong.
Backward Compatibility
~10 min3.8 million downloads. That is the number to hold in your head, because it is the thing that separates reviewing this repo from reviewing an internal service. There is no coordinated rollout, no deploy you can roll back, and no way to see the configs you are about to break. They are on laptops and in pipelines you will never have access to, and the first you hear about a mistake is an issue filed by someone whose Friday you ruined.
Version constraints do not pin users
The version constraint the documentation tells users to use accepts every future 1.x release. So “we will note it in the changelog” is not a mitigation, and neither is “it is only a minor version”. If a change would break a config that works today, it breaks it on an upgrade nobody deliberately chose.
What counts as breaking
Most of these do not look like breaking changes in a diff. Several look like improvements.
The rename pattern, as this repo does it
There are two live deprecations to copy from. partner_a_end_config was superseded by vrouter_config, and MCR's inline prefix_filter_lists was superseded by the standalone megaport_mcr_prefix_filter_list resource. Both kept the old surface working.
Note the cost, because it is the reason a reviewer should push back on gratuitous renames. Two attribute names now describe the same thing, both are wired through the model and the request builder, and a comment in the code has to warn that the two schemas must stay identical. Every future change to that shape has to be made twice. A rename is not free just because it is backward compatible, so “the new name is clearer” needs to be worth carrying both until the next major version.
Check whether the change needs a state upgrader
The Plugin Framework has a mechanism for this: bump SchemaVersion, implement UpgradeState, and Terraform migrates old state entries on read. Every resource here is at the implicit version 0, so a PR that needs old state rewritten is introducing the first one rather than following an existing pattern.
Know that before you review the diff, because it makes the change larger than it looks. A schema change that only works if existing state is rewritten is a design conversation first and a code review second.
Where a release note comes from
Asking for “a release note” only helps if you know what produces one, and in this repo it is not a file. There is no CHANGELOG.md and no .changelog/ directory to add an entry to. Pushing a v*tag runs GoReleaser, which uses GitHub's own release-note generator. It groups the merged PRs by their label: feature, enhancement, bug, documentation, and everything else into “Other Changes”.
Two consequences for a reviewer. The PR title isthe release note. A title like “tidy up validators” on a change that breaks existing configs is the whole problem, and asking for a better title is a substantive review comment rather than a nitpick. There is no group for a breaking change, so nothing about the tooling will make one stand out. If a change needs users to notice before they upgrade, the title has to carry it. Someone also has to say so in the release itself, which GoReleaser drafts rather than publishes, so it can still be edited.
SDK version bumps are provider changes
The provider depends on github.com/megaport/megaportgo and shares it with the CLI. A one-line bump in go.mod is a diff with no visible behavior. Three things it can carry:
- A field changing from a value to a pointer, or back. This compiles or it does not, so it is the safe kind. If it compiles and the field is now a pointer, check whether the provider is still distinguishing unset from zero correctly.
- New error wrapping. The SDK wraps errors with
%w. A bump that adds a wrapper on a path the provider checks with a bareerr.(*megaport.ErrorResponse)type assertion silently defeats that check. Nothing fails to compile, and the symptom is a deleted resource producing a hard error instead of being removed from state. - Changed defaults or validation inside the SDK. The provider inherits them without a diff line anywhere.
So the useful review question on a bump is not “does it build”. It is: read the SDK's changelog between the two versions, and for anything touching errors or request shapes, ask for an acceptance test run. A bump is the one change where compiling proves the least, because the whole category of risk is behavior that compiles.
Compatibility Review Checklist
contract_term_months already carries int64validator.OneOf(1, 12, 24, 36, 48, 60), on all six resources that have the attribute. Suppose a PR tightened a different attribute the same way, from accepting anything to a fixed set, and an existing user's config holds a value the new validator rejects. What is the risk to that user?
Data Sources
~6 minNine of the provider's registered types are data sources, not resources: read-only lookups that find a location ID, list every VXC in the account, or enumerate the MVE images a vendor publishes. Reviewing one is a smaller job than reviewing a resource, and the differences decide what to check.
Four methods, one of which does the work
Terraform re-reads every data source on every plan and throws the previous result away. There is no prior state to diff, so there is nothing for a plan modifier to modify and no drift to detect. That is why datasource.ReadRequest exposes Config and nothing else: no Plan, no State. The framework draws the same line in the schema types. The attribute structs under datasource/schema have no PlanModifiers field at all, so a PR that tries to add RequiresReplace() or UseStateForUnknown() to a data source attribute does not compile. This is the rare case where the build catches the mistake for you. Know it precisely, so you do not spend a review looking for a no-op that cannot exist.
Validation lives in Read, though it does not have to
The shape to watch for
A rule about which filters may be combined can live in two places: a declared Validators: entry, or a hand-written if at the top of Read. Most of them here are the second kind. So when a PR adds a filter attribute, the schema half is easy. Check that the guard above learned about it too, or the new attribute is silently rejected when it is the only one set.
This is a choice rather than a framework limit. datasourcevalidator.AtLeastOneOf does exactly this job as a declaration, alongside ExactlyOneOf, Conflicting and RequiredTogether, and the module they live in is already a direct dependency. What changes is when the error lands: a declared rule fails terraform validate, while a guard inside Read waits for a plan. On a new data source, that is a fair thing to ask for.
site_code in that guard shows a second pattern. It is still a documented attribute, but the v3 locations API dropped it, so using it as your only filter fails at plan time with a written explanation. That is deliberate: keeping the attribute avoids breaking configs that merely reference it, and the error is what stops anyone relying on it. If a PR deletes an attribute outright where this would do, say so.
Two habits that catch the rest
Open the siblings. The three list data sources were written to one design, so a divergence does not stand out. megaport_mves and megaport_vxcs put resource-tag fetching behind an include_resource_tags flag defaulting to false; megaport_mcrs fetches tags unconditionally, one extra API call per MCR, with no way to turn it off. Neither is wrong on its own, and reading one file cannot tell you that.
Grep for the TypeName, not the filename. partner_port_data_source.go is megaport_partner, and singular filenames produce plural Terraform types (mve_image_data_source.go gives megaport_mve_images, and mcr_prefix_filter_list_data_source.go gives megaport_mcr_prefix_filter_lists). Searching for the Terraform type name will not find the Go file.
Data Source Review Checklist
That last item is cheap to ask for. A data source is a pure function from config to state, so it tests against a fake client in milliseconds, with no Terraform CLI and no API involved. Several here are tested that way, and it is a reasonable thing to expect of a new one.
A PR adds a site_name filter to megaport_location: a new Optional attribute in Schema, a new field on locationModel, and a new branch in Read that calls GetLocationByNameV3. Tests pass. What is most likely broken?
Acceptance Tests
~7 minTerraform provider tests are acceptance tests: they run against a real staging environment, creating and destroying actual resources. That costs time and credentials, so they run deliberately rather than on every push (How to Read the CI Checks has the detail). Read this one with that in mind. The job is to judge whether a PR's new test would catch anything when it runs, and to know what to ask for when it would not.
Which shape does this change need?
Three shapes exist here, and only the first two run on every push. The useful review comment about tests is often not “add a test”. It is that the change was tested in the expensive shape when a cheap one covers it.
Do not skip the middle row. A mock is a struct with the service interface's methods on it, returning whatever the test needs, and it is the only way to cover a 500, a timeout, or a wrapped error in something that runs on every push. If a diff adds an error branch and covers it only in a TestAcc, ask for it in a mocked unit test as well, so every push covers it.
Test Lifecycle
The test framework automatically calls Destroy after all steps complete (or on failure) to clean up resources.
Annotated Test Example
The Check lists are trimmed here. The real test asserts on every computed attribute it can, including product_id, create_date, created_by, location_id, and company_uid.
Patterns in every test file
- t.Parallel() + acquireAccTestSlot(t): an acceptance test opens with both lines. The slot helper bounds how many resources provision at once and is where the
TF_ACCskip lives, so a PR that omits it does more than drop a concurrency limit: it makes a test that needs credentials try to run without them. - TestStep: Each step applies a Config and runs Check functions. Multiple steps test the full lifecycle.
- ImportState step: Verifies terraform import works. Uses ImportStateIdFunc to get the UID from state.
- ImportStateVerifyIgnore: Lists fields that may differ after import (timestamps, computed-only fields).
- TestCheckResourceAttr: Verifies exact value. TestCheckResourceAttrSet: Verifies field exists (for Computed fields).
- ComposeAggregateTestCheckFunc: Runs all checks even if one fails (vs ComposeTestCheckFunc which stops on first failure).
Test Review Checklist
Verifying a PR Locally
~17 minWhy this section exists
The acceptance tests are the only thing in the repo that talks to a Megaport API. They provision real infrastructure on staging, at no cost, and need credentials, so they gate themselves on TF_ACC and are run deliberately. Reading the diff tells you what the code intends. Applying it tells you what the fabric does, and only one of those two is something you can do from the PR page.
Running a PR locally takes about ten minutes the first time and about two minutes after that. Given the above, it is the one check that reading the diff cannot substitute for.
One-time setup
Terraform normally downloads providers from the registry. A dev_overrides block tells it to use a local binary instead, so your go install output is what runs. The provider README documents this; the block goes in ~/.terraformrc, with <PATH> replaced by the output of go env GOBIN.
Set these in a dedicated shell, not your profile. The failure mode you are protecting against is a stale MEGAPORT_ENVIRONMENT=production in a shell you forgot about while you are testing a destroy path.
Where the access key and secret key come from
You generate them yourself, in the Megaport Portal under Tools > API Key Generator. Use staging, not production: staging provisions the same product types without billing a real circuit. A key only works in the environment it was created in, so that means generating this one in the staging Portal at portal-staging.megaport.com. Your usual Portal login works there, though a brand new user account takes 24 hours before staging will accept it.
The form asks for a name, a role and a token expiry. Pick Company Admin for the role, because a Read Only key cannot provision anything and applying is the entire point. For expiry, take the 1440 minute maximum: the provider authenticates once while it configures and then holds that one token for the rest of the run, so a short expiry can strand a long apply part way through provisioning. You have to be a Company Admin yourself to reach this screen at all. If it is not there, someone who is has to generate the pair for you.
API Key is your MEGAPORT_ACCESS_KEY and API Key Secret is your MEGAPORT_SECRET_KEY. Copy the secret before closing the dialog, because it is not shown again. Both are credentials, so they belong in your shell or a secret manager, never in a .tf file or a terraform.tfvars you might commit.
Something to run it against
You need a small Terraform config per resource type, and the provider repo already has them. Use examples/. The first two groups below are the ones you want: each carries its own provider block pointed at staging and provisions real products, and several are staged across numbered files so you can drive an update or a replace rather than just a create. Copy one somewhere outside the repo and it becomes your scratch space. Despite the name, multicloud-scenarios/ is not one of them.
Not everything has a runnable directory. NAT gateway, IX, service keys and the MCR IPsec add-on exist only as resources/<type>/resource.tf snippets, so for those, copy the snippet and add a provider block yourself.
Read three lines before you run one
Every example's provider block hardcodes placeholder credentials: access_key = "access_key" and secret_key = "secret_Key". The provider reads the environment variables first and then overrides them with anything set in configuration, so those two lines beat the keys you exported and you get an authentication failure that looks like a bad key. Delete them and let the environment supply both.
That same override rule is what makes the third line the one to check. environment is set in the provider block too, and it beats your exported MEGAPORT_ENVIRONMENT exactly the way the credentials do. Most directories say "staging", but mcr_cloud_end_to_end/ and all thirteen multicloud-scenarios/scenario-*/ say "production". Exporting staging does not protect you from those: read the provider block, not your shell. accept_purchase_terms = true is already right and can stay.
Then expect the account specific values not to resolve. Location IDs, MVE image_id, Azure service keys, Google pairing keys and AWS owner accounts are all hardcoded to whatever worked for whoever wrote the file. Swap them for values from your own staging account before you plan.
Run validate before you trust a directory
The generate job checks that docs/ matches what the generator produces from these files. That is a different question from whether the HCL is valid: a config that does not parse generates a doc page perfectly well. So run terraform validate in your own copy before you rely on a directory, and if it does not come back clean you will know in two seconds rather than halfway through a plan. The failures worth reporting are the ordinary kind: an undeclared reference, a resource declared twice, block syntax where the schema wants an attribute.
One kind of failure needs a workaround rather than a fix. vxc_vlan_change/ and moving_vxc/ hold several complete configs in one directory, meant to be applied one after another, and Terraform loads every .tf file in a directory at once. Keep one file and move the others aside, then swap them to advance a step.
Carry this into review as a habit: read the HCL in a PR that touches examples/ as carefully as you read the Go, because whatever lands there is what users copy and what the registry publishes.
The per-PR loop
Two things that will confuse you the first time
Terraform prints a loud warning on every command telling you development overrides are in effect and the behavior may be unexpected. That is correct and expected. It is how you know the override is working. And do not run terraform init: overrides bypass provider installation entirely, so init either complains about a missing lock entry or silently wastes your time. Go straight to plan.
Read the diff before you build it
The repo is public, so some of what you review arrives from a fork. The binary you are about to build and run is that author's code, executing on your machine with your Megaport credentials in the environment. A change to go.mod or go.sum in a fork PR is the part to read hardest, because it decides what else ends up compiled into it. This is not a reason to skip the apply. It is a reason to do the reading first, in that order.
A worktree rather than a checkout is worth the extra line. You keep your own branch intact, your .tf files stay put between PRs, and go install from the worktree overwrites the same binary, so switching between two PRs is one cd and one go install.
Three checks, in order of value
The first two are cheap and mechanical, and you should do them on every PR that touches a resource. The third takes thought, and it finds the bugs the first two cannot.
Why zero is the value to try first
Go's zero values meet JSON's omitempty and the result is a field that vanishes. A tag like json:"threshold,omitempty" on an int cannot express zero: the encoder drops the key, so a value the user wrote never reaches the API and nothing errors anywhere along the way. Whether zero is a legal value for that field is a question about the API, and the Go type is free to disagree with it.
You cannot see this in a provider diff, because the tag lives in the SDK repository rather than the one you are reading, and the value is legal so no validator objects. What you can establish in two minutes is whether the value survives an apply and a re-plan. So whenever a PR adds a numeric or optional attribute, the question to ask is: is zero legal for this field, and if it is, does it survive?
When the defect is not in the PR
A vanishing zero usually cannot be fixed in the diff in front of you, because the struct tag is in a dependency. That changes what a useful comment looks like: do not write it as a suggestion the author can act on. Name the upstream field, check whether an issue is already open against it and link that one rather than opening a second, and treat the two repositories as two pieces of work. If the provider change is urgent, a validator that rejects the value with a clear message is worse behavior but honest behavior, and it beats silently discarding what the user wrote.
Clean up, and then check
Finish with terraform destroy, then confirm in the Portal that the services are gone. Deletion is asynchronous and the delete flags differ by product type, so a successful destroy is not by itself proof. Anything you leave behind is easy to lose track of, so check rather than assume.
Staging is also overwritten every 24 hours when it resynchronizes with production, and that takes whatever you created there with it. A directory you left applied yesterday is gone today, so a review you pause overnight starts from nothing. Do not let the sync stand in for the destroy either: whether Delete works is one of the things you are checking.
If you cannot run it
Sometimes you will not have credentials yet, or the PR needs a cloud account on the far side that you do not have. Two thirds of the value is still available without an apply, so do that part and be explicit about the rest.
Then say so in the review. "Read the diff, ran the unit tests, did not apply this against staging" is a useful review. An approval that reads as if you did apply it, when you did not, is worse than no approval, because the next person assumes the check happened.
Local Verification Checklist
You have dev_overrides configured and you have just run go install from a PR worktree. terraform plan reports no changes to a resource whose schema the PR clearly modified. What is the most likely explanation?
Gotchas Reference
Reference, not reading. Do not try to absorb the page in one sitting. Search it when a PR touches an area you have not seen before, and skim the titles once now so you recognize the shapes later. Each entry is a behavior that is non-obvious from the diff, plus what to check when a change lands near it.
17 of 17 shown