Skip to content

[snapshot] new d8 snapshot command - #422

Merged
ldmonster merged 447 commits into
mainfrom
feat/d8-snapshot
Aug 4, 2026
Merged

[snapshot] new d8 snapshot command#422
ldmonster merged 447 commits into
mainfrom
feat/d8-snapshot

Conversation

@kkozoriz

@kkozoriz kkozoriz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Adds d8 snapshot, a command family for backing up and restoring Deckhouse
workloads without requiring direct connectivity between the source and
destination clusters. A Snapshot CR captures a namespace's manifests and
volume data in-cluster; the CLI can then move that data offline as a plain
directory tree and restore it later into the same or a different
namespace/cluster.

Requires the state-snapshotter and storage-foundation Deckhouse
modules to be enabled on the cluster
— the CLI talks to their aggregated
API and DataExport/DataImport CRDs, it doesn't implement snapshotting
itself.

Breaking changes

The pre-existing d8 data export/d8 data import commands are updated to
match storage-foundation's current contract (API group move, an added
finalize step on upload, and a changed expiry signal). This requires a
matching storage-foundation version — clusters on an older one are not
compatible.

Commands

  • create — creates a Snapshot object capturing a namespace's state.
    An empty selector captures the whole namespace; --selector limits it to
    matching labels. Reconciled asynchronously by state-snapshotter;
    --wait blocks until it's Ready.
  • list — lists Snapshot resources in the cluster.
  • describe — shows a snapshot's manifest hierarchy as a tree,
    in-cluster.
  • download — downloads a snapshot (manifests + volume data) from the
    cluster into a local directory tree.
  • local describe / local get — inspect a downloaded archive's tree
    or get a one-line summary, fully offline.
  • import — uploads a downloaded archive into a namespace. Rebuilds
    the tree top-down (parents before children, then manifests, then volume
    data last, since a leaf's DataImport stays pending until its ancestors'
    manifests exist). --node restricts the upload to a single subtree.
  • restore — restores a snapshot in-place, into the same namespace it
    came from. Walks the hierarchy node by node, stages and validates the
    whole tree before applying anything. Cross-namespace/cluster restore is
    download + import + restore on the target, not a single command.
  • delete — deletes one or more Snapshot objects (by name, by
    --selector, or --all), letting state-snapshotter garbage-collect the
    associated data.

Archive layout

A downloaded snapshot is a plain directory tree, not a single blob —
manifests and volume data live side by side, and child resources nest
recursively:

<root>/
├── snapshot.yaml        # node metadata + checksum of this node's contents
├── manifests/
│   └── <kind>_<name>.yaml
├── data.tar / data.bin  # volume data (filesystem or block), optionally compressed
└── snapshots/           # child nodes, same structure recursively
    └── <kind>_<name>/
        └── ...

Each node is checksummed independently, so downloads/imports can resume
after an interruption without re-verifying the whole tree.

Typical workflow

  1. d8 snapshot create — capture a namespace's state in the source cluster.
  2. d8 snapshot download — pull manifests + volume data to a local directory.
  3. Move the directory wherever it needs to go (offline storage, another
    environment). Inspect it with local describe/local get if needed.
  4. d8 snapshot import (into a new/different namespace or cluster) or
    d8 snapshot restore (in place, same namespace).
  5. list / describe / delete for day-to-day lifecycle management.

Tests

Unit tests across all new packages, using fake Kubernetes clients and local
HTTP/TLS test servers — no real cluster required. Coverage includes the
download/restore/import pipeline, checksum verification, resumable/chunked
downloads across compression codecs, and the platform-specific archive
safety code that guards against symlink and mount-boundary escapes when
extracting an archive. Concurrency-sensitive packages (pipeline,
volume) are also covered under -race.

Notes

  • Builds and passes lint/tests on Linux, macOS, and Windows.
  • New dependencies are limited to what the feature needs (CSI snapshot
    client, compression codecs) — no unrelated version bumps.

kkozoriz added 30 commits July 7, 2026 19:46
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ckoff

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…er-chunk all-or-nothing

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…rable partial offset

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
… resume

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ceeded

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
… sidecar

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ine test

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…targets

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…le part file

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…rectory

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…pace

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…lready merged

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…-plane client

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
kkozoriz added 6 commits July 30, 2026 14:59
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
The DataImport typed client stayed on the legacy storage.deckhouse.io
group while DataExport was already migrated to
storage-foundation.deckhouse.io in this same branch. The legacy CRD is
removed by storage-foundation's 025-migrate-legacy-crds hook and the
SVDM controller only serves the new group, so `d8 data import
create/upload/delete` would fail outright after merging main.

