Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
209f4f439e | ||
|
|
a6771b2fbf | ||
|
|
502acd3771 | ||
|
|
87a1e64807 | ||
|
|
952290a49d | ||
|
|
6123a06527 | ||
|
|
12901e0e19 | ||
|
|
a6fb60d38f | ||
|
|
94e437e842 | ||
|
|
c1c9818e30 | ||
|
|
26dbcfd262 |
@@ -81,9 +81,347 @@ Supported components in this source:
|
||||
- `n8n-private-extension`
|
||||
- `module-foundry`
|
||||
- `device-plane`
|
||||
- `gitea`
|
||||
- `proxy-contur`
|
||||
- `dc-amd-proxy`
|
||||
|
||||
## Fresh Gitea installation
|
||||
|
||||
`gitea` is a one-time, fresh-install-only component. Its artifact contains
|
||||
only the reviewed Compose file and strict deployment descriptor; it contains
|
||||
no database, repository, user, token, key, hook, runtime data or secret. Build
|
||||
the deterministic artifact locally with:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-gitea-fresh-install-artifact.mjs \
|
||||
gitea-fresh-install-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
The runtime is fixed to Gitea `1.27.1-rootless` by exact digest and
|
||||
`linux/amd64`. The runner never pulls it: the exact image must first be loaded
|
||||
through a separately reviewed trusted image-acquisition step. Compose is
|
||||
invoked with `--pull never --no-deps`, uses `network_mode: none`, publishes no
|
||||
ports, and serves only the host-mounted Unix socket
|
||||
`/volume1/docker/nodedc-gitea/socket/gitea.sock`. It exposes no SSH port, uses
|
||||
fresh SQLite, enables no LFS, and mounts two runner-generated secrets by URI.
|
||||
Registration and password Basic auth are disabled, while MFA is enforced;
|
||||
hooks, webhooks, migrations, packages, Actions, OAuth2/OpenID, federation and
|
||||
update checks are disabled. HTTP Git with PAT remains the intended trusted
|
||||
client path; PAT clone/push acceptance belongs to the later reviewed
|
||||
user/repository restoration transition, not this empty installation. LFS may
|
||||
only be enabled by that later transition after its secret and restart behavior
|
||||
has been accepted against the pinned runtime.
|
||||
|
||||
Plan and apply fail closed unless all live prerequisites are already true:
|
||||
|
||||
- `/volume1/docker/nodedc-gitea`, the `nodedc-gitea` project, and any stale
|
||||
`nodedc-gitea_internal` network are absent; TCP/4022 is closed and Docker
|
||||
publishes neither TCP/3000 nor TCP/4022;
|
||||
- Docker Server is exactly 24.0.2, Compose is at least 2.20.1, and the exact
|
||||
pinned image ID and RepoDigest are local for linux/amd64 with image user
|
||||
`1000:1000`;
|
||||
- the separate root-owned Nginx bridge
|
||||
`/usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf` matches its exact
|
||||
reviewed bytes, owner/mode and SHA-256; Nginx 1.23.1 configuration validates,
|
||||
and only its root/uid-1023 processes own `127.0.0.1:3000`. The bridge proxies
|
||||
to the Unix socket, preserves Host/X-Real-IP/XFF/XFP, rate-limits login and
|
||||
bounds concurrent requests without limiting Git request bodies;
|
||||
- DSM reverse-proxy UUID `5bc46027-0307-4261-af7e-4f94a3c508c9` persistently
|
||||
and in generated nginx config routes `git.dcserve.ru:443` to
|
||||
`127.0.0.1:3000`; the generated vhost must also retain its exact host guard
|
||||
and canonical Host/X-Real-IP/XFF/XFP forwarding directives, so the UDS
|
||||
bridge never trusts a client-supplied `X-Real-IP`;
|
||||
- the emergency broad INPUT DROP for TCP/3000 has been removed, while the
|
||||
legacy `172.22.0.222:3000` OUTPUT DROP and TCP/4022 INPUT DROP remain;
|
||||
- any legacy container named `gitea` is stopped with restart policy `no`.
|
||||
|
||||
Changing DSM reverse proxy/firewall rules, acquiring evidence from the old
|
||||
root, restoring trusted users/repositories, and loading the pinned image are
|
||||
separate reviewed transitions. This component never reads or mounts
|
||||
`/volume1/docker/gitea` and never starts the legacy container. Failed apply
|
||||
stops/removes only the `nodedc-gitea` candidate, proves that it is absent, and
|
||||
quarantines the complete newly created root (including data/config/socket/secrets) for
|
||||
evidence, and restores the absent source/root state without deleting runtime
|
||||
evidence. If candidate absence cannot be proven, rollback preserves the root in
|
||||
place and records reconciliation-required instead of disconnecting live bind
|
||||
mounts.
|
||||
|
||||
Run the policy suite before publishing:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_gitea_fresh_install.py -v
|
||||
```
|
||||
|
||||
## Gitea incident salvage (clean-state recovery)
|
||||
|
||||
The additive `gitea-incident-salvage` slice is the reviewed recovery design for
|
||||
incident `gitea-20260814`. It does not upgrade or mount the compromised state.
|
||||
The candidate starts with a new SQLite database, new config, new runtime
|
||||
secrets, new email addresses and new password hashes. The exact v2 decision
|
||||
bundle is embedded as inert, digest-bound data:
|
||||
|
||||
- 2 new active identities (`dctouch`, admin; `SILVER`, non-admin), each with a
|
||||
new root-contained one-time password and mandatory password change;
|
||||
- 8 new locked identities with random discarded passwords, inactive,
|
||||
restricted and prohibited from login;
|
||||
- 45 exact repositories: 32 private `dctouch` repositories and 13 public
|
||||
`SILVER` repositories. The other 962 legacy users and 2013 legacy repository
|
||||
rows are not imported into the clean database.
|
||||
|
||||
The successor v3 descriptor embeds the owner-confirmed, canonical
|
||||
`confirmed-disposition-v1.json` and the additive
|
||||
`confirmed-closure-disposition-v1.json`. Both are bound to the exact snapshot,
|
||||
database, identity decision, reference manifest, unsupported-state report,
|
||||
schema catalog and semantic-topics hashes. The original disposition's 105
|
||||
reference decisions are explicit rows, not namespace wildcards: 85 normal
|
||||
heads, 4 tags and 4 wiki heads are eligible for later live reconstruction; the
|
||||
exact 5 pull refs and 7 remote refs are sealed archive-only evidence and can
|
||||
never be auto-promoted. The closure disposition authorizes only bounded
|
||||
plan-time inventory and records the target policy for access, collaboration,
|
||||
issue/PR metadata, attachments, releases, labels, projects, units, Packages
|
||||
and Actions. Observed hashes do not become activation authority, and the new
|
||||
closure-report digest is deliberately unpinned until separate review.
|
||||
|
||||
Build the deterministic review artifact locally with:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-gitea-incident-salvage-artifact.mjs \
|
||||
gitea-incident-salvage-subrelation-closure-20260814-006
|
||||
```
|
||||
|
||||
The original v1 descriptor and `...salvage-20260814-001.tgz` remain immutable.
|
||||
The exact `...salvage-disposition-20260814-002.tgz` also remains immutable and
|
||||
must not be retried: its plan failed safely because it assumed every nonempty
|
||||
`repository.topics` TEXT value was a JSON array. A bounded immutable-database
|
||||
classification proved all 45 kept rows are instead exact four-byte canonical
|
||||
JSON `null`, Gitea's semantic empty representation. The `...topics-...-003`
|
||||
artifact is the forward fix with a new patch id and digest.
|
||||
That exact `...topics-...-003` artifact is now also an immutable predecessor.
|
||||
The local pre-audit `...closure-...-004` draft must not be staged: review found
|
||||
orphan-join and nullable-Actions gaps. The local
|
||||
`...closure-reviewed-...-005` artifact is also stale and must not be staged:
|
||||
its primary closure was fail-closed, but it retained a broad polymorphic-
|
||||
subrelation blocker. The additive `...subrelation-closure-...-006` successor
|
||||
closes the concrete comment/history/merger relations in the pinned snapshot
|
||||
schema and is the only artifact produced by the current builder.
|
||||
The successor builder refuses to overwrite an existing artifact basename and
|
||||
publishes from an exclusively created same-directory temporary file using an
|
||||
atomic no-clobber hard link. A failed build removes only its private temporary
|
||||
file. The disposition is sorted, compact ASCII JSON with one terminal LF.
|
||||
|
||||
The runtime pin is
|
||||
`docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49a…3abd2c`, with exact
|
||||
linux/amd64 OCI image ID `sha256:272085a8…bc5c4c6`. It runs as `1000:1000`,
|
||||
uses the direct Gitea binary, `network_mode: none`, no Docker ports, no SSH and
|
||||
no LFS. Candidate restart policy is `no`. The existing reviewed Nginx bridge
|
||||
cannot reach the candidate while the socket parent is mode `0700`; publication
|
||||
is an explicit post-acceptance change to `0750`, followed by public TLS and
|
||||
smart-HTTP checks. Only after those checks may restart policy become
|
||||
`unless-stopped`. The transition never changes or enforces two-factor
|
||||
authentication.
|
||||
|
||||
The snapshot boundary is exact: UUID
|
||||
`f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f`, immutable SQLite SHA-256
|
||||
`8db9e74a…1a13052`, and the 972/2058 decisions must recompute from the database
|
||||
row by row. The runner enumerates only each v2 `repo_relative_path` and
|
||||
`wiki_relative_path`. It rejects symlinks, special files, hard links, NOCOW,
|
||||
alternates, shallow/replace/grafts/worktrees, promisor state, unknown object
|
||||
material and incomplete pack/index pairs. One preloaded mount inventory also
|
||||
guards the `objects` and `refs` roots plus every traversed directory and file:
|
||||
all must remain on the trusted snapshot device, outside nested mountpoints and,
|
||||
for directories, outside nested Btrfs subvolumes. Traversal errors fail closed.
|
||||
Forbidden-path probes (`objects/info/alternates`, grafts, replace refs and the
|
||||
other listed sentinels) lstat each path component without following links and
|
||||
validate every ancestor before probing its child; a symlink or mount boundary
|
||||
therefore stops the plan before any lookup can escape the snapshot tree.
|
||||
|
||||
Only three exact derived-file classes observed in the bounded 45-repository
|
||||
inventory are excluded: dumb-HTTP `objects/info/packs`,
|
||||
`objects/pack/pack-<oid>.bitmap` with its exact complete pack/index pair, and
|
||||
monolithic `objects/info/commit-graph`. Each must be a bounded regular,
|
||||
single-link, non-NOCOW file. The reference manifest records its exact path,
|
||||
class and byte size, but the runner neither reads nor copies the excluded
|
||||
bytes. Unobserved `.rev`, MIDX and split commit-graph layouts stay fail-closed;
|
||||
`.promisor`, `.mtimes`, `.keep`, unknown and temporary lookalikes are not
|
||||
classified as disposable accelerators.
|
||||
|
||||
The same inventory found receive-pack quarantine directories matching only an
|
||||
exact direct child `objects/tmp_objdir-incoming-[A-Za-z0-9]{6}`. A match must
|
||||
retain the reviewed uid/gid `1000:1000`, mode `0755`, single-link and non-NOCOW
|
||||
directory metadata. Its exact path and lstat fields are recorded, then the
|
||||
subtree is pruned without reading, hashing, copying or reflinking contents; the
|
||||
sealed read-only incident snapshot retains the forensic copy. Any name,
|
||||
metadata, mount or subvolume drift remains a hard stop. A reviewed reference/OID
|
||||
manifest is required; raw HEAD/refs files are never copied. The intended data
|
||||
path is same-filesystem per-file `--reflink=always`, with no byte-copy fallback
|
||||
and a bounded exclusive-allocation gate.
|
||||
|
||||
This slice is intentionally **NO-GO for apply** at the current review point.
|
||||
`plan` inventories and prints the exact ref-manifest digest, byte length and
|
||||
canonical compact JSON. The JSON is the reviewable, hash-bound reference/OID
|
||||
artifact: it contains approved repository identities, ref names/OIDs, object
|
||||
totals and stat-bound derived/quarantine exclusions, but no Git object bytes,
|
||||
hooks or config. The same plan prints the canonical unsupported-state report
|
||||
and a separate incident-closure report, each with its SHA-256, byte length and
|
||||
compact ASCII JSON. The closure report is evidence only: its disposition keeps
|
||||
`expectedSha256` and `expectedBytes` null and retains an explicit review-pin
|
||||
blocker.
|
||||
|
||||
The reviewed plan evidence bound by the disposition is reference manifest
|
||||
`9cddaf0e…a311` (36,010 bytes), unsupported-state report
|
||||
`4b2cecf8…106ac` (119,468 bytes), and schema catalog
|
||||
`b5e3b677…b6db`. The report has exact 45 repository rows with empty
|
||||
`schema_missing`, `schema_mismatch`, and `anomalies` arrays. These facts close
|
||||
only the owner's material-state decision; fsck/reachability, physical blob
|
||||
inventory, sanitized archive creation and candidate acceptance remain separate
|
||||
verifiers.
|
||||
|
||||
The unsupported-state report has one deterministic row for every one of the
|
||||
45 kept repository IDs, including explicit zeroes. It separates direct
|
||||
repository-relation counts, directional base/head PR counts, repo-unit type
|
||||
counts, text-metadata presence booleans and denormalized numeric hints; it does
|
||||
not add overlapping relations into a misleading grand total. LFS rows are
|
||||
reported per repository with validated logical byte sums and distinct-OID
|
||||
aggregates, including size conflicts and sharing with non-kept repositories.
|
||||
Every association sharing a kept OID must also have an integer repository ID
|
||||
that resolves to the exact snapshot `repository` table; invalid and orphan
|
||||
owners make shared/unique byte aggregates unavailable and add a data-anomaly
|
||||
blocker.
|
||||
Attachments are reported per repository with validated logical byte sums and
|
||||
explicit, overlapping issue/comment/release/unlinked link splits. Neither
|
||||
category claims that a physical blob exists; no title, body, note, comment,
|
||||
config, URL, token, key, secret, hash payload or blob content is selected or
|
||||
exported.
|
||||
|
||||
The report also embeds the exact `PRAGMA table_xinfo` catalog and explicit
|
||||
label-to-table-to-repository-column map for a code-owned allowlist of relevant
|
||||
tables. `sqlite_master` must prove an ordinary, non-virtual table before any
|
||||
row query; SQLite 3.37+ adds `PRAGMA table_list` ordinary-main-table proof. On
|
||||
an older compatible SQLite, the weaker object-kind capability is recorded and
|
||||
adds a coverage blocker. `query_only=ON` and `trusted_schema=OFF` are read back
|
||||
before the snapshot connection is accepted. Only column identifiers and
|
||||
structural metadata are exported, never DDL, defaults or row values. The v3
|
||||
closure inventory follows code-owned, schema-attested primary joins for the
|
||||
issue/PR dependency tables (comments, assignees, content histories, labels, issue
|
||||
users/watches, reactions, reviews/state, dependencies, notifications,
|
||||
stopwatches, tracked time and auto-merge), plus projects/boards, releases,
|
||||
attachments, Packages and Actions. It emits only IDs required to prove joins,
|
||||
actor classes, counts, declared sizes and SQLite byte lengths; it never selects
|
||||
or prints text bodies, names, URLs, package metadata, workflow payloads,
|
||||
credentials, tokens, secrets or logs. Package and Actions target features stay
|
||||
disabled and no legacy row is importable. The successor additionally closes
|
||||
every concrete FK-like comment field in the pinned schema (labels, projects,
|
||||
milestones, tracked time, assignees, dependency, review and cross-reference),
|
||||
content-history-to-comment, review-team and pull-merger provenance. Target
|
||||
repository and local actor IDs are classified through the exact 2058/972
|
||||
decisions. Migrated external-author IDs are never treated as local users and
|
||||
are represented only by presence counts and author-name byte lengths. A
|
||||
nonzero comment-assignee or review-reviewer team must resolve to an exact team
|
||||
and organization; because no organization/team allowlist exists, its row,
|
||||
team and organization IDs enter a sealed HOLD inventory and add the narrowly
|
||||
scoped `issue-pr-team-mapping-hold` blocker. Orphans, conflicting user/team
|
||||
identities, invalid cross-reference pairs and schema/type drift fail closed.
|
||||
The report is still not a physical-file or sanitized-archive verifier, so
|
||||
those separate blockers remain explicit.
|
||||
The schema-catalog digest is recorded as observed evidence but deliberately
|
||||
remains unpinned as reviewed activation authority. Schema absence/type drift,
|
||||
invalid numeric/OID state or aggregate overflow is a hard error. `apply` fails
|
||||
before creating `/volume1/docker/nodedc-gitea` until all of the following are
|
||||
registered:
|
||||
|
||||
- root-owned Btrfs proof that the named snapshot UUID is read-only;
|
||||
- exact stopped/restart-`no` legacy container image ID, image ref and sole
|
||||
`/volume1/docker/gitea:/data` bind inspect;
|
||||
- a separately verified exact reference/OID manifest digest, fsck,
|
||||
reachability and selected-object reconstruction;
|
||||
- immutable sanitized archives for issues, PRs, comments, releases, labels,
|
||||
projects and repository descriptions, with no legacy row import;
|
||||
- physical attachment inventory and reachable LFS pointer/OID/size/SHA proof;
|
||||
- kept-user mapping before any collaboration recreation; and
|
||||
- package/Actions schema plus physical zero closure and target unit-policy
|
||||
acceptance.
|
||||
|
||||
All 25 direct repository relations have explicit dispositions, including the
|
||||
15 exact zero-and-drop categories. Pull base/head counts remain directional
|
||||
views of the same five PRs. All repository numeric hints are dropped and
|
||||
recomputed. Repo-unit types 1–10 are total: clean types 1–5 and 8 are recreated
|
||||
once per repository; external wiki/tracker are forbidden; Packages (9) and
|
||||
Actions (10) are absent in the target and remain globally disabled. Legacy
|
||||
unit config is never imported. The topics verifier accepts exact JSON `null`
|
||||
as semantic empty state, or a canonical JSON array of sorted unique lowercase
|
||||
names matching `^[a-z0-9][-.a-z0-9]*$` with at most 35 UTF-8 bytes. SQL NULL,
|
||||
whitespace/case variants, quoted `"null"`, objects, numbers, booleans and
|
||||
noncanonical arrays fail closed. The current snapshot must prove exactly 45
|
||||
serialized JSON nulls, zero serialized arrays, zero material topics and zero
|
||||
`repo_topic` rows; target cache is rebuilt from relations.
|
||||
|
||||
The activation hook remains frozen until these blockers are closed and the
|
||||
full clean-database recreation, reflink reconstruction, doctor/fsck,
|
||||
credential-table zeroing, rollback and public acceptance path has its own
|
||||
runtime smoke. The current reviewed source identities are:
|
||||
|
||||
- runner `nodedc-deploy` SHA-256
|
||||
`c766985aa02fe911fa5a873717276f3503d9cecded448e41dc6945030dcbce9e`;
|
||||
- deterministic builder SHA-256
|
||||
`b92076583aeab854015a05deb9d942c8dfcf3ca475fd3d52115d402d6f61acca`;
|
||||
- salvage policy test SHA-256
|
||||
`99b96b6179286a78e7d53ee6a923fc092e3471a8cece382a472449d455ad212c`;
|
||||
- fresh-install policy test SHA-256
|
||||
`20653c640142c25e7d2ef712048acc7889c56e1d519e2d03b5cad4127e48947c`;
|
||||
- confirmed disposition SHA-256
|
||||
`0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c`;
|
||||
- confirmed closure disposition SHA-256
|
||||
`7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243`;
|
||||
- successor v3 descriptor SHA-256
|
||||
`9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a`;
|
||||
- deterministic local review artifact
|
||||
`nodedc-gitea-gitea-incident-salvage-subrelation-closure-20260814-006.tgz`
|
||||
SHA-256
|
||||
`d3e598cf892b1371912dcd9cef64caa991dd23613e48fe979d0f95d493c23d53`.
|
||||
|
||||
Only after that exact runner is separately reviewed, promoted to the
|
||||
root-owned runner path and its installed SHA-256 is re-attested may the exact
|
||||
artifact be staged into the canonical inbox for a canonical `plan` command.
|
||||
Staging is evidence collection only; it grants no authority to run `apply`.
|
||||
The legacy identity is now pinned to the observed ref `gitea/gitea:latest`
|
||||
and immutable image ID
|
||||
`sha256:bf95d9a45ce4fe38b027d051cdc4a4bc531513489fa6244af4074efbb1c376d6`.
|
||||
The mutable tag is provenance only. Every plan re-inspects exact name
|
||||
`/gitea`, stopped state, restart `no`, and the sole RW bind
|
||||
`/volume1/docker/gitea:/data`; drift is a hard stop.
|
||||
|
||||
The successor plan must report these exact remaining blockers:
|
||||
|
||||
- `attachment-physical-verifier-pending`;
|
||||
- `candidate-root-activation-hard-frozen`;
|
||||
- `closure-report-review-pin-pending`;
|
||||
- `collaboration-kept-user-mapping-verifier-pending`;
|
||||
- `forensic-ref-archive-verifier-pending`;
|
||||
- `issue-pr-metadata-sanitized-archive-verifier-pending`;
|
||||
- `lfs-reachable-pointer-physical-verifier-pending`;
|
||||
- `package-action-physical-closure-verifier-pending`;
|
||||
- `reference-manifest-fsck-reachability-verifier-pending`;
|
||||
- `repository-object-reconstruction-verifier-pending`;
|
||||
- `target-unit-policy-acceptance-pending`;
|
||||
- `unsupported-schema-catalog-verifier-pending`.
|
||||
|
||||
Any other blocker or identity drift is also a stop. `apply` remains prohibited
|
||||
and hard-frozen before root creation until a subsequent reviewed runner
|
||||
revision closes every blocker and implements and smoke-tests the complete
|
||||
activation/rollback path. The successor files must be independently reviewed
|
||||
before any runner promotion or inbox staging.
|
||||
|
||||
Run the bounded policy suite with:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_gitea_incident_salvage.py -v
|
||||
```
|
||||
|
||||
The frozen local cutoff passes 54 incident-salvage tests and 26 fresh-install
|
||||
tests, plus Python compilation and `git diff --check`.
|
||||
|
||||
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
|
||||
n8n private-node releases. Its artifact may contain exactly one digest-bound
|
||||
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureRoot = resolve(scriptDir, "fixtures/gitea");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "gitea-fresh-install-20260813-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-gitea-fresh-install-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const composeRelative = "docker-compose.gitea.yml";
|
||||
const descriptorRelative = "deployment/gitea-fresh-install-v1.json";
|
||||
const files = [composeRelative, descriptorRelative];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-gitea-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-gitea-${patchId}.tgz`);
|
||||
|
||||
await assertFixtureContract();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relative of files) {
|
||||
const source = resolve(fixtureRoot, relative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
||||
throw new Error(`gitea_fixture_type_rejected:${relative}`);
|
||||
}
|
||||
const destination = join(payload, relative);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=gitea\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = sha256(await readFile(target));
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "gitea",
|
||||
entries: files,
|
||||
services: ["gitea"],
|
||||
image: "docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148",
|
||||
installMode: "fresh-only",
|
||||
database: "fresh-sqlite-only",
|
||||
lfs: "disabled-pending-reviewed-restore-transition",
|
||||
transport: "unix:/run/gitea/gitea.sock",
|
||||
networkMode: "none",
|
||||
minimumComposeVersion: "2.20.1",
|
||||
preserved: ["legacy-gitea-root-unread-and-untouched"],
|
||||
excluded: [
|
||||
"secrets",
|
||||
"runtime-data",
|
||||
"database",
|
||||
"repositories",
|
||||
"users",
|
||||
"tokens",
|
||||
"ssh-keys",
|
||||
"hooks",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertFixtureContract() {
|
||||
const composeBytes = await readFile(resolve(fixtureRoot, composeRelative));
|
||||
const compose = composeBytes.toString("utf8");
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(resolve(fixtureRoot, descriptorRelative), "utf8"),
|
||||
);
|
||||
const expectedImage =
|
||||
"docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148";
|
||||
const required = [
|
||||
`image: ${expectedImage}`,
|
||||
"platform: linux/amd64",
|
||||
"pull_policy: never",
|
||||
"network_mode: none",
|
||||
'user: "1000:1000"',
|
||||
"stop_grace_period: 30s",
|
||||
"driver: json-file",
|
||||
'max-size: "10m"',
|
||||
'max-file: "3"',
|
||||
"GITEA__server__PROTOCOL: http+unix",
|
||||
"GITEA__server__HTTP_ADDR: /run/gitea/gitea.sock",
|
||||
'GITEA__server__UNIX_SOCKET_PERMISSION: "0666"',
|
||||
"GITEA__server__LOCAL_ROOT_URL: http://unix/",
|
||||
'GITEA__server__DISABLE_SSH: "true"',
|
||||
'GITEA__server__LFS_START_SERVER: "false"',
|
||||
'GITEA__server__LFS_ALLOW_PURE_SSH: "false"',
|
||||
"GITEA__security__SECRET_KEY_URI: file:/run/secrets/gitea_secret_key",
|
||||
"GITEA__security__INTERNAL_TOKEN_URI: file:/run/secrets/gitea_internal_token",
|
||||
"GITEA__security__TWO_FACTOR_AUTH: enforced",
|
||||
'GITEA__security__REVERSE_PROXY_LIMIT: "1"',
|
||||
"GITEA__security__ALLOWED_HOST_LIST: loopback",
|
||||
"GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 127.0.0.0/8,::1/128",
|
||||
'GITEA__service__DISABLE_REGISTRATION: "true"',
|
||||
'GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION: "false"',
|
||||
'GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION_API: "false"',
|
||||
'GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION: "false"',
|
||||
'GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false"',
|
||||
'GITEA__admin__DISABLE_REGULAR_ORG_CREATION: "true"',
|
||||
"GITEA__admin__USER_DISABLED_FEATURES: deletion,manage_ssh_keys,manage_gpg_keys,change_username",
|
||||
'GITEA__security__DISABLE_GIT_HOOKS: "true"',
|
||||
'GITEA__security__DISABLE_WEBHOOKS: "true"',
|
||||
'GITEA__repository__DISABLE_MIGRATIONS: "true"',
|
||||
'GITEA__packages__ENABLED: "false"',
|
||||
'GITEA__oauth2__ENABLED: "false"',
|
||||
'GITEA__openid__ENABLE_OPENID_SIGNIN: "false"',
|
||||
'GITEA__cron.update_checker__ENABLED: "false"',
|
||||
"source: /volume1/docker/nodedc-gitea/socket",
|
||||
"target: /run/gitea",
|
||||
"create_host_path: false",
|
||||
"read_only: true",
|
||||
"no-new-privileges:true",
|
||||
];
|
||||
for (const fragment of required) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`gitea_compose_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"4022",
|
||||
"2222:2222",
|
||||
"0.0.0.0:3000",
|
||||
"ports:",
|
||||
"networks:",
|
||||
"/var/run/docker.sock",
|
||||
"/volume1/docker/gitea",
|
||||
"privileged: true",
|
||||
"pull_policy: always",
|
||||
"__FILE",
|
||||
"GITEA__security__SECRET_KEY:",
|
||||
"GITEA__security__INTERNAL_TOKEN:",
|
||||
"GITEA__server__LFS_JWT_SECRET:",
|
||||
"GITEA__server__LFS_JWT_SECRET_URI",
|
||||
"gitea_lfs_jwt_secret",
|
||||
"lfs-jwt-secret",
|
||||
"GITEA__server__REVERSE_PROXY_LIMIT",
|
||||
"GITEA__server__REVERSE_PROXY_TRUSTED_PROXIES",
|
||||
"GITEA__security__ENABLE_REVERSE_PROXY_AUTHENTICATION",
|
||||
"GITEA__security__ENABLE_REVERSE_PROXY_AUTHENTICATION_API",
|
||||
"GITEA__security__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION",
|
||||
"GITEA__service__DISABLE_REGULAR_ORG_CREATION",
|
||||
"GITEA__service__USER_DISABLED_FEATURES",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`gitea_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.gitea.fresh-install.v1"
|
||||
|| descriptor.action !== "fresh-install"
|
||||
|| descriptor.component !== "gitea"
|
||||
|| descriptor.compose?.sha256 !== sha256(composeBytes)
|
||||
|| descriptor.runtime?.image !== expectedImage
|
||||
|| descriptor.runtime?.minimumComposeVersion !== "2.20.1"
|
||||
|| descriptor.runtime?.lfs !== "disabled-pending-reviewed-restore-transition"
|
||||
|| descriptor.runtime?.transport !== "unix:/run/gitea/gitea.sock"
|
||||
|| descriptor.runtime?.networkMode !== "none"
|
||||
|| descriptor.runtime?.logging !== "bounded-json-file-10m-x3"
|
||||
|| descriptor.runtime?.stopGracePeriod !== "30s"
|
||||
|| descriptor.trust?.artifactSecrets !== "forbidden"
|
||||
|| descriptor.trust?.legacyRootAccess !== "forbidden"
|
||||
) {
|
||||
throw new Error("gitea_descriptor_contract_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureRoot = resolve(scriptDir, "fixtures/gitea-salvage");
|
||||
const decisionRoot = resolve(
|
||||
scriptDir,
|
||||
"../../../security-incidents/gitea-20260814/confirmed-decisions-v2",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "gitea-incident-salvage-subrelation-closure-20260814-006", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-gitea-incident-salvage-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const composeRelative = "docker-compose.gitea.yml";
|
||||
const descriptorRelative = "deployment/gitea-incident-salvage-v3.json";
|
||||
const decisionPrefix = "deployment/gitea-incident-salvage";
|
||||
const dispositionRelative = `${decisionPrefix}/confirmed-disposition-v1.json`;
|
||||
const closureDispositionRelative =
|
||||
`${decisionPrefix}/confirmed-closure-disposition-v1.json`;
|
||||
const descriptorSha256 =
|
||||
"9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a";
|
||||
const dispositionSha256 =
|
||||
"0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c";
|
||||
const closureDispositionSha256 =
|
||||
"7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243";
|
||||
const decisionFiles = [
|
||||
["confirmed-decision.json", "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a"],
|
||||
["users.decisions.csv", "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6"],
|
||||
["repositories.decisions.csv", "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493"],
|
||||
];
|
||||
const files = [
|
||||
composeRelative,
|
||||
descriptorRelative,
|
||||
dispositionRelative,
|
||||
closureDispositionRelative,
|
||||
...decisionFiles.map(([name]) => `${decisionPrefix}/${name}`),
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-gitea-salvage-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-gitea-${patchId}.tgz`);
|
||||
const targetTemporary = join(
|
||||
artifactDir,
|
||||
`.${basename(target)}.${process.pid}.tmp`,
|
||||
);
|
||||
|
||||
await assertSourceContract();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relative of [
|
||||
composeRelative,
|
||||
descriptorRelative,
|
||||
dispositionRelative,
|
||||
closureDispositionRelative,
|
||||
]) {
|
||||
await copyRegularFile(resolve(fixtureRoot, relative), join(payload, relative), relative);
|
||||
}
|
||||
for (const [name] of decisionFiles) {
|
||||
await copyRegularFile(
|
||||
resolve(decisionRoot, name),
|
||||
join(payload, decisionPrefix, name),
|
||||
`${decisionPrefix}/${name}`,
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=gitea\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), targetTemporary, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
try {
|
||||
await link(targetTemporary, target);
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error("gitea_salvage_artifact_target_already_exists");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: sha256(await readFile(target)),
|
||||
component: "gitea",
|
||||
transition: "clean-state-incident-salvage",
|
||||
entries: files,
|
||||
services: ["gitea"],
|
||||
image: "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
decisions: {
|
||||
users: { active: 2, locked: 8, delete: 962 },
|
||||
repositories: { keep: 45, delete: 2013 },
|
||||
},
|
||||
disposition: {
|
||||
sha256: dispositionSha256,
|
||||
forensicRefs: 105,
|
||||
liveRefs: 93,
|
||||
archiveOnlyRefs: 12,
|
||||
},
|
||||
closureDisposition: {
|
||||
sha256: closureDispositionSha256,
|
||||
state: "plan-report-review-pending",
|
||||
},
|
||||
stateBoundary: "new-database-config-secrets-identities",
|
||||
repositoryBoundary: "exact-45-object-and-validated-ref-material-only",
|
||||
networkMode: "none",
|
||||
transport: "unix:/run/gitea/gitea.sock",
|
||||
stagePolicy: "after-exact-runner-promotion-plan-only",
|
||||
applyPolicy: "hard-frozen-before-root-creation",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(targetTemporary, { force: true });
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copyRegularFile(source, destination, label) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
||||
throw new Error(`gitea_salvage_source_type_rejected:${label}`);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalJson);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function assertSourceContract() {
|
||||
for (const [name, expected] of decisionFiles) {
|
||||
const bytes = await readFile(resolve(decisionRoot, name));
|
||||
if (sha256(bytes) !== expected) {
|
||||
throw new Error(`gitea_salvage_decision_digest_mismatch:${name}`);
|
||||
}
|
||||
}
|
||||
const composeBytes = await readFile(resolve(fixtureRoot, composeRelative));
|
||||
const compose = composeBytes.toString("utf8");
|
||||
const descriptorBytes = await readFile(
|
||||
resolve(fixtureRoot, descriptorRelative),
|
||||
);
|
||||
const descriptor = JSON.parse(descriptorBytes.toString("utf8"));
|
||||
if (sha256(descriptorBytes) !== descriptorSha256) {
|
||||
throw new Error("gitea_salvage_descriptor_digest_mismatch");
|
||||
}
|
||||
const dispositionBytes = await readFile(
|
||||
resolve(fixtureRoot, dispositionRelative),
|
||||
);
|
||||
const disposition = JSON.parse(dispositionBytes.toString("utf8"));
|
||||
if (sha256(dispositionBytes) !== dispositionSha256) {
|
||||
throw new Error("gitea_salvage_disposition_digest_mismatch");
|
||||
}
|
||||
const canonicalDisposition = Buffer.from(
|
||||
`${JSON.stringify(canonicalJson(disposition))}\n`,
|
||||
"utf8",
|
||||
);
|
||||
if (!dispositionBytes.equals(canonicalDisposition)) {
|
||||
throw new Error("gitea_salvage_disposition_not_canonical");
|
||||
}
|
||||
const closureDispositionBytes = await readFile(
|
||||
resolve(fixtureRoot, closureDispositionRelative),
|
||||
);
|
||||
const closureDisposition = JSON.parse(closureDispositionBytes.toString("utf8"));
|
||||
if (sha256(closureDispositionBytes) !== closureDispositionSha256) {
|
||||
throw new Error("gitea_salvage_closure_disposition_digest_mismatch");
|
||||
}
|
||||
const canonicalClosureDisposition = Buffer.from(
|
||||
`${JSON.stringify(canonicalJson(closureDisposition))}\n`,
|
||||
"utf8",
|
||||
);
|
||||
if (!closureDispositionBytes.equals(canonicalClosureDisposition)) {
|
||||
throw new Error("gitea_salvage_closure_disposition_not_canonical");
|
||||
}
|
||||
const expectedImage =
|
||||
"docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c";
|
||||
for (const required of [
|
||||
`image: ${expectedImage}`,
|
||||
"platform: linux/amd64",
|
||||
"pull_policy: never",
|
||||
"network_mode: none",
|
||||
'user: "1000:1000"',
|
||||
"entrypoint:",
|
||||
"- /usr/local/bin/gitea",
|
||||
"- /etc/gitea/app.ini",
|
||||
"read_only: true",
|
||||
"cap_drop:",
|
||||
"- ALL",
|
||||
"no-new-privileges:true",
|
||||
"source: /volume1/docker/nodedc-gitea/data",
|
||||
"target: /data",
|
||||
"source: /volume1/docker/nodedc-gitea/config",
|
||||
"target: /etc/gitea",
|
||||
"source: /volume1/docker/nodedc-gitea/socket",
|
||||
"target: /run/gitea",
|
||||
"create_host_path: false",
|
||||
]) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`gitea_salvage_compose_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"networks:",
|
||||
"/var/run/docker.sock",
|
||||
"/volume1/docker/gitea",
|
||||
"privileged: true",
|
||||
"4022",
|
||||
"TWO_FACTOR_AUTH",
|
||||
"LFS_JWT_SECRET",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`gitea_salvage_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.gitea.incident-salvage.v3"
|
||||
|| descriptor.action !== "clean-state-salvage"
|
||||
|| descriptor.compose?.sha256 !== sha256(composeBytes)
|
||||
|| descriptor.runtime?.image !== expectedImage
|
||||
|| descriptor.runtime?.networkMode !== "none"
|
||||
|| descriptor.runtime?.database !== "new-sqlite-1.27.2-only"
|
||||
|| descriptor.disposition?.file !== dispositionRelative
|
||||
|| descriptor.disposition?.sha256 !== dispositionSha256
|
||||
|| descriptor.closureDisposition?.file !== closureDispositionRelative
|
||||
|| descriptor.closureDisposition?.sha256 !== closureDispositionSha256
|
||||
|| descriptor.closureDisposition?.predecessorArtifactSha256
|
||||
!== "d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672"
|
||||
|| descriptor.trust?.legacyDatabaseImported !== false
|
||||
|| descriptor.trust?.legacyCredentialsImported !== false
|
||||
|| descriptor.identity?.twoFactorAuthentication
|
||||
!== "not-configured-by-transition"
|
||||
) {
|
||||
throw new Error("gitea_salvage_descriptor_contract_mismatch");
|
||||
}
|
||||
if (
|
||||
disposition.schemaVersion !== "nodedc.gitea.incident-disposition.v1"
|
||||
|| disposition.incidentId !== "gitea-20260814"
|
||||
|| disposition.activation?.allowedOperation !== "canonical-plan-only"
|
||||
|| disposition.activation?.applyFrozen !== true
|
||||
|| disposition.activation?.freezeBoundary
|
||||
!== "before-candidate-root-creation"
|
||||
|| disposition.referencePolicy?.forensicScope?.allDiscoveredRefs !== 105
|
||||
|| disposition.referencePolicy?.exactDecisions?.length !== 105
|
||||
|| disposition.referencePolicy?.liveRestore?.totalRefs !== 93
|
||||
|| disposition.referencePolicy?.archiveOnly?.totalRefs !== 12
|
||||
) {
|
||||
throw new Error("gitea_salvage_disposition_contract_mismatch");
|
||||
}
|
||||
if (
|
||||
closureDisposition.schemaVersion
|
||||
!== "nodedc.gitea.incident-closure-disposition.v1"
|
||||
|| closureDisposition.incidentId !== "gitea-20260814"
|
||||
|| closureDisposition.activation?.allowedOperation !== "canonical-plan-only"
|
||||
|| closureDisposition.activation?.applyFrozen !== true
|
||||
|| closureDisposition.closureReport?.expectedSha256 !== null
|
||||
|| closureDisposition.predecessor?.dispositionSha256 !== dispositionSha256
|
||||
|| closureDisposition.remainingBlockers
|
||||
?.includes("closure-report-review-pin-pending") !== true
|
||||
) {
|
||||
throw new Error("gitea_salvage_closure_disposition_contract_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'xb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.gitea.incident-salvage.v1",
|
||||
"action": "clean-state-salvage",
|
||||
"component": "gitea",
|
||||
"incidentId": "gitea-20260814",
|
||||
"installRoot": "/volume1/docker/nodedc-gitea",
|
||||
"compose": {
|
||||
"file": "docker-compose.gitea.yml",
|
||||
"project": "nodedc-gitea",
|
||||
"service": "gitea",
|
||||
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
|
||||
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"platform": "linux/amd64",
|
||||
"pullPolicy": "never",
|
||||
"entrypoint": "/usr/local/bin/gitea",
|
||||
"runAs": "1000:1000",
|
||||
"transport": "unix:/run/gitea/gitea.sock",
|
||||
"networkMode": "none",
|
||||
"ssh": "disabled-no-published-port",
|
||||
"lfs": "unsupported-hard-stop",
|
||||
"database": "new-sqlite-1.27.2-only",
|
||||
"logging": "bounded-json-file-10m-x3",
|
||||
"stopGracePeriod": "30s",
|
||||
"candidateRestartPolicy": "no",
|
||||
"acceptedRestartPolicy": "unless-stopped"
|
||||
},
|
||||
"decision": {
|
||||
"schema": "nodedc.gitea.incident-decision/v2",
|
||||
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
|
||||
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
|
||||
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
|
||||
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
|
||||
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
|
||||
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
|
||||
"users": {
|
||||
"active": 2,
|
||||
"locked": 8,
|
||||
"delete": 962
|
||||
},
|
||||
"repositories": {
|
||||
"keep": 45,
|
||||
"delete": 2013,
|
||||
"dctouchPrivate": 32,
|
||||
"silverPublic": 13
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
|
||||
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
|
||||
"readOnlyRequired": true,
|
||||
"database": "gitea/gitea/gitea.db",
|
||||
"databaseBytes": 182681600,
|
||||
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
|
||||
"repositoriesRoot": "gitea/git/repositories"
|
||||
},
|
||||
"trust": {
|
||||
"legacyRootMounted": false,
|
||||
"legacyDatabaseImported": false,
|
||||
"legacyConfigImported": false,
|
||||
"legacyCredentialsImported": false,
|
||||
"artifactSecrets": "forbidden",
|
||||
"runtimeSecrets": "runner-generated",
|
||||
"repositorySelection": "exact-v2-rows-only",
|
||||
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
|
||||
"forbiddenRepositoryMaterial": [
|
||||
"hooks",
|
||||
"config",
|
||||
"alternates",
|
||||
"http-alternates",
|
||||
"shallow",
|
||||
"replace",
|
||||
"grafts",
|
||||
"worktrees",
|
||||
"commondir",
|
||||
"lfs"
|
||||
],
|
||||
"unsupportedDatabaseState": "zero-material-rows-required-before-apply"
|
||||
},
|
||||
"identity": {
|
||||
"preserveNumericUserIds": false,
|
||||
"preserveNumericRepositoryIds": false,
|
||||
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
|
||||
"emails": "new-local-noreply-only",
|
||||
"passwordHashes": "never-imported",
|
||||
"activeBootstrapCredentials": "root-only-runner-state-require-change",
|
||||
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
|
||||
"twoFactorAuthentication": "not-configured-by-transition"
|
||||
},
|
||||
"externalPrerequisites": {
|
||||
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
|
||||
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
|
||||
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
|
||||
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
|
||||
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
|
||||
"mustPassBeforeApply": true
|
||||
},
|
||||
"acceptance": {
|
||||
"users": "exact-10-row-by-row",
|
||||
"repositories": "exact-45-row-by-row-and-visibility",
|
||||
"credentialsSessionsKeysWebhooksActions": "zero",
|
||||
"git": "fsck-all-and-public-smart-http-smoke",
|
||||
"registration": "closed",
|
||||
"ssh": "disabled",
|
||||
"network": "none-no-ports",
|
||||
"publicHttps": "required-before-ledger-success"
|
||||
},
|
||||
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.gitea.incident-salvage.v2",
|
||||
"action": "clean-state-salvage",
|
||||
"component": "gitea",
|
||||
"incidentId": "gitea-20260814",
|
||||
"installRoot": "/volume1/docker/nodedc-gitea",
|
||||
"compose": {
|
||||
"file": "docker-compose.gitea.yml",
|
||||
"project": "nodedc-gitea",
|
||||
"service": "gitea",
|
||||
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
|
||||
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"platform": "linux/amd64",
|
||||
"pullPolicy": "never",
|
||||
"entrypoint": "/usr/local/bin/gitea",
|
||||
"runAs": "1000:1000",
|
||||
"transport": "unix:/run/gitea/gitea.sock",
|
||||
"networkMode": "none",
|
||||
"ssh": "disabled-no-published-port",
|
||||
"lfs": "disabled-pending-reachable-pointer-physical-sha-verifier",
|
||||
"database": "new-sqlite-1.27.2-only",
|
||||
"logging": "bounded-json-file-10m-x3",
|
||||
"stopGracePeriod": "30s",
|
||||
"candidateRestartPolicy": "no",
|
||||
"acceptedRestartPolicy": "unless-stopped"
|
||||
},
|
||||
"decision": {
|
||||
"schema": "nodedc.gitea.incident-decision/v2",
|
||||
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
|
||||
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
|
||||
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
|
||||
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
|
||||
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
|
||||
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
|
||||
"users": {
|
||||
"active": 2,
|
||||
"locked": 8,
|
||||
"delete": 962
|
||||
},
|
||||
"repositories": {
|
||||
"keep": 45,
|
||||
"delete": 2013,
|
||||
"dctouchPrivate": 32,
|
||||
"silverPublic": 13
|
||||
}
|
||||
},
|
||||
"disposition": {
|
||||
"schema": "nodedc.gitea.incident-disposition.v1",
|
||||
"file": "deployment/gitea-incident-salvage/confirmed-disposition-v1.json",
|
||||
"sha256": "0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"
|
||||
},
|
||||
"snapshot": {
|
||||
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
|
||||
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
|
||||
"readOnlyRequired": true,
|
||||
"database": "gitea/gitea/gitea.db",
|
||||
"databaseBytes": 182681600,
|
||||
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
|
||||
"repositoriesRoot": "gitea/git/repositories"
|
||||
},
|
||||
"trust": {
|
||||
"legacyRootMounted": false,
|
||||
"legacyDatabaseImported": false,
|
||||
"legacyConfigImported": false,
|
||||
"legacyCredentialsImported": false,
|
||||
"artifactSecrets": "forbidden",
|
||||
"runtimeSecrets": "runner-generated",
|
||||
"repositorySelection": "exact-v2-rows-only",
|
||||
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
|
||||
"forbiddenRepositoryMaterial": [
|
||||
"hooks",
|
||||
"config",
|
||||
"alternates",
|
||||
"http-alternates",
|
||||
"shallow",
|
||||
"replace",
|
||||
"grafts",
|
||||
"worktrees",
|
||||
"commondir",
|
||||
"lfs"
|
||||
],
|
||||
"unsupportedDatabaseState": "confirmed-disposition-bound-no-legacy-row-import-verifiers-pending"
|
||||
},
|
||||
"identity": {
|
||||
"preserveNumericUserIds": false,
|
||||
"preserveNumericRepositoryIds": false,
|
||||
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
|
||||
"emails": "new-local-noreply-only",
|
||||
"passwordHashes": "never-imported",
|
||||
"activeBootstrapCredentials": "root-only-runner-state-require-change",
|
||||
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
|
||||
"twoFactorAuthentication": "not-configured-by-transition"
|
||||
},
|
||||
"externalPrerequisites": {
|
||||
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
|
||||
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
|
||||
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
|
||||
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
|
||||
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
|
||||
"mustPassBeforeApply": true
|
||||
},
|
||||
"acceptance": {
|
||||
"users": "exact-10-row-by-row",
|
||||
"repositories": "exact-45-row-by-row-and-visibility",
|
||||
"credentialsSessionsKeysWebhooksActions": "zero",
|
||||
"git": "fsck-all-and-public-smart-http-smoke",
|
||||
"registration": "closed",
|
||||
"ssh": "disabled",
|
||||
"network": "none-no-ports",
|
||||
"publicHttps": "required-before-ledger-success"
|
||||
},
|
||||
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.gitea.incident-salvage.v3",
|
||||
"action": "clean-state-salvage",
|
||||
"component": "gitea",
|
||||
"incidentId": "gitea-20260814",
|
||||
"installRoot": "/volume1/docker/nodedc-gitea",
|
||||
"compose": {
|
||||
"file": "docker-compose.gitea.yml",
|
||||
"project": "nodedc-gitea",
|
||||
"service": "gitea",
|
||||
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
|
||||
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
|
||||
"platform": "linux/amd64",
|
||||
"pullPolicy": "never",
|
||||
"entrypoint": "/usr/local/bin/gitea",
|
||||
"runAs": "1000:1000",
|
||||
"transport": "unix:/run/gitea/gitea.sock",
|
||||
"networkMode": "none",
|
||||
"ssh": "disabled-no-published-port",
|
||||
"lfs": "disabled-pending-reachable-pointer-physical-sha-verifier",
|
||||
"database": "new-sqlite-1.27.2-only",
|
||||
"logging": "bounded-json-file-10m-x3",
|
||||
"stopGracePeriod": "30s",
|
||||
"candidateRestartPolicy": "no",
|
||||
"acceptedRestartPolicy": "unless-stopped"
|
||||
},
|
||||
"decision": {
|
||||
"schema": "nodedc.gitea.incident-decision/v2",
|
||||
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
|
||||
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
|
||||
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
|
||||
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
|
||||
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
|
||||
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
|
||||
"users": {
|
||||
"active": 2,
|
||||
"locked": 8,
|
||||
"delete": 962
|
||||
},
|
||||
"repositories": {
|
||||
"keep": 45,
|
||||
"delete": 2013,
|
||||
"dctouchPrivate": 32,
|
||||
"silverPublic": 13
|
||||
}
|
||||
},
|
||||
"disposition": {
|
||||
"schema": "nodedc.gitea.incident-disposition.v1",
|
||||
"file": "deployment/gitea-incident-salvage/confirmed-disposition-v1.json",
|
||||
"sha256": "0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"
|
||||
},
|
||||
"snapshot": {
|
||||
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
|
||||
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
|
||||
"readOnlyRequired": true,
|
||||
"database": "gitea/gitea/gitea.db",
|
||||
"databaseBytes": 182681600,
|
||||
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
|
||||
"repositoriesRoot": "gitea/git/repositories"
|
||||
},
|
||||
"trust": {
|
||||
"legacyRootMounted": false,
|
||||
"legacyDatabaseImported": false,
|
||||
"legacyConfigImported": false,
|
||||
"legacyCredentialsImported": false,
|
||||
"artifactSecrets": "forbidden",
|
||||
"runtimeSecrets": "runner-generated",
|
||||
"repositorySelection": "exact-v2-rows-only",
|
||||
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
|
||||
"forbiddenRepositoryMaterial": [
|
||||
"hooks",
|
||||
"config",
|
||||
"alternates",
|
||||
"http-alternates",
|
||||
"shallow",
|
||||
"replace",
|
||||
"grafts",
|
||||
"worktrees",
|
||||
"commondir",
|
||||
"lfs"
|
||||
],
|
||||
"unsupportedDatabaseState": "closure-disposition-bound-plan-report-review-and-verifiers-pending"
|
||||
},
|
||||
"identity": {
|
||||
"preserveNumericUserIds": false,
|
||||
"preserveNumericRepositoryIds": false,
|
||||
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
|
||||
"emails": "new-local-noreply-only",
|
||||
"passwordHashes": "never-imported",
|
||||
"activeBootstrapCredentials": "root-only-runner-state-require-change",
|
||||
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
|
||||
"twoFactorAuthentication": "not-configured-by-transition"
|
||||
},
|
||||
"externalPrerequisites": {
|
||||
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
|
||||
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
|
||||
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
|
||||
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
|
||||
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
|
||||
"mustPassBeforeApply": true
|
||||
},
|
||||
"acceptance": {
|
||||
"users": "exact-10-row-by-row",
|
||||
"repositories": "exact-45-row-by-row-and-visibility",
|
||||
"credentialsSessionsKeysWebhooksActions": "zero",
|
||||
"git": "fsck-all-and-public-smart-http-smoke",
|
||||
"registration": "closed",
|
||||
"ssh": "disabled",
|
||||
"network": "none-no-ports",
|
||||
"publicHttps": "required-before-ledger-success"
|
||||
},
|
||||
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy",
|
||||
"closureDisposition": {
|
||||
"file": "deployment/gitea-incident-salvage/confirmed-closure-disposition-v1.json",
|
||||
"predecessorArtifactSha256": "d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672",
|
||||
"schema": "nodedc.gitea.incident-closure-disposition.v1",
|
||||
"sha256": "7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"activation":{"allowedOperation":"canonical-plan-only","applyFrozen":true,"freezeBoundary":"before-candidate-root-creation"},"authority":{"policyScope":"access-collaboration-issue-pr-attachment-release-label-project-unit-package-action-closure","source":"owner-instruction-in-current-incident-thread","state":"confirmed-policy-evidence-review-pending"},"closureReport":{"expectedBytes":null,"expectedSha256":null,"reviewState":"canonical-plan-output-unreviewed","schema":"nodedc.gitea.salvage-closure-inventory/v1"},"incidentId":"gitea-20260814","policies":{"accessCollaboration":{"accessCache":"DROP_RESET_RECOMPUTE","actorEvidence":"old-user-id-mode-and-kept-deleted-class-only","collaboration":"RECREATE_ONLY_KEPT_ACTORS_AFTER_REVIEWED_OLD_TO_NEW_ID_MAP","deletedActors":962,"keptActors":10,"legacyRowsImported":false,"sensitiveUserPayloadExported":false},"attachments":{"databaseManifest":"ID_UUID_RELATIONS_DECLARED_SIZE_ONLY","filenameOrContentExported":false,"legacyRowsImported":false,"physicalDisposition":"VERIFY_BYTES_THEN_SANITIZED_IMMUTABLE_ARCHIVE_ONLY","physicalPresenceClaimed":false},"issuesPullRequestsMetadata":{"archive":"SANITIZED_IMMUTABLE_ARCHIVE_ONLY","legacyRowsImported":false,"payloadExportedInPlan":false,"planEvidence":"PER_REPOSITORY_COUNTS_ACTOR_CLASSES_TEXT_BYTE_LENGTHS_ONLY","subrelationClosure":{"commentHistoryMerger":"SCHEMA_BOUND_EXACT_RELATION_COUNTS_AND_CLASSES","externalAuthors":"PRESENCE_AND_NAME_BYTE_LENGTHS_ONLY_NO_LOCAL_USER_MAPPING","legacyRowsImported":false,"teamRelations":"EXACT_ROW_TEAM_ORG_IDS_SEALED_HOLD_AND_BLOCK_IF_PRESENT"},"tables":["comment","issue","issue_assignees","issue_content_history","issue_dependency","issue_label","issue_user","issue_watch","notification","project","project_board","project_issue","pull_auto_merge","pull_request","reaction","review","review_state","stopwatch","tracked_time"]},"packagesActions":{"actionsTarget":"DISABLED","legacyPayloadSecretTokenLogImported":false,"packageTarget":"DISABLED","physicalDisposition":"DROP_ONLY_AFTER_SCHEMA_RELATION_AND_PHYSICAL_CLOSURE_VERIFIER","planEvidence":"RELATIONSHIP_COUNTS_AND_SAFE_DECLARED_SIZES_ONLY"},"releasesLabelsProjects":{"archive":"SANITIZED_IMMUTABLE_ARCHIVE_ONLY","legacyRowsImported":false,"payloadExportedInPlan":false},"repositoryState":{"cachedAccessCountersStarsWatches":"DROP_RESET_RECOMPUTE","legacyHooksWebhooksKeysTokensSessionsCredentialsSecrets":"IMPORT_ZERO","topics":"VERIFIED_SEMANTIC_EMPTY_REBUILD_ZERO"},"units":{"actionsType10":"DISABLED_ZERO_TARGET_ROWS","disabledTypes":[6,7,9,10],"enabledCleanTypes":[1,2,3,4,5,8],"legacyConfigImported":false,"legacyRowsImported":false,"packagesType9":"DISABLED_ZERO_TARGET_ROWS"}},"predecessor":{"artifactSha256":"d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672","dispositionFile":"deployment/gitea-incident-salvage/confirmed-disposition-v1.json","dispositionSha256":"0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"},"remainingBlockers":["attachment-physical-verifier-pending","candidate-root-activation-hard-frozen","closure-report-review-pin-pending","collaboration-kept-user-mapping-verifier-pending","forensic-ref-archive-verifier-pending","issue-pr-metadata-sanitized-archive-verifier-pending","lfs-reachable-pointer-physical-verifier-pending","package-action-physical-closure-verifier-pending","reference-manifest-fsck-reachability-verifier-pending","repository-object-reconstruction-verifier-pending","target-unit-policy-acceptance-pending","unsupported-schema-catalog-verifier-pending"],"schemaVersion":"nodedc.gitea.incident-closure-disposition.v1","scope":{"deletedRepositories":2013,"deletedUsers":962,"keptRepositories":45,"keptUsers":10},"sourceEvidence":{"databaseSha256":"8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052","identityDecisionManifestSha256":"dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a","referenceManifestSha256":"9cddaf0e4d4cf22dd264a6ae589ccc50d29e07f85c55e9d34b14627cecb8a311","semanticTopicsSha256":"df6e3612186234bfcf3c172ef4e0fff933baaa691a510f9780ebf9e21c8d4d05","snapshotUuid":"f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f","unsupportedRepositoryReportSha256":"4b2cecf88c62fc5c4a43419885e88a01c9f9aac03133afb19dae0a7caef106ac","unsupportedSchemaCatalogSha256":"b5e3b6776926c4f1627fafd882362ed0ef986bfc86fc6ac6507a43976531b6db"}}
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
||||
name: nodedc-gitea
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c
|
||||
platform: linux/amd64
|
||||
pull_policy: never
|
||||
network_mode: none
|
||||
user: "1000:1000"
|
||||
entrypoint:
|
||||
- /usr/local/bin/gitea
|
||||
command:
|
||||
- web
|
||||
- --config
|
||||
- /etc/gitea/app.ini
|
||||
restart: "no"
|
||||
stop_grace_period: 30s
|
||||
init: true
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 512
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
USER: git
|
||||
HOME: /data/gitea
|
||||
GITEA_WORK_DIR: /data/gitea
|
||||
GITEA_CUSTOM: /data/gitea
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/config
|
||||
target: /etc/gitea
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/socket
|
||||
target: /run/gitea
|
||||
bind:
|
||||
create_host_path: false
|
||||
secrets:
|
||||
- gitea_secret_key
|
||||
- gitea_internal_token
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=256m
|
||||
|
||||
secrets:
|
||||
gitea_secret_key:
|
||||
file: /volume1/docker/nodedc-gitea/secrets/secret-key
|
||||
gitea_internal_token:
|
||||
file: /volume1/docker/nodedc-gitea/secrets/internal-token
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.gitea.fresh-install.v1",
|
||||
"action": "fresh-install",
|
||||
"component": "gitea",
|
||||
"installRoot": "/volume1/docker/nodedc-gitea",
|
||||
"compose": {
|
||||
"file": "docker-compose.gitea.yml",
|
||||
"project": "nodedc-gitea",
|
||||
"service": "gitea",
|
||||
"sha256": "25868a40996c405543b4627d06499b68f43556e607d839e969a400b0bc0ddadb"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148",
|
||||
"platform": "linux/amd64",
|
||||
"pullPolicy": "never",
|
||||
"minimumComposeVersion": "2.20.1",
|
||||
"transport": "unix:/run/gitea/gitea.sock",
|
||||
"socketBind": "/volume1/docker/nodedc-gitea/socket:/run/gitea",
|
||||
"ssh": "disabled-no-published-port",
|
||||
"database": "fresh-sqlite-only",
|
||||
"lfs": "disabled-pending-reviewed-restore-transition",
|
||||
"networkMode": "none",
|
||||
"logging": "bounded-json-file-10m-x3",
|
||||
"stopGracePeriod": "30s"
|
||||
},
|
||||
"trust": {
|
||||
"artifactSecrets": "forbidden",
|
||||
"runtimeSecrets": "runner-managed-file-mounts",
|
||||
"legacyRootAccess": "forbidden",
|
||||
"legacyDatabaseImport": "forbidden",
|
||||
"legacyRepositoryImport": "forbidden"
|
||||
},
|
||||
"reverseProxyPrerequisite": {
|
||||
"managedOutsideArtifact": true,
|
||||
"requiredDsmUpstream": "127.0.0.1:3000",
|
||||
"requiredNginxBridge": "/usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf",
|
||||
"requiredNginxBridgeSha256": "164f37a12a4f91e656cf20bd5b109978d16d723bdfde236653722aaf820780c9",
|
||||
"requiredUnixUpstream": "/volume1/docker/nodedc-gitea/socket/gitea.sock",
|
||||
"mustBeCompletedBeforeApply": true
|
||||
},
|
||||
"rollback": "stop-candidate-preserve-fresh-runtime-state-and-restore-source"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
name: nodedc-gitea
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148
|
||||
platform: linux/amd64
|
||||
pull_policy: never
|
||||
network_mode: none
|
||||
user: "1000:1000"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
init: true
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 512
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
GITEA_WORK_DIR: /var/lib/gitea
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
|
||||
GITEA__server__DOMAIN: git.dcserve.ru
|
||||
GITEA__server__ROOT_URL: https://git.dcserve.ru/
|
||||
GITEA__server__PROTOCOL: http+unix
|
||||
GITEA__server__HTTP_ADDR: /run/gitea/gitea.sock
|
||||
GITEA__server__UNIX_SOCKET_PERMISSION: "0666"
|
||||
GITEA__server__LOCAL_ROOT_URL: http://unix/
|
||||
GITEA__server__DISABLE_SSH: "true"
|
||||
GITEA__server__START_SSH_SERVER: "false"
|
||||
GITEA__server__SSH_CREATE_AUTHORIZED_KEYS_FILE: "false"
|
||||
GITEA__server__LFS_START_SERVER: "false"
|
||||
GITEA__server__LFS_ALLOW_PURE_SSH: "false"
|
||||
GITEA__server__OFFLINE_MODE: "true"
|
||||
GITEA__server__LANDING_PAGE: login
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__security__SECRET_KEY_URI: file:/run/secrets/gitea_secret_key
|
||||
GITEA__security__INTERNAL_TOKEN_URI: file:/run/secrets/gitea_internal_token
|
||||
GITEA__security__REVERSE_PROXY_LIMIT: "1"
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 127.0.0.0/8,::1/128
|
||||
GITEA__security__DISABLE_GIT_HOOKS: "true"
|
||||
GITEA__security__DISABLE_WEBHOOKS: "true"
|
||||
GITEA__security__IMPORT_LOCAL_PATHS: "false"
|
||||
GITEA__security__ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET: "true"
|
||||
GITEA__security__PASSWORD_HASH_ALGO: argon2
|
||||
GITEA__security__MIN_PASSWORD_LENGTH: "16"
|
||||
GITEA__security__PASSWORD_COMPLEXITY: lower,upper,digit,spec
|
||||
GITEA__security__TWO_FACTOR_AUTH: enforced
|
||||
GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true"
|
||||
GITEA__security__ALLOWED_HOST_LIST: loopback
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
|
||||
GITEA__service__SHOW_REGISTRATION_BUTTON: "false"
|
||||
GITEA__service__DEFAULT_KEEP_EMAIL_PRIVATE: "true"
|
||||
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "false"
|
||||
GITEA__service__DEFAULT_USER_IS_RESTRICTED: "true"
|
||||
GITEA__service__DEFAULT_USER_VISIBILITY: private
|
||||
GITEA__service__ALLOWED_USER_VISIBILITY_MODES: private
|
||||
GITEA__service__DEFAULT_ORG_VISIBILITY: private
|
||||
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION: "false"
|
||||
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION_API: "false"
|
||||
GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION: "false"
|
||||
GITEA__service__ENABLE_NOTIFY_MAIL: "false"
|
||||
GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false"
|
||||
GITEA__admin__DISABLE_REGULAR_ORG_CREATION: "true"
|
||||
GITEA__admin__USER_DISABLED_FEATURES: deletion,manage_ssh_keys,manage_gpg_keys,change_username
|
||||
GITEA__repository__FORCE_PRIVATE: "true"
|
||||
GITEA__repository__DEFAULT_PRIVATE: private
|
||||
GITEA__repository__USER_MAX_CREATION_LIMIT: "0"
|
||||
GITEA__repository__ORG_MAX_CREATION_LIMIT: "0"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_USER: "false"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "false"
|
||||
GITEA__repository__DISABLE_MIGRATIONS: "true"
|
||||
GITEA__repository__ALLOW_ADOPTION_OF_UNADOPTED_REPOSITORIES: "false"
|
||||
GITEA__repository__ALLOW_DELETION_OF_UNADOPTED_REPOSITORIES: "false"
|
||||
GITEA__repository__DISABLE_HTTP_GIT: "false"
|
||||
GITEA__repository.upload__ENABLED: "false"
|
||||
GITEA__attachment__ENABLED: "false"
|
||||
GITEA__actions__ENABLED: "false"
|
||||
GITEA__packages__ENABLED: "false"
|
||||
GITEA__oauth2__ENABLED: "false"
|
||||
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "false"
|
||||
GITEA__openid__ENABLE_OPENID_SIGNIN: "false"
|
||||
GITEA__openid__ENABLE_OPENID_SIGNUP: "false"
|
||||
GITEA__federation__ENABLED: "false"
|
||||
GITEA__mailer__ENABLED: "false"
|
||||
GITEA__session__COOKIE_SECURE: "true"
|
||||
GITEA__session__SAME_SITE: strict
|
||||
GITEA__api__ENABLE_SWAGGER: "false"
|
||||
GITEA__migrations__ALLOW_LOCALNETWORKS: "false"
|
||||
GITEA__migrations__SKIP_TLS_VERIFY: "false"
|
||||
GITEA__cors__ENABLED: "false"
|
||||
GITEA__metrics__ENABLED: "false"
|
||||
GITEA__cron.update_checker__ENABLED: "false"
|
||||
GITEA__log__MODE: console
|
||||
GITEA__log__LEVEL: Info
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/data
|
||||
target: /var/lib/gitea
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/config
|
||||
target: /etc/gitea
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-gitea/socket
|
||||
target: /run/gitea
|
||||
bind:
|
||||
create_host_path: false
|
||||
secrets:
|
||||
- gitea_secret_key
|
||||
- gitea_internal_token
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=256m
|
||||
|
||||
secrets:
|
||||
gitea_secret_key:
|
||||
file: /volume1/docker/nodedc-gitea/secrets/secret-key
|
||||
gitea_internal_token:
|
||||
file: /volume1/docker/nodedc-gitea/secrets/internal-token
|
||||
+9874
-54
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -27,6 +29,617 @@ RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
def test_manager_v7_pins_v6_and_live_edge_host_projection(self):
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_PATCH_ID,
|
||||
"device-manager-release-v6-20260822-035",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_PATCH_ID,
|
||||
"device-control-core-release-v2-20260822-036",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_COMPOSE_SHA256,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256,
|
||||
)
|
||||
boundaries = RUNNER.expected_device_plane_manager_release_v7_boundaries()
|
||||
self.assertEqual(
|
||||
boundaries["infrastructureHostProjection"],
|
||||
"edge-registration-live-channel-v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
boundaries["ontologyStatus"],
|
||||
"generic-host-domain-candidate-not-canonical",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES,
|
||||
),
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
def test_manager_v6_pins_034_and_canonical_favicon_boundary(self):
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID,
|
||||
"device-manager-release-v5-20260822-034",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256,
|
||||
)
|
||||
boundaries = RUNNER.expected_device_plane_manager_release_v6_boundaries()
|
||||
self.assertEqual(boundaries["faviconSet"], "nodedc-adaptive-v1")
|
||||
self.assertEqual(
|
||||
boundaries["overviewLayout"],
|
||||
"mission-core-landing-stage-v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES,
|
||||
),
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
def test_manager_v5_pins_033_and_mission_core_overview_layout(self):
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID,
|
||||
"device-manager-release-v4-20260822-033",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256,
|
||||
)
|
||||
boundaries = RUNNER.expected_device_plane_manager_release_v5_boundaries()
|
||||
self.assertEqual(
|
||||
boundaries["overviewLayout"],
|
||||
"mission-core-landing-stage-v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES,
|
||||
),
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
def test_manager_v4_pins_032_and_persistent_white_boundary(self):
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID,
|
||||
"device-manager-release-v3-20260822-032",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256,
|
||||
"e7dff0f5873ad4586bd55946d3db2bb86092a5e149e886d120adc041e056c256",
|
||||
)
|
||||
boundaries = RUNNER.expected_device_plane_manager_release_v4_boundaries()
|
||||
self.assertEqual(boundaries["defaultAccentHex"], "#f5f5f5")
|
||||
self.assertEqual(
|
||||
boundaries["presentationDataHostPath"],
|
||||
"/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||
)
|
||||
self.assertEqual(
|
||||
boundaries["presentationDataContainerPath"],
|
||||
"/var/lib/nodedc-device-manager",
|
||||
)
|
||||
|
||||
def test_manager_v3_targets_applied_control_core_recovery(self):
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID,
|
||||
"device-control-core-release-v2-20260821-030",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256,
|
||||
"8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.expected_device_plane_manager_release_v3_boundaries()[
|
||||
"controlCorePredecessor"
|
||||
],
|
||||
{
|
||||
"patchId": (
|
||||
"device-control-core-release-v2-20260821-030"
|
||||
),
|
||||
"artifactSha256": (
|
||||
"8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def test_control_core_predecessor_health_is_phase_scoped(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"preflight phase is invalid",
|
||||
):
|
||||
RUNNER.validate_device_plane_control_core_release_predecessor(
|
||||
Path("/not-used"),
|
||||
preflight_phase="unknown",
|
||||
)
|
||||
|
||||
plan_source = inspect.getsource(RUNNER.plan_artifact)
|
||||
self.assertIn(
|
||||
'validate_device_plane_control_core_release_predecessor(\n'
|
||||
' payload_dir,\n'
|
||||
' preflight_phase="plan",',
|
||||
plan_source,
|
||||
)
|
||||
apply_source = inspect.getsource(RUNNER.apply_artifact)
|
||||
self.assertIn(
|
||||
'validate_device_plane_control_core_release_predecessor(\n'
|
||||
' payload_dir,\n'
|
||||
' preflight_phase="apply",',
|
||||
apply_source,
|
||||
)
|
||||
|
||||
def test_control_core_preflight_health_excludes_selected_target(self):
|
||||
descriptor = {
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as healthcheck:
|
||||
services = (
|
||||
RUNNER.validate_device_plane_control_core_preserved_runtime_health(
|
||||
descriptor
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
services,
|
||||
(
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
healthcheck.call_args_list,
|
||||
[
|
||||
mock.call("device-plane", "device-manager"),
|
||||
mock.call("device-plane", "device-gateway"),
|
||||
mock.call("device-plane", "device-postgres"),
|
||||
mock.call("device-plane", "device-backhaul-target"),
|
||||
],
|
||||
)
|
||||
self.assertNotIn(
|
||||
mock.call("device-plane", "device-control-core"),
|
||||
healthcheck.call_args_list,
|
||||
)
|
||||
|
||||
def test_control_core_selected_predecessor_may_be_unhealthy(self):
|
||||
inventory = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [{
|
||||
"service": "device-control-core",
|
||||
"containerId": "a" * 64,
|
||||
"imageId": "sha256:" + "b" * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "unhealthy",
|
||||
"restartCount": 4,
|
||||
}],
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=inventory,
|
||||
):
|
||||
selected = (
|
||||
RUNNER.validate_device_plane_control_core_selected_predecessor_runtime()
|
||||
)
|
||||
self.assertEqual(selected["health"], "unhealthy")
|
||||
|
||||
def test_control_core_selected_predecessor_may_be_restarting(self):
|
||||
inventory = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [{
|
||||
"service": "device-control-core",
|
||||
"containerId": "a" * 64,
|
||||
"imageId": "sha256:" + "b" * 64,
|
||||
"status": "restarting",
|
||||
"running": True,
|
||||
"health": "starting",
|
||||
"restartCount": 5,
|
||||
}],
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=inventory,
|
||||
):
|
||||
selected = (
|
||||
RUNNER.validate_device_plane_control_core_selected_predecessor_runtime()
|
||||
)
|
||||
self.assertEqual(selected["status"], "restarting")
|
||||
self.assertEqual(selected["health"], "starting")
|
||||
|
||||
def test_control_core_rollback_accepts_restored_unhealthy_boundary(self):
|
||||
service_names = (
|
||||
"device-control-core",
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
)
|
||||
inventory = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
{
|
||||
"service": service,
|
||||
"containerId": chr(97 + index) * 64,
|
||||
"imageId": "sha256:" + str(index + 1) * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "unhealthy" if index == 0 else "healthy",
|
||||
"restartCount": index,
|
||||
}
|
||||
for index, service in enumerate(service_names)
|
||||
],
|
||||
}
|
||||
restored_id = "f" * 64
|
||||
restored = {
|
||||
"Id": restored_id,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Health": {"Status": "unhealthy"},
|
||||
},
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as preserved_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"compose_service_container_id",
|
||||
return_value=restored_id,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"inspect_device_plane_container",
|
||||
return_value=restored,
|
||||
),
|
||||
):
|
||||
accepted = (
|
||||
RUNNER.accept_device_plane_control_core_rollback_runtime(
|
||||
inventory
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(accepted["health"], "unhealthy")
|
||||
self.assertEqual(accepted["status"], "running")
|
||||
self.assertEqual(accepted["predecessorHealth"], "unhealthy")
|
||||
self.assertEqual(
|
||||
preserved_health.call_args_list,
|
||||
[
|
||||
mock.call("device-plane", "device-manager"),
|
||||
mock.call("device-plane", "device-gateway"),
|
||||
mock.call("device-plane", "device-postgres"),
|
||||
mock.call("device-plane", "device-backhaul-target"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_control_core_post_apply_health_includes_backhaul(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as healthcheck,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
),
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES,
|
||||
("device-control-core",),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
healthcheck.call_args_list,
|
||||
[
|
||||
mock.call("device-plane", "device-control-core"),
|
||||
mock.call("device-plane", "device-manager"),
|
||||
mock.call("device-plane", "device-gateway"),
|
||||
mock.call("device-plane", "device-postgres"),
|
||||
mock.call("device-plane", "device-backhaul-target"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_manager_v3_post_apply_uses_private_egress_core_boundary(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES,
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True,
|
||||
core_network_mode="private-egress",
|
||||
)
|
||||
|
||||
def test_manager_v4_post_apply_requires_persistent_data(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES,
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True,
|
||||
core_network_mode="private-egress",
|
||||
require_persistent_data=True,
|
||||
)
|
||||
|
||||
def test_manager_v5_post_apply_preserves_persistent_data_gate(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES,
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True,
|
||||
core_network_mode="private-egress",
|
||||
require_persistent_data=True,
|
||||
)
|
||||
|
||||
def test_manager_v6_post_apply_preserves_persistent_data_gate(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES,
|
||||
("device-manager",),
|
||||
)
|
||||
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True,
|
||||
core_network_mode="private-egress",
|
||||
require_persistent_data=True,
|
||||
)
|
||||
|
||||
def test_manager_v4_prepare_owns_new_parent_and_managed_data_directory(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-manager-v4-data-",
|
||||
) as directory:
|
||||
data_dir = Path(directory) / "data" / "device-manager"
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_MANAGER_DATA_DIR",
|
||||
data_dir,
|
||||
),
|
||||
mock.patch.object(RUNNER.os, "chown") as chown,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_persistent_data_metadata",
|
||||
return_value="uid-1000-gid-1000-mode-0750",
|
||||
) as validate,
|
||||
):
|
||||
result = RUNNER.ensure_device_plane_manager_persistent_data()
|
||||
|
||||
self.assertEqual(result, "uid-1000-gid-1000-mode-0750")
|
||||
self.assertEqual(
|
||||
chown.call_args_list,
|
||||
[
|
||||
mock.call(data_dir.parent, 0, 0),
|
||||
mock.call(data_dir, 1000, 1000),
|
||||
],
|
||||
)
|
||||
validate.assert_called_once_with()
|
||||
|
||||
def test_manager_v4_prepare_preserves_existing_safe_parent_metadata(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-manager-v4-parent-",
|
||||
) as directory:
|
||||
data_parent = Path(directory) / "data"
|
||||
data_parent.mkdir(mode=0o700)
|
||||
data_dir = data_parent / "device-manager"
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_MANAGER_DATA_DIR",
|
||||
data_dir,
|
||||
),
|
||||
mock.patch.object(RUNNER.os, "chown") as chown,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_persistent_data_metadata",
|
||||
return_value="uid-1000-gid-1000-mode-0750",
|
||||
),
|
||||
):
|
||||
RUNNER.ensure_device_plane_manager_persistent_data()
|
||||
|
||||
self.assertEqual(stat.S_IMODE(data_parent.stat().st_mode), 0o700)
|
||||
chown.assert_called_once_with(data_dir, 1000, 1000)
|
||||
|
||||
def test_manager_v4_prepare_rejects_symlink_parent_before_child_creation(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-manager-v4-symlink-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
outside = root / "outside"
|
||||
outside.mkdir()
|
||||
data_parent = root / "data"
|
||||
data_parent.symlink_to(outside, target_is_directory=True)
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_MANAGER_DATA_DIR",
|
||||
data_parent / "device-manager",
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"persistent data parent is unsafe",
|
||||
):
|
||||
RUNNER.ensure_device_plane_manager_persistent_data()
|
||||
self.assertFalse((outside / "device-manager").exists())
|
||||
|
||||
def test_manager_predecessor_health_is_phase_scoped(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"preflight phase is invalid",
|
||||
):
|
||||
RUNNER.validate_device_plane_manager_activation_predecessor(
|
||||
Path("/not-used"),
|
||||
preflight_phase="unknown",
|
||||
)
|
||||
|
||||
plan_source = inspect.getsource(RUNNER.plan_artifact)
|
||||
self.assertIn('preflight_phase="plan"', plan_source)
|
||||
apply_source = inspect.getsource(RUNNER.apply_artifact)
|
||||
self.assertIn('preflight_phase="apply"', apply_source)
|
||||
|
||||
def test_manager_v3_health_gate_checks_only_preserved_services(self):
|
||||
descriptor = {
|
||||
"schemaVersion": (
|
||||
"nodedc.device-plane.device-manager-release.v3"
|
||||
),
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as healthcheck:
|
||||
services = (
|
||||
RUNNER.validate_device_plane_manager_preserved_runtime_health(
|
||||
descriptor
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
services,
|
||||
("device-control-core", "device-postgres"),
|
||||
)
|
||||
self.assertEqual(
|
||||
healthcheck.call_args_list,
|
||||
[
|
||||
mock.call("device-plane", "device-control-core"),
|
||||
mock.call("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
self.assertNotIn(
|
||||
mock.call("device-plane", "device-manager"),
|
||||
healthcheck.call_args_list,
|
||||
)
|
||||
|
||||
def test_legacy_manager_health_gate_excludes_selected_services(self):
|
||||
descriptor = {
|
||||
"schemaVersion": (
|
||||
"nodedc.device-plane.device-manager-release.v2"
|
||||
),
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as healthcheck:
|
||||
services = (
|
||||
RUNNER.validate_device_plane_manager_preserved_runtime_health(
|
||||
descriptor
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(services, ("device-postgres",))
|
||||
healthcheck.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-postgres",
|
||||
)
|
||||
|
||||
def test_registry_has_exact_roots_project_and_stateless_services(self):
|
||||
component = RUNNER.COMPONENTS["device-plane"]
|
||||
root = Path("/volume1/docker/nodedc-device-plane")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user