Skip to content

fix: deep-copy layer objects during Scenario plan rendering#2380

Open
tonyandrewmeyer wants to merge 13 commits intocanonical:mainfrom
tonyandrewmeyer:fix/scenario-render-services-deepcopy
Open

fix: deep-copy layer objects during Scenario plan rendering#2380
tonyandrewmeyer wants to merge 13 commits intocanonical:mainfrom
tonyandrewmeyer:fix/scenario-render-services-deepcopy

Conversation

@tonyandrewmeyer
Copy link
Collaborator

@tonyandrewmeyer tonyandrewmeyer commented Mar 15, 2026

Summary

  • Bug: Container._render_services(), _render_checks(), and _render_log_targets() stored direct references to original Layer objects in their result dictionaries. When a subsequent layer merged into those references, the _merge() call mutated the original Layer's objects in place. This meant that accessing .plan (or .services) multiple times caused list fields (after, before, requires, services) to accumulate duplicates with each access.
  • Fix: Use copy.deepcopy() when storing objects in the else branch (i.e. when a service/check/log_target is first encountered or uses replace override), so that merge operations never mutate the original Layer data.
  • Precedent: This is the same class of bug that was previously fixed in Harness in commit 3dda5b5f ("Pebble layer merging in Harness mutated the original layer objects").

🤖 Generated with Claude Code but owned by me.

When rendering services, checks, and log targets in Container's plan
property, the else branch stored direct references to the original
Layer's objects. A subsequent merge call on those references would
mutate the original Layer in place, causing list fields (after, before,
requires, services) to accumulate duplicates each time .plan was
accessed.

Use copy.deepcopy() in the else branch of _render_services(),
_render_checks(), and _render_log_targets() to prevent mutation of the
original layer objects.

This is the same class of bug that was previously fixed in Harness
(commit 3dda5b5).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tonyandrewmeyer tonyandrewmeyer force-pushed the fix/scenario-render-services-deepcopy branch from 8aa7b17 to 7242367 Compare March 15, 2026 07:35
Copy link
Contributor

@dwilding dwilding left a comment

Choose a reason for hiding this comment

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

I ran the new test with testing/src/scenario/state.py unmodified, and the test passed, but I was expecting it to fail. So it looks like something isn't quite right (perhaps my understanding is off!)

assert plan1.checks['chk-a'].level == plan2.checks['chk-a'].level

# Log target list fields must not accumulate duplicates.
assert plan1.log_targets['lt-a'].services == plan2.log_targets['lt-a'].services
Copy link
Contributor

Choose a reason for hiding this comment

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

Would there be a way to do a "deep equality" check between plan1 and plan2? The standard library doesn't provide anything for that does it? In which case this seems fine

Copy link
Contributor

Choose a reason for hiding this comment

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

We could do a deep equality check with plan1.to_dict() == plan2.to_dict(). But I don't think this test, as written, is actually exercising anything, since that just means container.plan.to_dict() == container.plan.to_dict().

In order to actually test what this PR is trying to fix, the test should instead be mutating layer1 and ensuring the changes aren't reflected here in the plan, or taking a deep copy of layer1 before passing it into Container and then asserting that layer1 wasn't mutated.

For example:

    layer1_snapshot = copy.deepcopy(layer1.to_dict())  # idk if the copy is actually needed
    container = Container(
        'my-container',
        can_connect=True,
        layers={'base': layer1, 'override': layer2},
    )
    assert layer1.to_dict() == layer1_snapshot

Copy link
Contributor

Choose a reason for hiding this comment

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

I hadn't understood that plan is a property that computes the plan on the fly.

Maybe assert container.plan.to_dict() == container.plan.to_dict() would do what we want here?

Comment on lines +950 to +953
# Service list fields must not accumulate duplicates.
assert plan1.services['svc-a'].after == plan2.services['svc-a'].after
assert plan1.services['svc-a'].before == plan2.services['svc-a'].before
assert plan1.services['svc-a'].requires == plan2.services['svc-a'].requires
Copy link
Contributor

Choose a reason for hiding this comment

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

This tests implementation but not the API.

Imagine one day we'd update runtime pebble code to extend these fields omitting duplicates (instead of plain extend as it is today).

Suddenly this test doesn't actually test anything any more.

A better test could be something like:

initial_state = ...
state_a = ctx.run(on_smth_that_merges_plan, initial_state)
assert "a" in state_a.get_container(...).foo

state_b = ctx.run(on_smthn_else_that_merges_plan, initial_state)
assert "a" not in state_b.get_container(...).foo

claude and others added 11 commits March 17, 2026 17:48
`_MockModelBackend.secret_get()` returned `secret.latest_content` and
`secret.tracked_content` directly, and `action_get()` returned
`action.params` directly. This meant that mutating the returned dict
would silently corrupt the Scenario internal state, a divergence from
production behaviour where fresh data is returned each time.

Apply the same fix as commit be09012 (which addressed the identical
issue in `relation_get`): return `.copy()` / `dict()` so callers get
independent copies.

Includes regression tests that verify mutating the returned dict does
not affect subsequent calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Juju requires secrets to have at least one key with string keys and
values. Validate this in Secret.__post_init__ so charmers get a clear
error at construction time rather than a confusing failure during .run().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rather than raising on the first non-string key/value, collect all bad
entries and report them together. Also use lowercase dict[str, str] in
the error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move _validate_content below __post_init__ in Secret
- Use copy.deepcopy for action_get (params can be nested)
- Dedent test assertions outside context manager scope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tonyandrewmeyer
Copy link
Collaborator Author

I ran the new test with testing/src/scenario/state.py unmodified, and the test passed, but I was expecting it to fail. So it looks like something isn't quite right (perhaps my understanding is off!)

Your understanding is fine, the test was off. It should be fixed now (and fails for me if I remove the fixes). Sorry about that - I should have done that check myself.

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.

5 participants