Also fixes stale docs/comments referencing the removed
`d8 snapshot import` command name (the actual command is
`d8 snapshot upload`), and adds a cross-check test pinning
snapimport's hardcoded DataImport GVR literal to the same group so
the two cannot drift apart again unnoticed.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
GetDataImportWithRestart still matched the legacy standalone
Type=="Expired" condition, which the storage-foundation controller no
longer sets. DataExport's equivalent restart loop was already updated
in this branch to the current contract (Ready=False,
Reason="Expired"); the DataImport side was missed, so an expired
DataImport was never detected and auto-restarted.

Also sets notReadyErr after a successful delete+recreate: previously
the function could fall through and return the stale object's
already-populated status.url/status.volumeMode as if the fresh import
were ready, before the new DataImport actually became Ready.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
alignDataImportTTL patched spec.ttl with a bare Update and no
retry.RetryOnConflict, unlike the equivalent reconcileExistingMarker
logic in the same package. A single 409 from a concurrent
status-writing controller aborted the entire `d8 snapshot upload` run
instead of retrying.

The retry re-Gets the object on conflict rather than resubmitting the
same stale revision, and re-validates identity (validateDataImport)
and the Expired condition on every freshly fetched object before
patching — a naive retry-on-the-same-object could otherwise patch the
TTL of a foreign DataImport that raced in under the same name, or of
an object that expired mid-retry. Both cases now surface
errDataImportRecheck so EnsureDataImport's outer loop re-evaluates the
object from scratch (recreate or delete-and-recreate) instead of
treating the race as a fatal error.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
An interrupted-and-resumed download can leave a child node directory
under snapshots/ with no snapshot.yaml (archive.CollisionNodeDir
redirects the resumed node elsewhere and leaves the original partial
directory in place). archive/checksum.go already tolerates this when
verifying archive integrity, but snapimport's plan builder and
localscan's tree scanner both failed hard on it, so a verified-intact
archive could not actually be uploaded (`d8 snapshot upload`) or
inspected (`d8 snapshot local get/describe`).

Both packages now skip a child directory that carries no
snapshot.yaml the same way checksum.go already does (a non-regular
snapshot.yaml, e.g. a symlink or directory, still fails hard — the
tolerance is scoped to this exact leftover shape) and report what was
skipped. Existing exported BuildPlan*/Scan* signatures are unchanged
(they have many test call sites); a new *ReportingSkips variant in
each package surfaces the skipped paths, and the command layer
(`d8 snapshot upload`, `d8 snapshot local get/describe`) logs a
warning instead of silently dropping part of the tree.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
The package comment still named the removed `d8 snapshot import`
command; the actual command is `d8 snapshot upload` (cmdUse in
cmd/snapimport/import.go). Missed when the other stale references to
the old name were fixed alongside the DataImport API group rename.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
@ldmonster
ldmonster requested a review from Copilot July 30, 2026 15:23

Copilot AI 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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

kkozoriz added 8 commits July 30, 2026 19:10
TestImportFSFromTar_StreamingIsMemoryBounded and
TestPutBlockCompressed_StreamingIsMemoryBounded each push a >=200 MiB
payload through a real httptest.Server. On a healthy machine that is
~0.1s, and neither test had a deadline of its own: they ran on
context.Background().

Without a bound, an environment whose loopback/decode throughput
collapses does not fail these two tests -- it consumes the whole
10-minute `go test` package budget. The binary is then killed on the
package timeout, and every top-level test still parked in t.Parallel()
is reported as a failure alongside the one that actually stalled. That
is what a CI run of this package produced: 18 reported failures, with
the observed upload throughput down to ~165 KiB/s and the timeout
goroutine dump the only place naming the test that really hung.

Both tests now run on a context bounded by the shared
memoryBoundedStreamingTimeout (2 minutes, ~1000x the healthy runtime,
so it can only fire on a pathological environment and never on a
merely slow one). A stall now surfaces as a `context deadline
exceeded` failure in the responsible test, and the rest of the package
keeps running.

This does not make the CI job green by itself -- it removes the
diagnostic noise only. Why throughput collapsed in that environment is
not established here, and no cause is asserted: the commits on this
branch do not touch fs.go, the compress package, or any other code on
the stalling path.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Three tests in this package override the package-level var
maxRetryAttempts to make GetDataImportWithRestart return after one
iteration instead of looping 60+ times:
TestGetDataImportWithRestart_ExpiredRecreates,
_LegacyExpiredConditionIsNotUsed and _CompletedIsNotTreatedAsExpired.
All three also called t.Parallel(), so all three wrote and restored
that shared var concurrently.

Two consequences. It is a plain data race, and it is order-dependent:
one test's t.Cleanup restores the original 60 while another is still
running, so that test stops seeing its own -1 and instead waits out
60 attempts at retryInterval=3s -- roughly three minutes -- and then
fails on a timeout-shaped error rather than the assertion it was
written for.

t.Parallel() is removed from all three, with the reason recorded at
each. The override is left as-is: it is the existing mechanism in this
package and reworking it into an injected parameter is a wider change
than this fix.

Note on why this was never caught: `task test` runs
`gotestsum -- -count=1 -tags=...` (Taskfile.yml:110) with no -race, so
CI cannot see this class of bug at all. Adding -race to the CI test
task is a separate change and is deliberately not made here.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
`d8 data import` built spec.targetRef{kind: PersistentVolumeClaim,
pvcTemplate: ...}. The current storage-foundation DataImport CRD has
no spec.targetRef at all -- the discriminator is spec.mode and
pvcTemplate sits at the top level of the spec.

What that produced, in order. The CRD's structural schema has
preserveUnknownFields: false, so the whole spec.targetRef subtree was
pruned on admission -- silently, no error. Pruning took pvcTemplate
with it, since pvcTemplate was nested inside targetRef. spec.mode was
absent, so the schema default (CreatePVC) was applied. The CEL rule
"mode CreatePVC requires pvcTemplate" then evaluated against a spec
that had mode=CreatePVC and no pvcTemplate, and rejected the object.
The resulting admission error named pvcTemplate, which is the one
field the CLI was in fact sending.

DataImportTargetRefSpec and KindPersistentVolumeClaim are removed and
replaced by a DataImportMode string type with the single constant
DataImportModeCreatePVC. Deepcopy is updated by hand to match, as this
package's zz_generated file has no generator wired up (no hack/, no
controller-gen, no //go:generate).

Mode is sent explicitly even though the CRD defaults it: mode is
immutable after creation, so leaving it to the server-side default
would bind every object this CLI creates to whatever default the
CRD revision on that particular cluster happens to ship.

Version compatibility: a CLI with this change will not create a
DataImport against a storage-foundation deployment old enough to still
expect spec.targetRef. That is accepted and is the continuation of the
API group move already committed in b7d7ab7 -- the two versions of
the CRD are not simultaneously satisfiable from one client.

`d8 snapshot upload` is unaffected: it builds its own unstructured
PopulateData spec and never went through this type.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
spec.waitForFirstConsumer carried omitempty. The CRD defaults that
field to true, so an absent key means true on the server -- and
omitempty drops precisely the zero value, which for a bool is the
false the caller asked for.

The effect was that waitForFirstConsumer could only ever be true.
`--wffc` defaults to false (create.go:70), so the common case -- not
passing the flag at all -- produced a DataImport that waits for a first
consumer, and passing `--wffc=false` explicitly did the same thing. No
error, no warning; the flag was simply inoperative in one direction.

The tag is now `json:"waitForFirstConsumer"`, matching the field in the
storage-foundation API types, with the constraint recorded at the field
so the omitempty is not re-added for consistency with its neighbours.

Publish and Mode keep their omitempty deliberately, and are not
"aligned" along with this one: publish is not defaulted true by the
CRD, so an omitted key and false mean the same thing there, and an
omitted mode is defaulted by the CRD to the same CreatePVC the CLI
sends explicitly anyway.

Kept separate from the spec restructuring in the preceding commit
because it is a different defect with a different root cause -- a json
tag, not the shape of the struct -- and so it can be reverted on its
own.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
DataImportSpec had no test of its own; the package only tested the API
group registration. The two defects fixed in the preceding commits --
the wrong spec shape and the omitempty on waitForFirstConsumer -- were
both invisible to every existing assertion, because both are properties
of the serialised object and not of the Go struct.

New internal/data/dataimport/api/v1alpha1/data_import_test.go:

- TestDataImportSpec_JSONWireShape asserts the exact set of spec keys
  json.Marshal emits, per field, because the CRD treats them
  differently: waitForFirstConsumer must be present even when false (it
  is defaulted to true server-side), publish is correctly omitted when
  false (not defaulted), and targetRef must never appear (it would be
  pruned without an error). Verified to fail if the omitempty is put
  back on waitForFirstConsumer.

- TestDataImportSpec_DeepCopy_DoesNotAliasPvcTemplate mutates every
  reference-typed member of a copied pvcTemplate (name, the accessModes
  slice, the resources.requests map, both pointers) and asserts the
  original is untouched. This package's zz_generated deepcopy is
  hand-maintained -- no controller-gen, no //go:generate -- so nothing
  else would catch a new field that gets shallow-copied. Verified to
  fail if the accessModes branch is removed from DeepCopyInto.

Extended in util_test.go:

- TestCreateDataImport_BuildsCreatePVCSpec gets a subtest asserting
  that CreateDataImport with waitForFirstConsumer=false really puts
  "waitForFirstConsumer":false on the wire.

- TestGetDataImportWithRestart_ExpiredRecreates gets the same
  wire-shape assertion on the recreated object, since the recreate path
  rebuilds the spec from the stale object's fields instead of copying it
  and can regress independently.

Both wire-shape checks are scoped to this package's json tags, and the
comments say so: the controller-runtime fake client applies neither
structural-schema pruning nor CEL, so acceptance by a real apiserver is
not what is being proven here.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
The README still described the removed spec.targetRef.kind contract
(commit 8039617 moved every Go doc comment to spec.mode: CreatePVC
but missed this file), and its create example used the space form
--wffc false, which pflag parses as a positional arg, not a value,
and create's Args validator rejects.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Pre-existing since f651f88; gofmt -l (go1.26 stdlib formatter)
flags it even though golangci-lint's bundled formatter does not.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…copy

Three gaps survived the existing tests, each confirmed by mutation
(existing tests stayed green while the mutated code was objectively
wrong):

- No test pinned spec.pvcTemplate's own JSON keys beyond presence —
  a metadata.name/inline mixup or a renamed accessModes/resources/
  storageClassName/volumeMode key would pass every existing test
  while the server silently drops the field or rejects the object.

- Every existing test only covered CLI -> server (building a spec).
  Nothing covered server -> CLI: decoding an object already on the
  wire, including the recreate-after-expiry path, which is exactly
  where a lost waitForFirstConsumer=false or a dropped
  storageClassName would silently reintroduce this branch's bugs.

- DeepCopy coverage stopped at the spec; EnsureDataImportPublish
  builds its patch from DeepCopy().status/metadata, so an aliased
  status.conditions or metadata.labels would produce an empty patch.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Comment thread internal/snapshot/restore/edit.go Outdated
Comment thread internal/snapshot/archive/names.go
…layouts

FsFileChunksDirName suffixed the chunked file's own name with ".d", so a
file "x" produced a chunk dir literally named "x.d" -- indistinguishable
from a real conf.d-style sibling directory in the same volume (e.g.
"sudoers" next to "sudoers.d/override"). Once that collided, a nested
file's chunk dir ended up physically inside the other file's chunk dir,
and got wiped by MergeBlockChunks' post-merge cleanup or by
ensureChunkGeometry's stale-geometry purge before it could complete.
Fail-closed (the affected file just re-downloaded after a loud
ErrMissingChunk-class error), but the collision was real and reproduced
in an ordinary sequential run, not only under concurrent merges.

Move the codec/geometry-specific part into a fixed leaf directory nested
under relPath instead of suffixing relPath itself:
".d8-meta/chunks/<relPath>/<leaf><ext>.d". Two chunk dirs can now only
nest if the same relPath were simultaneously an ordinary file and a
directory holding another relPath -- impossible on any filesystem.

The progress scanner (scanFSProgressDirectory) identified a chunk dir by
the mere presence of a chunks.meta sidecar, without checking the
directory's name. Left alone, that would misclassify an abandoned
pre-upgrade chunk dir (old flat naming) as valid and credit its bytes
toward resume progress, even though the current download code will
never touch that path again. It now requires the entry to match the
reserved leaf name before trusting chunks.meta inside it.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…bjects

splitYAMLDocs split edited multi-document manifests on the literal byte
sequence "\n---\n", so CRLF line endings, a trailing space after "---",
or a "# comment" following the separator -- all things real editors
produce -- went unrecognized. The whole file then decoded as a single
YAML document, silently keeping only the first object; restore applied
an incomplete set and exited 0. This reached users who never asked for
editing too: the automatic edit session opened by default on an
interactive HTTP 422 preflight rejection goes through the same decode
path as explicit --edit.

Decode with k8s.io/apimachinery/pkg/util/yaml.NewYAMLReader instead, the
same reader kubectl apply -f uses -- it handles all three cases
correctly. A line starting with "---" at column zero followed by
anything other than whitespace or a "#" comment is now a hard parse
error rather than being silently kept as document content; this matches
kubectl's own behavior and is exercised by a new test.

A shrinking or growing object count across an edit session is not
treated as an error. Removing an object from the edited manifests is
already the documented recovery path for a cluster-scoped object or an
already-Bound PVC rejected during restore, and after the parser fix
there is no way to distinguish "the parser dropped an object" from "the
user removed it on purpose" -- both look identical: N objects before,
M after. Aborting on any mismatch would break the very recovery path
the command recommends, so it is reported instead: a WARN listing
removed objects and a INFO listing added ones, identified only by
apiVersion/kind/name (the set may contain Secrets), capped at 20 entries
per direction with the remainder counted. Dry-run validation still runs
on whatever set comes out of the edit session.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
LargeFlatInventoryBoundedHeap still reported a count-dependent delta on a
loaded runner: CI measured 2124600 bytes against the 2 MiB bound. The July
fix removed HeapAlloc's uncollected-garbage term but not the second one
hiding in /gc/heap/live:bytes. That series is heapMarked, and a collection
that overlaps an allocating mutator marks whatever that mutator allocates
during the mark phase, live or not, so it carries an
(allocation rate x mark duration) term. Streaming 30k entries churns
~345 MB in ~2s, and the long run is sampled ~10x more often than the 3k
run, so its peak draws from that tail far more often. At GOMAXPROCS=1 with
60 MiB of unrelated live heap the delta reproduced at 2.3-9.0 MiB, five
failures out of five -- more than the ~6 MiB a full-listing regression
costs, so the noise exceeded the signal the test exists to catch. It is
not retention: running the 3k inventory ten times so both sides process
30k entries leaves the small side inflated by the same 2.8-4.9 MiB, which
is why no bound on a sampled peak can be both flake-free and sensitive.

Read the live heap only where the path is single-threaded and parked, with
two forced collections per reading so the second drops whatever the first
marked black. The load-bearing barrier is a directory item appended to the
listing: walkFSInventory drives everything from one goroutine over an
on-disk queue, so it can only request that directory once the whole flat
listing is ingested, and it stays parked in that round trip until the
handler answers. setTotal, both durability confirmations and the return of
the call cover the merge, the assembly, and anything outliving the call.
The count-dependent delta then measures 26 KiB or less across GOMAXPROCS
1, 2, 4 and 12, with and without that resting heap and with every core
busy, so the bound tightens to 512 KiB. Appending every item to a slice on
the run builder moves the delta to 4.9 MiB and fails at GOMAXPROCS 1 and
12 alike; the ingest reading is asserted to have happened, so a barrier
directory that stopped being listed cannot silently leave the bound
guarding nothing.

Verified: ./internal/snapshot/... green, the volume package green at
-race -count=3, the test green at -count=25 under GOMAXPROCS 1, 2 and 16
and at -race -count=25 under GOMAXPROCS 1 and 2, and the rest of the
snapshot tree green at -race -count=25; golangci-lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SA7yFLVgMnQ7CanHs8R3cs
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
@ldmonster ldmonster added the enhancement New feature or request label Aug 4, 2026
@ldmonster
ldmonster merged commit e4cd67a into main Aug 4, 2026
5 of 6 checks passed
@ldmonster
ldmonster deleted the feat/d8-snapshot branch August 4, 2026 16:17
kkozoriz added a commit that referenced this pull request Aug 5, 2026
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Neumoin, Konstantin <konstantin.neumoin@flant.com>
Signed-off-by: Aleksandr Zimin <alexandr.zimin@flant.com>
Signed-off-by: Konstantin Kozoriz <153918271+kkozoriz@users.noreply.github.com>
Co-authored-by: Neumoin, Konstantin <konstantin.neumoin@flant.com>
Co-authored-by: Aleksandr Zimin <alexandr.zimin@flant.com>
ldmonster pushed a commit that referenced this pull request Aug 6, 2026
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Neumoin, Konstantin <konstantin.neumoin@flant.com>
Signed-off-by: Aleksandr Zimin <alexandr.zimin@flant.com>
Signed-off-by: Konstantin Kozoriz <153918271+kkozoriz@users.noreply.github.com>
Co-authored-by: Neumoin, Konstantin <konstantin.neumoin@flant.com>
Co-authored-by: Aleksandr Zimin <alexandr.zimin@flant.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants