Small Project updates

This commit is contained in:
Victor Wiebe 2026-09-14 11:42:12 -04:00
parent f6237060d1
commit 7b994d6db0
17 changed files with 1024 additions and 34 deletions

View File

@ -386,3 +386,23 @@ Date: 2026-07-20
- Anonymous source/image/attachment retrieval, checksum and OCI identity verification, and the published image's backup/restore/recreation suite all passed. The compatible Social Scheduler 0.5.0 kit is attached to the release.
- SLICE11.md records the full receipt and digests. The original source folder was aligned to the release commit after byte-for-byte and empty-index checks; working files were preserved.
- SLICE12 and SLICE13 remain planned, not started. The customer VM still runs its prior combined image.
### 2026-09-13 SLICE12 — Fresh setup and matched recovery
- User authorized SLICE12. Updated sibling skeletonworks-scripts/setup-conductor.sh to the verified v1.1.0 digest, persistent installed-app storage, safe dry-run, resumable preparation, existing-installation refusal, correct customer FQDN handling and validated Caddy integration.
- Updated the matched backup/restore wrappers for installed app assets and pre-mutation archive verification. Bootstrap itself needs no change: it already deploys sibling shell scripts, and no host Node.js is used.
- Added docs/SKELETONWORKS_INSTALL.md, aligned install/handover documentation and added disposable Ubuntu/nested-Docker/browser acceptance. Full acceptance passed, including first-run HTTPS login, Projects, empty/populated Publishing, scheduled-user backup, app-file recovery and restart persistence. Static/docs/governance/visual checks pass.
- SLICE12.md records evidence and resolved test-harness issues. This was not an actual customer-VM deployment or public certificate issuance.
- All changes are local and uncommitted/unpushed. Preserve pre-existing scripts-repository changes to uninstall-wordpress.sh, password-reset-conductor.sh and removed mshield sidecars.
- SLICE13 remains planned and requires the user's next instruction. Do not use fresh setup to migrate the current combined Social Scheduler image.
### 2026-09-14 SLICE13 — Reusable upgrade and image/data recovery
- User authorized SLICE13. Added sibling skeletonworks-scripts/update-conductor.sh, shared upgrade/backup/restore maintenance locking and setup pointers, a complete VM upgrade/recovery guide, an app-owned legacy migration recipe and a local transfer bundle.
- The upgrader preserves the original volume and image, tests a consistent copied volume in a container with no network access, migrates only already registered legacy apps, verifies existing table contents/encrypted data/assets/routes, promotes the copied data and saves an interruption journal. Same-image retries are no-ops. Explicit rollback uses verified archived image/data/configuration and works with the current service stopped.
- Final acceptance passes against the original Social Scheduler 0.4.0 combined-image source and the published standalone Conductor v1.0.0 image. It covers SIGKILL/rerun, health failure, corruption refusal, same credentials/project/publication, Social Scheduler API/account/queue rendering, app titles/icons, an independent second app, routine backup/restore locking, archived image/data recovery after original removal, and no app seeding on a standalone installation.
- Node-RED/PostgreSQL/customer VMs were not changed. The backend check uses a read-only authenticated API fixture; no real social post was performed. SLICE13.md and test-results/slice13 record evidence and limits.
- The local release/conductor-upgrade-1.1.0.tar.gz kit is ready for VM administrator testing. It uses the existing published v1.1.0 image and unchanged published Social Scheduler 0.5.0 browser assets. No installer commit/push or customer deployment occurred.
- Preserve all earlier SLICE12 and unrelated scripts-repository changes. Follow docs/CONDUCTOR_UPGRADE.md for customer dry-run, update, retry and rollback; do not use fresh setup to overwrite an existing installation.

View File

@ -100,6 +100,8 @@ Start with the document for your role. Most people need only one guide; cross-li
| [docs/INSTALL.md](docs/INSTALL.md) | Public source/container and reverse-proxy installation |
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Production configuration, backup, restore, upgrade, and rollback |
| [docs/RELEASE_NOTES_v1.1.0.md](docs/RELEASE_NOTES_v1.1.0.md) | v1.1.0 capabilities, compatibility, limitations, and artifact identities |
| [docs/SKELETONWORKS_INSTALL.md](docs/SKELETONWORKS_INSTALL.md) | Automated Skeleton Works fresh installation, persistence and customer handover |
| [docs/CONDUCTOR_UPGRADE.md](docs/CONDUCTOR_UPGRADE.md) | Existing-VM upgrade, legacy app migration, retry and image/data recovery |
| [docs/TUTORIAL.md](docs/TUTORIAL.md) | Guided first project covering authoring, REST actions, bindings, Preview, saving, and publishing |
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Project authoring and published-application use |
| [docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md) | Users, sessions, credentials, publishing, and execution history |
@ -119,4 +121,4 @@ Start with the document for your role. Most people need only one guide; cross-li
## Independent applications in v1.1.0
Conductor hosts trusted application packages from its persistent data volume. Installers add browser files and declared API routes without rebuilding the core image. Social Scheduler 0.5.0 uses this interface. See [Application packages](docs/APP_PACKAGES.md), [release notes](docs/RELEASE_NOTES_v1.1.0.md), and the [SLICE11 log](SLICE11.md). Fresh setup and the existing-VM upgrader remain in SLICE12 and SLICE13.
Conductor hosts trusted application packages from its persistent data volume. Installers add browser files and declared API routes without rebuilding the core image. Social Scheduler 0.5.0 uses this interface. See [Application packages](docs/APP_PACKAGES.md), [release notes](docs/RELEASE_NOTES_v1.1.0.md), and the [SLICE11 log](SLICE11.md). [SLICE12](SLICE12.md) supplies fresh setup and matched recovery scripts; [SLICE13](SLICE13.md) supplies the reusable existing-VM updater and a [complete upgrade/recovery guide](docs/CONDUCTOR_UPGRADE.md).

View File

@ -356,5 +356,5 @@ The original slices above remain historical. The 2026-09-13 follow-up uses these
- [SLICE10: browser titles and application icons](SLICE10.md) — implemented and released in v1.1.0; customer-VM deployment belongs to SLICE13.
- [SLICE11: independent release and installed-app packages](SLICE11.md) — complete; v1.1.0 published and publicly verified.
- [SLICE12: fresh-install setup script](SLICE12.md) — planned.
- [SLICE13: idempotent upgrades](SLICE13.md) — planned.
- [SLICE12: fresh-install setup script](SLICE12.md) — complete; local scripts and handover guide verified in disposable Ubuntu/Docker.
- [SLICE13: idempotent upgrades](SLICE13.md) — complete locally; reusable updater, legacy-app migration, image/data recovery kit and isolated acceptance verified. Customer-VM acceptance and installer publication remain pending.

View File

@ -1,6 +1,6 @@
# SLICE12 — Fresh-install setup-conductor.sh update
Status: Planned
Status: Complete — implemented and verified in disposable Ubuntu/Docker
## Purpose
@ -14,13 +14,13 @@ This slice is planned; do not start it as part of SLICE10. Update the checklist
## Scope
- [ ] Update /home/vwiebe/projects/skeletonworks-scripts/setup-conductor.sh to use the verified SLICE11 release.
- [ ] Create/mount the persistent storage expected by the generic installed-app host.
- [ ] Preserve customer URL discovery, Docker-based builds/runtime, persistent keys and existing first-run account setup.
- [ ] Deliver favicon/title behavior through the Conductor release; do not patch frontend source from the setup script.
- [ ] Keep Conductor mandatory in the SaaS offering while leaving Social Scheduler unregistered until its installer succeeds.
- [ ] Give clear guidance for existing installations so fresh setup is not confused with the SLICE13 upgrade process.
- [ ] Update installation and handover guides, including sudo for protected discovery/dry-run operations.
- [x] Update /home/vwiebe/projects/skeletonworks-scripts/setup-conductor.sh to use the verified SLICE11 release.
- [x] Create/mount the persistent storage expected by the generic installed-app host.
- [x] Preserve customer URL discovery, Docker-based builds/runtime, persistent keys and existing first-run account setup.
- [x] Deliver favicon/title behavior through the Conductor release; do not patch frontend source from the setup script.
- [x] Keep Conductor mandatory in the SaaS offering while leaving Social Scheduler unregistered until its installer succeeds.
- [x] Give clear guidance for existing installations so fresh setup is not confused with the SLICE13 upgrade process.
- [x] Update installation and handover guides, including sudo for protected discovery/dry-run operations.
## Acceptance checks
@ -46,4 +46,41 @@ The customer control panel and other SaaS backlog items remain outside these fou
## Current handoff
Await completion of the preceding slice and the instruction to proceed.
SLICE12 is complete. Updated scripts and guides are local working-tree changes. Proceed with SLICE13 only when requested; do not run fresh setup against the existing customer VM.
### 2026-09-13 — Implementation started
- User authorized SLICE12 after the publicly verified v1.1.0 release.
- Inspected bootstrap, Caddy setup, current setup and backup/restore wrappers. Bootstrap deploys all sibling shell scripts and installs Docker; no host Node installation is needed.
- Preserve the existing /data named volume, customer URL/result-file contract, persistent keys and browser first-run administration.
- Default to the verified v1.1.0 image digest. Add read-only dry-run and explicit existing-installation refusal. A preparation made by this new script can be resumed without replacing its keys.
- The old scheduled backup wrapper omits installed-app assets. Update the matched backup/restore wrappers to the existing application backup v2 contract so fresh installations have complete recovery coverage.
- Preserve pre-existing changes in skeletonworks-scripts (uninstall-wordpress, password-reset-conductor and removed mshield sidecars). The source snapshot is under /home/vwiebe/projects/.local/conductor-slice12-start.
- No live VM deployment or upgrade is part of this slice. Test in disposable infrastructure only.
### 2026-09-13 — Implementation complete
- setup-conductor.sh defaults to Conductor v1.1.0 at the verified OCI index digest sha256:ab5e7dcaed065a304660af31e53ffada290b22dc9176ffeb5817f2e7f07ac313. No new Conductor image, frontend patch or host Node installation is involved.
- Preserved the customer URL, named /data volume and setup-result fields used by Social Scheduler. Created /data/applications under that same persistent volume. There are no seeded apps.
- Added root/sudo guidance including dry-run, validated image/domain/resource inputs, correct base-domain/already-qualified/custom-FQDN resolution, capability/readiness checks and safe refusal of existing installations/data.
- --no-start writes a private resumable preparation. Repeated preparation and later activation preserve the exact keys and Compose files. A completed or changed installation is not overwritten by fresh setup.
- Added managed Caddy blocks, a protected configuration backup, candidate validation before replacement and graceful reload through stdin so a previously mounted file cannot supply stale bytes. Existing sites and unrelated cron entries are preserved.
- Updated the matched Skeleton Works backup/restore wrappers to use the release's app-file backup mechanism. Backups include verified assets referenced by the SQLite snapshot, private keys and configuration; restore accepts v1/v2 and validates member paths/types, checksum coverage, format and hostname before changing files.
- Added docs/SKELETONWORKS_INSTALL.md for the VM administrator and customer handover, including sudo discovery, three-script deployment, persistent storage, first-run administration, backup/recovery and the fresh-install/upgrade boundary. Updated README, docs/INSTALL.md and TESTING.md accordingly.
### 2026-09-13 — Verification and limits
- PASS: Bash syntax, ShellCheck and --help for setup/backup/restore.
- PASS: documentation links, governance and whitespace checks.
- PASS: actual scripts in disposable Ubuntu 24.04 with its own Docker engine and no Node.js installed. No host Docker socket, host directory or customer data was mounted into that test host.
- PASS: read-only dry-run, DNS/FQDN variants, invalid arguments, sudo guidance and unchanged legacy installation files.
- PASS: no-start/repeat/resume, private file ownership/modes, exact key preservation, anonymous pull of the pinned image, package host readiness, non-root/read-only runtime and persistent app volume.
- PASS: generated Caddy HTTPS route using local test certificates, preserved neighboring site and existing cron job, browser first-run administrator creation, Projects save, default favicon and appropriate setup/home/app titles.
- PASS: initially empty Publishing, independent example app installation/launch without changing the core image, scheduled-user backup, corrupt/symlink/traversal/missing-app backup rejection before configuration changes, recovery of deliberately removed app files, and account/project/app persistence after container recreation.
- PASS: visual review of fresh and populated Publishing screenshots under test-results/slice12; no browser errors.
- Initial attempts exposed test-environment issues: nested Docker needed its official cgroup initialization, the first-run title expectation needed the existing Set up title, and fixture files needed a persistent directory instead of Docker-copied /tmp. Corrected the harness and reran the complete acceptance successfully. These were not changes to the released Conductor frontend.
- The runner removed its disposable host and all nested containers/data. Evidence: test-results/slice12/result.json, browser-result.json, acceptance.log and screenshots.
- A real customer VM deployment, public DNS/certificate issuance, complete Social Scheduler/Node-RED/PostgreSQL deployment and existing-image migration were not performed in this slice. SLICE11 already covered the scheduler package contract; SLICE13 owns the current VM's upgrade.
- Changes remain uncommitted/unpushed in the local source folders. Pre-existing skeletonworks-scripts changes were preserved. No production service, user account, social credential, schedule or published release artifact was changed.

View File

@ -1,6 +1,6 @@
# SLICE13 — Idempotent Conductor upgrade process
Status: Planned
Status: Complete — implementation and local acceptance
## Purpose
@ -10,19 +10,19 @@ Provide one reusable upgrade process that preserves customer data, configuration
Read CODEX.md and applicable AGENTS.md instructions before work. Current source and tests are authoritative. Preserve unrelated working-tree changes. Use this file as both the slice specification and its continuing log.
This slice is planned; do not start it as part of SLICE10. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
This slice is separate from SLICE10; the user authorized implementation on 2026-09-14. Update the checklist and append dated log entries as work proceeds. Record decisions, changed files, checks and results, failures, unresolved issues and the next action. Do not erase earlier log entries or claim VM/release acceptance from local tests alone.
## Scope
- [ ] Choose a shared upgrade implementation exposed through an upgrade mode or update-conductor.sh; avoid accumulating one-off numbered patch scripts.
- [ ] Inspect current installation and target compatibility before changing live state; support a read-only dry run.
- [ ] Back up Conductor data, keys, configuration and installed-app state before mutation.
- [ ] Preserve users, passwords/keys, projects, publications, app packages, registry records and API connection settings.
- [ ] Apply supported migrations once and keep source-independent installed apps functional during core upgrades.
- [ ] Use locking and repeatable steps so interrupted or repeated runs do not duplicate apps, regenerate credentials or reset settings.
- [ ] Check health and representative application routes after upgrade.
- [ ] Provide and verify recovery to the previous working image/data combination.
- [ ] Document update, retry, compatibility-failure and recovery procedures.
- [x] Choose a shared upgrade implementation exposed through an upgrade mode or update-conductor.sh; avoid accumulating one-off numbered patch scripts.
- [x] Inspect current installation and target compatibility before changing live state; support a read-only dry run.
- [x] Back up Conductor data, keys, configuration and installed-app state before mutation.
- [x] Preserve users, passwords/keys, projects, publications, app packages, registry records and API connection settings.
- [x] Apply supported migrations once and keep source-independent installed apps functional during core upgrades.
- [x] Use locking and repeatable steps so interrupted or repeated runs do not duplicate apps, regenerate credentials or reset settings.
- [x] Check health and representative application routes after upgrade.
- [x] Provide and verify recovery to the previous working image/data combination.
- [x] Document update, retry, compatibility-failure and recovery procedures.
## Acceptance checks
@ -47,6 +47,28 @@ The customer control panel and other SaaS backlog items remain outside these fou
- Renamed the proposed four phases to SLICE10, SLICE11, SLICE12 and SLICE13, respectively.
- Implementation has not started.
### 2026-09-14 — Implementation and acceptance in progress
- User authorized SLICE13. Preserved the SLICE12 work and unrelated scripts-repository changes; starting files are backed up under the sibling .local/conductor-slice13-start directory.
- Implemented a reusable update-conductor.sh with protected dry-run, compatibility checks, maintenance locking, an interruption journal, consistent full-volume/image/configuration snapshots, offline candidate checks, promotion to a copied data volume, automatic recovery and explicit archived-image/data rollback.
- Added an app-owned legacy connection recipe in social-scheduler/integration/conductor-migration.json. The local upgrade bundle includes only the verified Social Scheduler 0.5.0 browser package; it migrates an already registered 0.4.0 app and does not seed an absent app.
- Added the upgrade/recovery guide and disposable Ubuntu/nested-Docker/browser tests. The old image fixture is rebuilt from the original Social Scheduler 0.4.0 archive.
- Initial browser test needed correction: Playwright's Node-side API client did not use Chromium's test-host DNS mapping. Authenticated fixture calls now run inside the browser, through the generated HTTPS route.
- Initial checks pass: legacy account/project/publication and Social Scheduler rendering, protected dry-run, corrupt-package/incompatible-image refusal, actual SIGKILL recovery and failed-candidate health recovery. Remaining acceptance is in progress.
- No customer VM, live social account, Node-RED flow or PostgreSQL database has been changed.
### 2026-09-14 — Final acceptance and local delivery
- Final isolated acceptance passed using the current kit: protected read-only dry-run; bad image/package refusal; actual SIGKILL and rerun recovery; failed-candidate health and automatic recovery; the original 0.4.0 combined frontend migrated to a persistent 0.5.0 package; unchanged existing database rows and decryptable secrets; same administrator password; preserved saved project and publication; existing account/queued-post view through the authenticated API; application title/default favicon; repeat no-op; and a second independent app surviving another core image.
- Routine backup/restore passed with the shared maintenance lock, including restore's nested safety backup. Corrupt upgrade archives were rejected before live changes. Explicit rollback passed after the old image and original volume were removed and with the active service stopped. The saved image was reloaded and its corresponding data restored; repeating the rollback was a no-op.
- The published standalone Conductor v1.0.0 image also upgraded to v1.1.0, with no Social Scheduler or other app falsely added. The same upgrade command then returned already-current.
- The backend API fixture's container identity/start time, account/queued-post state and zero-write counter were unchanged. Caddy stayed byte-identical. The isolated VM had no Node.js installed. This checks the Conductor/API boundary with a read-only fixture, not a real Bluesky post or an actual Node-RED/PostgreSQL migration.
- Browser screenshots were reviewed. Shell and embedded-Python syntax, documentation links (65 Markdown files), governance and whitespace checks passed. The initial test-browser DNS issue was resolved and the final run was clean.
- Main deliverables: sibling skeletonworks-scripts/update-conductor.sh; matching maintenance-lock changes in backup/restore scripts; setup's existing-installation pointers; docs/CONDUCTOR_UPGRADE.md; reproducible fixture, bundle and acceptance scripts; and the app-owned social-scheduler/integration/conductor-migration.json recipe.
- Local release/conductor-upgrade-1.1.0.tar.gz includes the tested updater, fresh setup, backup/restore scripts, upgrade guide, checked Social Scheduler browser assets and per-file checksums. The Conductor image and published Social Scheduler 0.5.0 archive were not changed.
- Evidence: test-results/slice13/result.json, browser JSON/logs and screenshots, plus test-results/slice13-final.log. Disposable test hosts and their nested data were removed. No customer VM or live social account was accessed.
- All SLICE12/SLICE13 installer and documentation changes remain local and uncommitted/unpushed. Existing unrelated working-tree edits were preserved.
## Current handoff
Await completion of the preceding slice and the instruction to proceed.
SLICE13 implementation and local acceptance are complete. Transfer the local upgrade bundle and checksum file to the customer VM, follow its INSTALL.md dry-run/update steps, and perform the read-only customer checks in the guide. Real customer-VM acceptance and publication of these installer changes have not been performed.

View File

@ -776,3 +776,35 @@ The production lifecycle suite now backs up and restores an installed example pa
CONDUCTOR_TEST_IMAGE=gitea.skeletonworks.online/vwiebe/conductor:v1.1.0 bash scripts/test-production.sh --skip-build
The legacy source-overlay test-social-identity.mjs remains historical compatibility evidence for SLICE10. Current package releases use test-installed-apps.mjs.
## SLICE12 — Skeleton Works fresh setup
Run shell syntax/static checks on setup-conductor.sh, backup-conductor.sh and restore-conductor.sh in the sibling skeletonworks-scripts repository.
The disposable acceptance environment is Ubuntu 24.04 with a separate Docker engine and no Node.js. Its privileged outer container is used only to enable nested Docker; it mounts no host socket, host directory or customer data. Containers, volumes, Caddy and cron changes remain inside it, and the runner deletes that outer container on completion.
docker build -t conductor:slice12-test-host scripts/fixtures/slice12
python3 scripts/test-setup-conductor.py
Run the test driver from the Conductor root with Node 24 and the existing Playwright dependencies available on the development workstation. Node is used by the browser test driver only; the simulated customer host has none. Screenshots and results go to test-results/slice12.
The checks cover protected dry-run, DNS/FQDN variants, invalid inputs, old-installation refusal, preparation/repeat/resume, private key modes and retention, the pinned public release, generated HTTPS routing, first-run browser setup, Projects, initially empty Publishing, independent app installation, scheduled backup as admin, unsafe backup refusal, app restoration and container recreation. Human/customer-VM acceptance is distinct; see docs/SKELETONWORKS_INSTALL.md for actions paired with their verification purpose.
## SLICE13 — Existing installation upgrade and recovery
Run these checks in the developer's Node.js 24 environment with Docker available:
```bash
python3 scripts/build-upgrade-test-fixtures.py
python3 scripts/build-upgrade-bundle.py
python3 scripts/test-update-conductor.py
```
The fixture builder uses the original sibling `social-scheduler/release/social-scheduler-0.4.0.tar.gz` archive and builds its combined image without changing source. It also prepares the disposable Ubuntu/Docker host from SLICE12. The tests require the published Conductor v1.0.0 and v1.1.0 images locally; pull those fixed tags first if they are absent.
The host has its own nested Docker daemon, no host Node.js, no host Docker socket and no customer data mounts. Only its test HTTPS port is exposed on loopback. The fixture's Node-RED API accepts read-only requests, requires the existing internal connection settings and records any attempted mutation; no social post is made.
Acceptance covers the real legacy browser app/account/project/publication, protected dry-run, bad image/package refusal, a real SIGKILL interruption followed by recovery, a failed-health candidate followed by recovery, legacy package migration, preserved table contents/encrypted secrets, repeat no-op, a second independent package surviving another core image, routine backup/restore locking, corrupt recovery archive refusal, archived image/data recovery after removing original image/volume, recovery with the active service stopped, rollback repeat, and a separate published v1.0.0 installation upgraded without seeding Social Scheduler.
Screenshots, browser results and the final report are written under `test-results/slice13/`. Failed tests remove their uniquely named outer host and its disposable nested engine. They never remove another host's containers or volumes. The local test evidence does not replace customer-VM acceptance; [the upgrade guide](docs/CONDUCTOR_UPGRADE.md) pairs each administrator check with its purpose.

165
docs/CONDUCTOR_UPGRADE.md Normal file
View File

@ -0,0 +1,165 @@
# Conductor upgrade and recovery
This guide is for the VM administrator. Use `update-conductor.sh` to upgrade an existing Conductor installation. Use `setup-conductor.sh` for a fresh installation.
The upgrade bundle is currently a **local deliverable**, not a newly published Conductor release. It installs the already published Conductor v1.1.0 image at its verified digest. It includes the matching backup/restore scripts and the published Social Scheduler 0.5.0 browser package needed to migrate the older combined installation.
## What happens
1. The script checks the current installation, target image, app compatibility and available disk space.
2. It saves the previous Conductor image and private configuration, then briefly stops Conductor to copy its complete data volume.
3. It starts an isolated test container using a new copy of that data. This container has no network access to Node-RED or the Internet. The script checks startup, saved-record preservation, encrypted secrets, installed assets and representative routes.
4. After those checks pass, it switches Conductor to the new image and copied data, checks the running service, updates its setup record, and installs the matching routine backup/restore scripts.
5. It retains the previous image archive, database, app files, configuration, keys and original volume for recovery.
Conductor is unavailable during the copy/check/switch portion. Image downloading and the image backup happen before that stop. Allow a maintenance window; duration depends on image download speed and the size of the data.
Node-RED is not restarted. Its social credentials, API tokens, enabled/paused setting, PostgreSQL schedules, media and posting activity are not changed. Caddy and the Conductor address stay as they are. No host Node.js installation is needed.
## Supported starting points
| Starting installation | Behavior |
|---|---|
| Standard Conductor v1.0.0 | Preserves accounts, passwords, projects, publications and encrypted secrets; enables the v1.1.0 app host. |
| Conductor with Social Scheduler 0.4.0 built into its image | Migrates that browser app into persistent package storage, using the existing internal Node-RED connection settings. |
| Conductor v1.1.0 with independent apps | Preserves the packages and encrypted connections. The same image and app state produce an already-current result. |
| An unknown legacy app, unsupported package/API/version or custom persistent mount | Stops for a compatibility review before changing the live installation. |
This version of the upgrader targets **Conductor v1.1.0 / application host API 1**. It does not promise compatibility with future Conductor versions that have not been tested. A subsequent release can update the same reusable upgrade process.
The standard installation has one Conductor service, port 8080, and a named volume mounted at `/data`. It retains its existing resource limits, network, restart policy, security settings and environment files. Compose is saved as valid JSON after the switch; Docker Compose supports that format. The physical data volume changes to the verified copy. Future tools should discover that volume from Docker or `setup-result.json` rather than assuming its old name.
## Upgrade the existing VM
Transfer **`conductor-upgrade-1.1.0.tar.gz` and its `.sha256` file** from the local release folder to the VM. The app files are inside this bundle; separate Node-RED `.tgz` packages are not needed for this Conductor upgrade.
Verify and extract it:
```bash
sha256sum -c conductor-upgrade-1.1.0.tar.gz.sha256
tar -xzf conductor-upgrade-1.1.0.tar.gz
cd conductor-upgrade
sha256sum -c SHA256SUMS
```
These checks confirm that the transferred kit and its contents are intact.
Use the full hostname already assigned by setup-conductor. For the current Skeleton Works instance:
```bash
sudo bash ./update-conductor.sh --fqdn conductor.skeletonworks.online --dry-run
```
The dry run reads protected setup records and the running installation. It does not pull an image, create a backup or lock, change keys, install an app, stop a service or write customer records. It lists legacy apps requiring migration and independent apps to preserve. Target image compatibility is checked during execution before Conductor is stopped.
Run the upgrade:
```bash
sudo bash ./update-conductor.sh --fqdn conductor.skeletonworks.online
```
Keep the **backup directory** printed in the successful result. Do not run the full Social Scheduler installer as an extra migration step. The app package and its connection migration are already included in this command.
The updater installs the matching `backup-conductor.sh` and `restore-conductor.sh` into `/opt/skeletonworks/scripts`. Existing cron entries continue to use those paths. The fresh setup script is also in the kit for provisioning other VMs.
## Check the result
| Action | What it proves |
|---|---|
| Sign in at the existing Conductor URL using the existing username/password. | The account, password and authentication configuration survived. |
| Open Projects and an existing saved project. | Saved customer work remains available. |
| Open Publishing and an existing published application. | Publication snapshots and access settings survived. |
| Open Social Scheduler from Included with Skeleton Works. | The app was migrated and registered once. Its title is Social Scheduler · Conductor and it inherits the default favicon. |
| Inspect Social connections and the existing post list. | The app can still reach its existing Node-RED connection and schedules. No new credentials should be required. |
| Rerun the same upgrade command. | An already-current result confirms that repetition does not reinstall apps, duplicate entries or regenerate keys. |
These are read-only acceptance checks. A new real social post is not needed to validate the Conductor upgrade.
## Interrupted or failed upgrades
An ordinary failure after the snapshot triggers automatic recovery using the untouched original data volume and previous image/configuration. Read the final message to confirm whether recovery succeeded.
After a power loss, forced interruption or an incomplete automatic recovery, rerun the same command. The journal causes the script to recover the previous installation **before** attempting another upgrade. It reports `recovered` when that recovery finishes; run the command once more when ready to retry.
Do not delete `.upgrade-state.json` or edit its phase to bypass recovery. Routine backup and restore refuse to run while an upgrade needs recovery. A maintenance lock prevents concurrent upgrade/backup/restore operations. Avoid changing Conductor settings or installing apps from another terminal during maintenance.
For a compatibility refusal, the live installation stays in place. Missing legacy package instructions mean the whole upgrade bundle must be present, or the relevant app package must be supplied with `--app-package /path/to/package`. Do not run fresh setup to work around that refusal.
For a disk-space failure, free space and retry. Keep recovery data until the new version has been accepted; Docker cleanup commands that remove unused images/volumes can remove the quick recovery copy.
## Explicit recovery to an earlier image and data
Use the exact backup directory printed by the successful upgrade, for example:
```bash
sudo bash ./update-conductor.sh \
--fqdn conductor.skeletonworks.online \
--rollback /opt/skeletonworks/conductor/conductor.skeletonworks.online/.upgrades/REPLACE_WITH_BACKUP_ID \
--dry-run
```
The directory name above is a placeholder. Copy the actual path from the earlier result.
Review the recovery plan, then run:
```bash
sudo bash ./update-conductor.sh \
--fqdn conductor.skeletonworks.online \
--rollback /opt/skeletonworks/conductor/conductor.skeletonworks.online/.upgrades/REPLACE_WITH_BACKUP_ID \
--accept-data-rewind
```
**Explicit rollback returns Conductor data to the backup time.** Conductor projects, publications, users or app changes made afterward are not in that restored copy. The flag acknowledges this. The script first snapshots the current installation so that those newer records remain available for a separate recovery.
Node-RED and PostgreSQL are not rolled back: a social post already sent stays sent, and current backend schedules and credentials remain current.
Recovery validates archive checksums and paths before stopping the current service. It can reload the saved old image even if its local tag/image and original volume were removed. It restores into a new volume, with the corresponding old configuration and maintenance scripts. This also works when the active Conductor service is stopped. Repeating a completed rollback returns `already-restored`.
An automatic recovery after a failed upgrade uses the untouched original volume. If that volume or image was manually removed during the incomplete attempt, retain all backups and inspect the reported recovery issue; do not bypass the journal.
## What to retain and protect
Upgrade snapshots are in:
```text
/opt/skeletonworks/conductor/FULL_HOSTNAME/.upgrades/BACKUP_ID/
```
Each completed snapshot contains:
- `image.tar`: the exact previous runtime image, including any old built-in app.
- `data.tar`: consistent SQLite data and every file in the stopped Conductor volume.
- `configuration/`: original setup records, environment, encryption/session keys and Compose.
- `maintenance/`: the previous backup/restore scripts.
- `original.json` and `manifest.json`: configuration identity and file checksums.
The directory and files are private. Treat a copied recovery directory as containing customer data and credentials. Keep it in the administrator's protected backup storage. Upgrade snapshots are **not automatically deleted by routine backup retention**.
The script leaves unused original/failed candidate volumes in place for recovery or investigation. Remove them only after acceptance and after identifying the exact volume names in the upgrade journal/backup metadata. Do not remove the active volume recorded in `setup-result.json`.
Routine `backup-conductor.sh` / `restore-conductor.sh` remain appropriate for data recovery on a compatible runtime. Use **`update-conductor.sh --rollback`** when the runtime image must also be returned to its prior version.
## Modular legacy app migration
Base Conductor and its fresh setup do not register Social Scheduler. The upgrade bundle contains app-owned packages under `conductor-app-packages/`. Only an app already recorded in the old installation is migrated. Uninstalled apps in that directory are never added automatically.
A future legacy integration can supply a verified `conductor-app.json` browser package plus a separate `conductor-migration.json` recipe:
```json
{
"migrationVersion": 1,
"applicationId": "example-app",
"fromCatalogVersions": ["0.4.0"],
"connection": {
"origin": {"environment": "EXAMPLE_API_ORIGIN"},
"token": {"environment": "EXAMPLE_INTERNAL_TOKEN"},
"values": {
"workspaceId": {"environment": "EXAMPLE_WORKSPACE_ID", "default": "customer"}
}
}
}
```
The recipe names existing environment settings; it contains no credentials or executable migration code. The upgrader copies their values into the app host's encrypted connection record. Packages without an API can omit `connection`. Independent apps already installed through host API 1 need no legacy recipe and are not updated by a core upgrade.
For development/rebuilding and test evidence, see [TESTING.md](../TESTING.md) and the [SLICE13 log](../SLICE13.md).

View File

@ -4,7 +4,7 @@ This guide is for independent self-hosters installing Conductor on an ordinary i
The recommended path runs the published container. The tagged source bundle supplies the matching Compose file, environment template, backup/restore scripts, and documentation.
Use the `v1.1.0` commands below only after the public Gitea Release entry exists. Before publication, do not substitute an older release candidate and treat a missing source tag or image as an incomplete release rather than an installation problem.
Conductor v1.1.0 is published and verified. Skeleton Works VM administrators should use the [Skeleton Works installation and handover guide](SKELETONWORKS_INSTALL.md), which automates fresh setup. Existing installations use the [upgrade and recovery guide](CONDUCTOR_UPGRADE.md). The commands below remain the independent self-hosting path.
## What you need
@ -239,18 +239,10 @@ Do not add `--volumes` unless permanent data deletion is intentional and a verif
For common failures, see [Troubleshooting](TROUBLESHOOTING.md).
## Included-application support
The current source creates an empty installed-application registry in the existing SQLite database on startup. No additional database service or environment variable is required. This source change is not automatically present in an older published container image: deploy a build containing it before registering applications.
The Social Scheduler 0.4.0 kit includes this updated Conductor source and builds the integrated image inside Docker. Existing Skeleton Works setup-conductor.sh URL, volume and container settings work unchanged. Use the application's installer to update that installation; rerunning the base setup script can replace application-specific settings.
See [Included applications](INCLUDED_APPLICATIONS.md) for the installer contract and lifecycle responsibilities.
## Independently installed applications
Conductor v1.1.0 includes the default favicon, application-aware titles, publishing icons and application host API v1. Installers add verified browser packages and declared API routes without modifying this image. A fresh database has no seeded applications. See [Application packages](APP_PACKAGES.md).
The existing Conductor data volume also contains installed app files beneath /data/applications. Back up these files with the database and persistent keys. The updated backup/restore tools cover this state.
The Skeleton Works fresh setup script is updated separately in [SLICE12](../SLICE12.md). The supported existing-VM upgrade process is [SLICE13](../SLICE13.md). Do not rerun the older setup-conductor.sh against the combined Social Scheduler image as a substitute for that migration.
The Skeleton Works fresh setup script installs this verified release and its matched backup tooling; see [the setup guide](SKELETONWORKS_INSTALL.md) and [SLICE12](../SLICE12.md). The supported existing-VM upgrade process is documented in the [upgrade guide](CONDUCTOR_UPGRADE.md), with evidence in [SLICE13](../SLICE13.md). Do not rerun the older setup-conductor.sh against the combined Social Scheduler image as a substitute for that migration.

View File

@ -0,0 +1,156 @@
# Skeleton Works: Conductor installation and handover
Audience: the VM administrator. This guide covers a **new** Skeleton Works Conductor installation using the verified v1.1.0 release. Upgrading an existing installation is covered by the [upgrade and recovery guide](CONDUCTOR_UPGRADE.md) from SLICE13.
Conductor is a required application in the Skeleton Works SaaS offering. Social Scheduler and future included applications are installed separately into it. Customers can still use Node-RED and its APIs for their daily work.
## Before setup
Complete bootstrap.sh and setup-caddy.sh on the new Ubuntu AMD64 VM. The shared Caddy configuration and Docker/Compose must exist. DNS for the chosen Conductor hostname must point to this VM. Bootstrap deploys every sibling shell script into /opt/skeletonworks/scripts.
Deploy these **three matching scripts together** from the updated skeletonworks-scripts folder:
- setup-conductor.sh — installs the verified release.
- backup-conductor.sh — backs up SQLite, configuration, keys and installed app files.
- restore-conductor.sh — verifies and restores that recovery set; accepts backup formats v1 and v2.
If bootstrap has already run, copy these three updated files to the VM, then install them into the scripts directory:
```bash
sudo install -o admin -g admin -m 755 \
setup-conductor.sh backup-conductor.sh restore-conductor.sh \
/opt/skeletonworks/scripts/
```
Use this on a new installation. Do not replace the matched recovery tooling on the current combined 0.4.0 Conductor/Social Scheduler VM until following its upgrade plan.
Setup checks Docker/Compose, Python 3, OpenSSL, cron and the admin account. It does not install or use Node.js on the VM host. Node runs inside Conductor's container. There is no Conductor PostgreSQL setup: Conductor uses SQLite. Social Scheduler's own installer handles its PostgreSQL database.
The default is the published Conductor v1.1.0 image pinned to this verified OCI digest:
```text
gitea.skeletonworks.online/vwiebe/conductor:v1.1.0@sha256:ab5e7dcaed065a304660af31e53ffada290b22dc9176ffeb5817f2e7f07ac313
```
The image supplies the finished management screens, default favicon, browser titles and installed-application host. Setup does not patch or build frontend source. [Release artifacts and verification](https://gitea.skeletonworks.online/vwiebe/conductor/releases/tag/v1.1.0) are publicly downloadable.
## 1. Review the plan
Replace customer.example.com with the customer's base domain:
```bash
sudo bash /opt/skeletonworks/scripts/setup-conductor.sh \
--domain customer.example.com \
--restartcaddy \
--dry-run
```
**Why:** confirm the hostname, exact image, container and volume before installation. **Expected:** a JSON plan and no changes to files, images, containers, keys, Caddy or cron.
Use sudo for dry-run too: bootstrap protects /opt/skeletonworks. A permission failure is not evidence that the installation is missing.
The default URL in this example is https://conductor.customer.example.com. Supported alternatives:
| Arguments | Resulting hostname |
|---|---|
| --domain customer.example.com | conductor.customer.example.com |
| --domain conductor.customer.example.com | conductor.customer.example.com |
| --domain customer.example.com --subdomain portal | portal.customer.example.com |
| --domain customer.example.com --subdomain portal.other.example.com | portal.other.example.com |
Supply DNS names without https://, a path or a port. Setup never substitutes a developer preview URL.
## 2. Install
Run the same command without --dry-run:
```bash
sudo bash /opt/skeletonworks/scripts/setup-conductor.sh \
--domain customer.example.com \
--restartcaddy
```
**Why:** install and expose the actual application. **Expected:** the pinned image is pulled publicly, its application-host capability is checked, Conductor becomes healthy, the Caddy configuration validates and reloads, and a backup job is installed.
The --restartcaddy flag applies the validated configuration through a graceful Caddy reload. Without it, setup saves the route but reports caddyApplied: false; apply that saved configuration before opening the browser. Validation uses the [Caddy command-line interface](https://caddyserver.com/docs/command-line) and preserves other applications' site blocks.
The command prints a redacted JSON result and saves the same document at:
```text
/opt/skeletonworks/conductor/CONDUCTOR_FQDN/setup-result.json
```
A complete installation reports status: success, started: true and health: healthy. The url and files fields are the discovery contract used by included-app installers.
### Prepare now, start later
Use --no-start instead of --restartcaddy to write private configuration without pulling an image, starting a service, modifying Caddy or installing cron. The result is status: prepared, not a completed installation.
Rerun with the same domain, image and resource options, removing --no-start and adding --restartcaddy, to finish. The script verifies and reuses the prepared files and keys. Changed preparation files or different options are refused rather than silently overwritten.
An interrupted installation can resume from this preparation record. A completed or older installation is refused by fresh setup and directed to the separate upgrade process.
## 3. Application administrator handover
The VM administrator and application administrator can be different people. Give the customer's intended application administrator the final HTTPS URL and the [Administrator Guide](ADMIN_GUIDE.md).
1. Open that URL and create the first administrator. **Why:** establish the customer's application account; there is no default Conductor username or password. **Expected:** the first-run screen closes and the administrator reaches Conductor.
2. Open Projects and create a small project. **Why:** verify the finished project-management screen and saving. **Expected:** the saved project appears in Projects.
3. Open Publishing. **Why:** verify a clean installation does not advertise an app it cannot run. **Expected:** Included with Skeleton Works is empty.
4. Check the browser tab. **Why:** confirm the installed release supplies the favicon and page titles. **Expected:** the Conductor icon and title appear; an installed app later receives its app-specific title.
5. Follow the included application's installation/configuration guide. **Why:** keep app installation and customer-owned social credentials in their proper roles. **Expected:** a successfully installed app appears in Publishing.
For Social Scheduler, the VM administrator installs the 0.5.0 kit after Conductor, Node-RED and PostgreSQL are ready. The customer then connects social accounts in Conductor or Node-RED. The Social Scheduler installer remains responsible for registering its Publishing entry; base setup never seeds it.
The Social Scheduler discovery command also requires sudo:
```bash
sudo bash setup-social-scheduler.sh --dry-run
```
If multiple Conductor installations exist, use its --conductor-result option with the exact protected setup-result.json path above.
## Persistence and recovery
The generated container has a read-only root filesystem, runs as its non-root application user, and exposes port 8080 only on the shared Docker network. Caddy provides public HTTPS.
One named volume holds all Conductor state beneath /data:
- SQLite users, projects, publications, icons, installed-app selections and encrypted backend connections.
- Installed application files beneath /data/applications.
The stack's .env and .secrets hold persistent keys and have mode 600; the stack directory has mode 700. Keep both keys with the data backup. Recreating the container must retain this volume and configuration.
The matched backup script runs as admin every day at 01:45 in the VM's cron timezone and retains seven days by default. It uses SQLite's online backup and copies the verified app files referenced by that snapshot. Its checksummed backup includes the keys and Compose configuration. Confirm backup output before relying on the schedule:
```bash
sudo -u admin /opt/skeletonworks/scripts/backup-conductor.sh \
--fqdn conductor.customer.example.com
```
**Why:** verify the scheduled user's permissions and complete recovery coverage. **Expected:** status: success and coverage.installedApplications: true, plus the archive path. Copy the protected archive and checksum off the VM.
List available backups without restoring anything:
```bash
sudo bash /opt/skeletonworks/scripts/restore-conductor.sh \
--fqdn conductor.customer.example.com --list
```
To restore a deliberately selected backup:
```bash
sudo bash /opt/skeletonworks/scripts/restore-conductor.sh \
--fqdn conductor.customer.example.com \
--backup-file /absolute/path/to/conductor_BACKUP_TIMESTAMP.tar.gz
```
**Why:** recover Conductor data and the keys needed to read it. **Expected:** checksum/format/hostname checks, a confirmation prompt, a safety backup, brief Conductor downtime, restored database/app files and a healthy service. Noninteractive restore requires --force. This is recovery, not an application upgrade.
These backups do not replace Node-RED or PostgreSQL backups. Social credentials, private media, schedules and posting history belong to their respective application backends.
## Existing installations
Fresh setup stops if it finds an existing installation, container or volume that is not its own unfinished preparation. This includes the current combined Conductor/Social Scheduler image. It leaves customer keys, app registrations and data intact.
Do not delete the setup result, preparation record or data volume to bypass this check. Use [update-conductor.sh and its recovery guide](CONDUCTOR_UPGRADE.md) for image migration and repeatable upgrades. [SLICE12](../SLICE12.md) records the exact fresh-install validation and its limits.

View File

@ -0,0 +1,35 @@
"""Build a local VM upgrade kit. The core image and published app release are unchanged."""
import argparse,hashlib,pathlib,shutil,tarfile,tempfile
ROOT=pathlib.Path(__file__).resolve().parent.parent
parser=argparse.ArgumentParser()
parser.add_argument('--scripts',type=pathlib.Path,default=ROOT.parent/'skeletonworks-scripts')
parser.add_argument('--legacy-app-release',type=pathlib.Path,default=ROOT.parent/'social-scheduler/release/social-scheduler-0.5.0.tar.gz')
parser.add_argument('--migration-recipe',type=pathlib.Path,default=ROOT.parent/'social-scheduler/integration/conductor-migration.json')
args=parser.parse_args()
expected='e5a9e230a53c750c63aefeed6ec91fb6ca04e678a7cb75418e921b48ac27fdbd'
if hashlib.sha256(args.legacy_app_release.read_bytes()).hexdigest()!=expected:raise SystemExit('The legacy frontend migration requires the verified, published Social Scheduler 0.5.0 kit.')
out=ROOT/'release';out.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='conductor-upgrade-') as temp:
kit=pathlib.Path(temp)/'conductor-upgrade';kit.mkdir()
for name in ['update-conductor.sh','setup-conductor.sh','backup-conductor.sh','restore-conductor.sh']:
shutil.copy2(args.scripts/name,kit/name)
guide=ROOT/'docs/CONDUCTOR_UPGRADE.md'
if guide.exists():shutil.copy2(guide,kit/'INSTALL.md')
packages=kit/'conductor-app-packages';packages.mkdir()
# Only the app-owned browser package and declarative connection mapping are bundled.
with tarfile.open(args.legacy_app_release) as archive:
prefix='social-scheduler/dist/conductor-app/'
for member in archive:
if not member.name.startswith(prefix) or member.isdir():continue
relative=pathlib.PurePosixPath(member.name[len(prefix):])
if not member.isfile() or relative.is_absolute() or '..' in relative.parts:raise SystemExit('Unsafe release package asset.')
target=packages/'social-scheduler'/str(relative);target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(archive.extractfile(member).read())
shutil.copy2(args.migration_recipe,packages/'social-scheduler/conductor-migration.json')
files=sorted(p for p in kit.rglob('*') if p.is_file())
(kit/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+str(p.relative_to(kit))+'\n' for p in files))
output=out/'conductor-upgrade-1.1.0.tar.gz'
with tarfile.open(output.with_suffix('.gz.new'),'w:gz') as archive:archive.add(kit,arcname=kit.name)
output.with_suffix('.gz.new').replace(output)
(out/(output.name+'.sha256')).write_text(hashlib.sha256(output.read_bytes()).hexdigest()+' '+output.name+'\n')
print(output)

View File

@ -0,0 +1,20 @@
"""Build test-only images; never modifies the archived kit or project source."""
import pathlib,subprocess,tarfile,tempfile,hashlib,json,shutil
ROOT=pathlib.Path(__file__).resolve().parent.parent
archive=ROOT.parent/'social-scheduler/release/social-scheduler-0.4.0.tar.gz'
node=shutil.which('node')
if not node:raise SystemExit('Use the developer Node.js 24 environment; the disposable VM itself needs no Node.js.')
if not archive.is_file():raise SystemExit('The original Social Scheduler 0.4.0 archive is required for the faithful legacy test.')
subprocess.run(['docker','build','-f',str(ROOT/'scripts/fixtures/slice12/Dockerfile'),'-t','conductor:slice12-test-host',str(ROOT/'scripts/fixtures/slice12')],check=True)
with tempfile.TemporaryDirectory(prefix='conductor-legacy-fixture-') as temporary:
with tarfile.open(archive) as tar:
for member in tar:
p=pathlib.PurePosixPath(member.name)
if p.is_absolute() or '..' in p.parts or not(member.isfile() or member.isdir()):raise SystemExit('Unsafe legacy archive.')
tar.extractall(temporary)
kit=pathlib.Path(temporary)/'social-scheduler'
subprocess.run([node,'scripts/build-conductor-copy.mjs','vendor/conductor'],cwd=kit,check=True)
context=kit/'.local/conductor-build'
subprocess.run(['docker','build','-f',str(context/'Dockerfile.production'),'-t','conductor:slice13-legacy',str(context)],check=True)
result=ROOT/'test-results/slice13';result.mkdir(parents=True,exist_ok=True)
(result/'legacy-fixture-source.json').write_text(json.dumps({'archive':archive.name,'sha256':hashlib.sha256(archive.read_bytes()).hexdigest(),'sourceChanges':False,'image':'conductor:slice13-legacy'},indent=2)+'\n')

View File

@ -0,0 +1,11 @@
# Disposable Ubuntu customer-style test host. No Node.js, host socket or customer files.
FROM docker:29.6.2-dind AS engine
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends python3 openssl curl ca-certificates cron iptables iproute2 procps util-linux libseccomp2 && rm -rf /var/lib/apt/lists/*
COPY --from=engine /usr/local/bin/docker /usr/local/bin/dockerd /usr/local/bin/containerd /usr/local/bin/containerd-shim-runc-v2 /usr/local/bin/ctr /usr/local/bin/runc /usr/local/bin/docker-init /usr/local/bin/docker-proxy /usr/local/bin/
COPY --from=engine /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
RUN groupadd docker && useradd -m -G docker admin && mkdir -p /opt/skeletonworks/scripts /opt/skeletonworks/caddy/logs && chown -R admin:admin /opt/skeletonworks
COPY --from=engine /usr/local/bin/dind /usr/local/bin/dind
ENTRYPOINT ["dind"]
CMD ["dockerd", "--host=unix:///var/run/docker.sock", "--storage-driver=vfs", "--log-level=error"]

View File

@ -0,0 +1,25 @@
import pathlib,json,hashlib,tarfile,io,subprocess,sys,tempfile
archive,fqdn=sys.argv[1:]
stack=pathlib.Path('/opt/skeletonworks/conductor')/fqdn
def fingerprint():return {p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in stack.iterdir() if p.is_file()}
before=fingerprint()
with tempfile.TemporaryDirectory() as directory:
directory=pathlib.Path(directory)
with tarfile.open(archive) as source:
original=[(m,source.extractfile(m).read() if m.isfile() else b'') for m in source.getmembers()]
for kind in ['checksum','symlink','traversal','missing-applications']:
target=directory/(kind+'.tar.gz')
with tarfile.open(target,'w:gz') as output:
for member,data in original:
if kind=='missing-applications' and (member.name=='applications' or member.name.startswith('applications/')):continue
if kind=='checksum' and member.name=='manifest.json':data=data+b' '
member.size=len(data) if member.isfile() else 0
output.addfile(member,io.BytesIO(data) if member.isfile() else None)
if kind in ['symlink','traversal']:
member=tarfile.TarInfo('unsafe' if kind=='symlink' else '../unsafe')
if kind=='symlink':member.type=tarfile.SYMTYPE;member.linkname='/etc/passwd'
output.addfile(member)
result=subprocess.run(['/opt/skeletonworks/scripts/restore-conductor.sh','--fqdn',fqdn,'--backup-file',str(target),'--force','--skip-safety-backup'],capture_output=True,text=True)
assert result.returncode!=0,kind
assert fingerprint()==before,kind+' changed configuration before rejecting the archive'
print('PASS: corrupted, linked, traversing and incomplete-app backups rejected without configuration changes.')

View File

@ -0,0 +1,80 @@
// Run only against the disposable nested-Docker host created by test-setup-conductor.py.
import {chromium,expect} from '@playwright/test';
import {execFileSync} from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
const [host,port]=process.argv.slice(2);
if(!/^conductor-slice12-[a-f0-9]{10}$/.test(host)||!/^[0-9]+$/.test(port))throw new Error('Disposable test host required.');
const artifacts=path.resolve('test-results/slice12');
const hostname='conductor.customer.test',base='https://'+hostname+':'+port;
const run=(args,input)=>execFileSync('docker',['exec','-i',host,...args],{input,encoding:'utf8',maxBuffer:4e6});
const result=JSON.parse(run(['cat','/opt/skeletonworks/conductor/'+hostname+'/setup-result.json']));
const inner=result.container;
const cli=(...args)=>JSON.parse(run(['docker','exec',inner,'node','dist/scripts/installedApps.js',...args]));
let browser,page;
try{
browser=await chromium.launch({headless:true,args:['--host-resolver-rules=MAP '+hostname+' 127.0.0.1','--no-proxy-server']});
const context=await browser.newContext({baseURL:base,ignoreHTTPSErrors:true,viewport:{width:1400,height:1000}});
page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('/');
await expect(page).toHaveTitle('Set up · Conductor');
// Browser-based setup proves HTTPS cookies work through the generated Caddy route.
await page.getByLabel('Username',{exact:true}).fill('customer-admin');
await page.getByLabel('Password',{exact:true}).fill('disposable-setup-password');
await page.getByLabel('Confirm password',{exact:true}).fill('disposable-setup-password');
await page.getByRole('button',{name:'Create administrator',exact:true}).click();
await expect(page.getByRole('heading',{name:'Welcome to Conductor'})).toBeVisible();
await expect(page).toHaveTitle('Conductor');
await expect(page.locator('#conductor-favicon')).toHaveAttribute('href','/favicon.svg');
await expect(page.getByText(/Backend scaffold|coming in a later step/)).toHaveCount(0);
const nav=name=>page.getByRole('navigation').getByRole('button',{name,exact:true}).click();
await nav('Projects');
await expect(page.getByText('No saved projects yet.')).toBeVisible();
await page.getByRole('button',{name:'New project',exact:true}).click();
await page.getByLabel('Project name',{exact:true}).fill('Fresh VM project');
await page.getByRole('button',{name:'Create project',exact:true}).click();
await expect(page.getByTitle('Click to rename project')).toHaveText('Fresh VM project');
await nav('Publishing');
await expect(page.getByText('No included applications are installed yet.',{exact:false})).toBeVisible();
expect(cli('list')).toEqual([]);
await page.screenshot({path:path.join(artifacts,'fresh-publishing.png'),fullPage:true});
// Install a verified, independently packaged app, without any core image/config change.
const originalImage=run(['docker','inspect','--format','{{.Image}}',inner]);
run(['docker','exec',inner,'mkdir','-m','700','/tmp/example-package']);
run(['bash','-c','tar -C /opt/conductor-test/example-package -cf - . | docker exec -i "$1" tar -xf - -C /tmp/example-package','fixture',inner]);
const installed=cli('install','/tmp/example-package');
await page.getByRole('button',{name:'Refresh applications',exact:true}).click();
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toBeVisible();
await expect(page.getByText('Social Scheduler',{exact:true})).toHaveCount(0);
await page.screenshot({path:path.join(artifacts,'installed-publishing.png'),fullPage:true});
await page.getByRole('link',{name:'Open Example Application',exact:true}).click();
await expect(page).toHaveTitle('Example Application · Conductor');
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toBeVisible();
expect(run(['docker','inspect','--format','{{.Image}}',inner])).toBe(originalImage);
const backup=JSON.parse(run(['runuser','-u','admin','--','/opt/skeletonworks/scripts/backup-conductor.sh','--fqdn',hostname]));
expect(backup.coverage.installedApplications).toBe(true);
// Reject corrupt/unsafe backups before touching the live fixture.
run(['python3','/opt/conductor-test/check-backup-rejection.py',backup.backup.file,hostname]);
// Lose only the known disposable application's assets, then prove recovery from the archive.
run(['docker','exec',inner,'node','-e','require("fs").rmSync("/data/applications/example-application",{recursive:true,force:true})']);
run(['/opt/skeletonworks/scripts/restore-conductor.sh','--fqdn',hostname,'--backup-file',backup.backup.file,'--force','--skip-safety-backup']);
expect(cli('list')[0].digest).toBe(installed.digest);
run(['docker','exec',inner,'node','-e','if(!require("fs").existsSync(process.argv[1]))process.exit(1)','/data/applications/example-application/'+installed.digest+'/app.js']);
run(['docker','compose','-f','/opt/skeletonworks/conductor/'+hostname+'/docker-compose.yml','up','-d','--force-recreate']);
for(let i=0;i<50;i++){
if(run(['docker','inspect','--format','{{.State.Health.Status}}',inner]).trim()==='healthy')break;
await new Promise(r=>setTimeout(r,500));
}
expect(run(['docker','inspect','--format','{{.State.Health.Status}}',inner]).trim()).toBe('healthy');
await page.goto('/');await nav('Projects');
await expect(page.getByRole('article',{name:'Project Fresh VM project',exact:true})).toBeVisible();
await page.goto('/example-application');
await expect(page.getByRole('heading',{name:'Example Application',exact:true})).toBeVisible();
expect(cli('list')[0].digest).toBe(installed.digest);
expect(errors).toEqual([]);
await fs.writeFile(path.join(artifacts,'browser-result.json'),JSON.stringify({passed:true,checks:['HTTPS browser first-run administrator','default icon and browser title','finished Projects','empty initial Publishing','independent app registration and launch','unchanged core image','admin-run scheduled backup','corrupt and unsafe archive refusal','app asset restore','account/project/app persistence after recreation']},null,2));
console.log('PASS: generated HTTPS route, browser administration, Projects, empty/populated Publishing, app restore and recreation.');
}catch(error){
if(page)await page.screenshot({path:path.join(artifacts,'failure.png'),fullPage:true}).catch(()=>{});
throw error;
}finally{await browser?.close();}

View File

@ -0,0 +1,127 @@
"""Fresh-install acceptance in a disposable Ubuntu host with a separate Docker daemon.
Requires the local Docker engine. The privileged outer container is only for nested
Docker; no host socket, host directory, customer VM or production data is mounted.
"""
import pathlib,subprocess,secrets,json,time,os,hashlib,sys
ROOT=pathlib.Path(__file__).resolve().parent.parent
SCRIPTS=ROOT.parent/'skeletonworks-scripts'
ARTIFACTS=ROOT/'test-results/slice12';ARTIFACTS.mkdir(parents=True,exist_ok=True)
name='conductor-slice12-'+secrets.token_hex(5)
fqdn='conductor.customer.test'
stack='/opt/skeletonworks/conductor/'+fqdn
setup='/opt/skeletonworks/scripts/setup-conductor.sh'
def command(args,input=None,check=True):
r=subprocess.run(args,input=input,text=True,capture_output=True)
if check and r.returncode:raise RuntimeError('Command failed: '+str(args[:5])+'\n'+r.stderr[-9000:])
return r
def host(args,input=None,check=True):return command(['docker','exec','-i',name]+args,input,check)
def write_files(files):
host(['python3','-c','import sys,json,pathlib\nfor name,content in json.load(sys.stdin).items():\n p=pathlib.Path(name);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(content)\n'],json.dumps(files))
def snapshot():
return json.loads(host(['python3','-c','import pathlib,hashlib,json\np=pathlib.Path("/opt/skeletonworks")\nprint(json.dumps({str(f):hashlib.sha256(f.read_bytes()).hexdigest() for f in p.rglob("*") if f.is_file()}))']).stdout)
def invoke(args,check=True):
result=host(['bash',setup]+args,check=check)
if check:return json.loads(result.stdout)
assert result.returncode!=0,'Expected setup refusal'
return result.stderr
def milestone(text):print(text,flush=True)
try:
command(['docker','run','-d','--privileged','--cgroupns=private','--name',name,'--publish','127.0.0.1::443','conductor:slice12-test-host'])
for _ in range(80):
if host(['docker','info'],check=False).returncode==0:break
time.sleep(.25)
else:raise RuntimeError('The disposable nested Docker daemon did not start.')
assert host(['bash','-c','command -v node'],check=False).returncode!=0
host(['bash','-c','getent group docker >/dev/null || groupadd docker; usermod -aG docker admin; chgrp docker /var/run/docker.sock'])
for script in ['setup-conductor.sh','backup-conductor.sh','restore-conductor.sh']:
command(['docker','cp',str(SCRIPTS/script),name+':/opt/skeletonworks/scripts/'+script])
host(['chmod','755',setup,'/opt/skeletonworks/scripts/backup-conductor.sh','/opt/skeletonworks/scripts/restore-conductor.sh'])
host(['mkdir','-p','/opt/conductor-test'])
command(['docker','cp',str(ROOT/'examples/installed-app'),name+':/opt/conductor-test/example-package'])
command(['docker','cp',str(ROOT/'scripts/fixtures/slice12/check-backup-rejection.py'),name+':/opt/conductor-test/check-backup-rejection.py'])
caddy="""{
local_certs
auto_https disable_redirects
}
unrelated.customer.test {
respond "Neighbor preserved"
}
"""
compose="""services:
caddy:
image: caddy:2.10.2
ports: ["443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./logs:/var/log/caddy
- caddy_data:/data
- caddy_config:/config
networks: [skeletonworks_net]
volumes:
caddy_data:
caddy_config:
networks:
skeletonworks_net: {external: true}
"""
write_files({'/opt/skeletonworks/caddy/Caddyfile':caddy,'/opt/skeletonworks/caddy/docker-compose.yml':compose})
host(['crontab','-u','admin','-'],'0 2 * * * echo preserve-unrelated-job\n')
before=snapshot()
plan=invoke(['--domain','customer.test','--dry-run'])
assert plan['url']=='https://'+fqdn and plan['hostNodeRequired'] is False
assert plan['image'].endswith('@sha256:ab5e7dcaed065a304660af31e53ffada290b22dc9176ffeb5817f2e7f07ac313')
assert snapshot()==before
for domain,sub,expected in [('conductor.customer.test','','conductor.customer.test'),('customer.test','portal','portal.customer.test'),('customer.test','portal.other.test','portal.other.test')]:
args=['--domain',domain,'--dry-run']+(['--subdomain',sub] if sub else [])
assert invoke(args)['url']=='https://'+expected
for args in [['--domain'],['--domain','../customer.test'],['--domain','https://customer.test'],['--domain','customer.test','--image','conductor:latest'],['--domain','customer.test','--cpus','0'],['--domain','customer.test','--no-start','--restartcaddy']]:
invoke(args,False)
nonroot=host(['runuser','-u','admin','--','bash',setup,'--domain','customer.test','--dry-run'],check=False)
assert nonroot.returncode!=0 and 'sudo' in nonroot.stderr
assert snapshot()==before
write_files({'/opt/skeletonworks/conductor/conductor.legacy.test/.env':'preserve-legacy-configuration\n'})
before=snapshot()
assert 'SLICE13' in invoke(['--domain','legacy.test'],False)
assert snapshot()==before
milestone('PASS: protected dry-run, FQDN variants, input rejection and unchanged legacy installation.')
prepared=invoke(['--domain','customer.test','--no-start'])
assert prepared['status']=='prepared' and not prepared['started'] and not prepared['cronInstalled']
protected=snapshot()
assert invoke(['--domain','customer.test','--no-start'])['status']=='prepared'
for file in ['.env','.secrets','docker-compose.yml','.setup-plan.json']:assert snapshot()[stack+'/'+file]==protected[stack+'/'+file]
assert host(['docker','ps','-aq']).stdout.strip()==''
assert host(['docker','image','ls','-q']).stdout.strip()==''
assert host(['cat','/opt/skeletonworks/caddy/Caddyfile']).stdout==caddy
for file in ['.env','.secrets','docker-compose.yml','setup-result.json']:
assert host(['stat','-c','%a %U',stack+'/'+file]).stdout.strip()=='600 admin'
milestone('PASS: preparation/repeat preserves private keys and makes no container, image, Caddy or cron changes.')
started=invoke(['--domain','customer.test','--restartcaddy'])
assert started['status']=='success' and started['started'] and started['cronInstalled'] and started['caddyApplied']
assert started['verified']['version']=='v1.1.0' and started['verified']['firstRunSetupRequired']
for file in ['.env','.secrets']:assert snapshot()[stack+'/'+file]==protected[stack+'/'+file]
assert 'Neighbor preserved' in host(['cat','/opt/skeletonworks/caddy/Caddyfile']).stdout
cron=host(['crontab','-u','admin','-l']).stdout
assert cron.count('# skeletonworks-conductor '+fqdn)==1 and 'preserve-unrelated-job' in cron
inner=started['container']
metadata=json.loads(host(['docker','inspect',inner]).stdout)[0]
assert metadata['HostConfig']['ReadonlyRootfs'] and metadata['Config']['User']=='conductor'
assert metadata['HostConfig']['PortBindings']=={}
assert any(v['Destination']=='/data' and v['Name']==inner+'-data' for v in metadata['Mounts'])
assert json.loads(host(['docker','exec',inner,'node','dist/scripts/installedApps.js','list']).stdout)==[]
port=json.loads(command(['docker','inspect',name]).stdout)[0]['NetworkSettings']['Ports']['443/tcp'][0]['HostPort']
milestone('PASS: pinned public image, generated Caddy route, persistent volume, first-run readiness and preserved neighbor/cron.')
result=subprocess.run(['node',str(ROOT/'scripts/test-setup-conductor.mjs'),name,port],cwd=ROOT,text=True,capture_output=True)
(ARTIFACTS/'browser.log').write_text(result.stdout+'\n'+result.stderr)
if result.returncode:raise RuntimeError('Browser/recovery acceptance failed; see test-results/slice12/browser.log:\n'+result.stderr[-9000:])
milestone(result.stdout.strip())
before=snapshot()
assert 'SLICE13' in invoke(['--domain','customer.test'],False)
assert snapshot()==before
assert host(['bash','-c','command -v node'],check=False).returncode!=0
(ARTIFACTS/'result.json').write_text(json.dumps({'passed':True,'environment':'Disposable Ubuntu 24.04 with isolated Docker daemon; no host Node.js','setupChecks':['read-only dry-run','domain/FQDN variants','invalid argument refusal','sudo guidance','old installation preserved','no-start/repeat/resume','key/configuration permissions','verified release digest','healthy first-run setup','persistent app volume','Caddy validation/reload','unrelated Caddy/cron preserved','completed installation refuses fresh setup'],'browserAndRecovery':'passed','liveVmChanged':False},indent=2)+'\n')
milestone('PASS: SLICE12 fresh-install acceptance complete. No customer VM was used.')
finally:
logs=command(['docker','logs',name],check=False)
(ARTIFACTS/'test-host.log').write_text(logs.stderr[-12000:])
command(['docker','rm','-f','-v',name],check=False)

View File

@ -0,0 +1,67 @@
// Browser checks run only against the isolated SLICE13 test host.
import {chromium,expect} from '@playwright/test';
import fs from 'node:fs/promises';
const [host,port,phase]=process.argv.slice(2);
if(!/^conductor-slice13-[a-f0-9]{10}$/.test(host)||!/^\d+$/.test(port)||!['seed','legacy','upgraded'].includes(phase))throw Error('Disposable upgrade fixture required.');
const dir='test-results/slice13',base='https://conductor.customer.test:'+port;
const browser=await chromium.launch({headless:true,args:['--host-resolver-rules=MAP conductor.customer.test 127.0.0.1','--no-proxy-server']});
let page;
try{
const context=await browser.newContext({baseURL:base,ignoreHTTPSErrors:true,viewport:{width:1400,height:1000}});
page=await context.newPage();
await page.goto('/');
await expect(page.getByLabel('Username',{exact:true})).toBeVisible();
const request=async(method,url,options={})=>{
const value=await page.evaluate(async({method,url,data,headers})=>{
const response=await fetch(url,{method,headers:{...(data?{'Content-Type':'application/json'}:{}),...headers},body:data?JSON.stringify(data):undefined});
return {status:response.status,body:await response.json()};
},{method,url,...options});
return {status:()=>value.status,json:async()=>value.body};
};
let auth;
if(phase==='seed'){
const setup=await request('GET','/api/auth/setup');
auth=await request('POST','/api/auth/setup',{headers:{'X-Setup-Token':(await setup.json()).setupToken},data:{username:'customer-admin',password:'disposable-upgrade-password',displayName:'Customer Administrator'}});
expect(auth.status()).toBe(201);
}else{
auth=await request('POST','/api/auth/login',{data:{username:'customer-admin',password:'disposable-upgrade-password'}});
expect(auth.status()).toBe(200);
}
const csrf=(await auth.json()).csrfToken;
if(phase==='seed'){
const project=await request('POST','/api/projects',{headers:{'X-CSRF-Token':csrf},data:{name:'Customer project',description:'Preserve my saved work.'}});
expect(project.status()).toBe(201);
const publication=await request('POST','/api/admin/published-apps',{headers:{'X-CSRF-Token':csrf},data:{sourceProjectId:(await project.json()).id,slug:'customer-app',displayName:'Customer publication',visibility:'authenticated'}});
expect(publication.status()).toBe(201);
}
const projects=await (await request('GET','/api/projects')).json();
expect(projects).toHaveLength(1);expect(projects[0].name).toBe('Customer project');
const published=await (await request('GET','/api/admin/published-apps')).json();
expect(published).toHaveLength(1);expect(published[0].slug).toBe('customer-app');
expect(published[0].version).toBe(1);
const session=await request('GET','/api/social-scheduler/session');
expect(session.status()).toBe(200);expect((await session.json()).role).toBe('admin');
const snapshot=await (await request('GET','/api/social-scheduler/workspaces/customer')).json();
expect(snapshot.accounts[0].handle).toBe('customer.bsky.social');
expect(snapshot.posts[0].text).toBe('Scheduled before the Conductor upgrade.');
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('/');await page.getByRole('navigation').getByRole('button',{name:'Publishing',exact:true}).click();
await expect(page.getByRole('heading',{name:'Social Scheduler',exact:true})).toHaveCount(1);
await page.screenshot({path:dir+'/'+phase+'-publishing.png',fullPage:true});
await page.getByRole('link',{name:'Open Social Scheduler',exact:true}).click();
await expect(page.getByRole('heading',{name:'Compose a post',exact:true})).toBeVisible();
await expect(page.getByText('Scheduled before the Conductor upgrade.',{exact:true}).first()).toBeVisible();
if(phase==='upgraded'){
await expect(page).toHaveTitle('Social Scheduler · Conductor');
await expect(page.locator('#conductor-favicon')).toHaveAttribute('href','/favicon.svg');
}
await page.screenshot({path:dir+'/'+phase+'-social.png',fullPage:true});
await page.goto('/apps/customer-app');
await expect(page.getByText('Customer publication',{exact:true}).first()).toBeVisible();
expect(errors).toEqual([]);
await fs.writeFile(dir+'/'+phase+'-browser.json',JSON.stringify({passed:true,phase,checks:['same administrator password','saved project','unchanged published snapshot/version','existing social connection','existing queued post','authenticated API bridge','single included-app card','real Social Scheduler screen',...(phase==='upgraded'?['app browser title','default favicon']:[])]},null,2));
console.log('PASS: '+phase+' browser login, project/publication, app screen and existing connection/queue.');
}catch(error){
if(page)await page.screenshot({path:dir+'/'+phase+'-failure.png',fullPage:true}).catch(()=>{});
throw error;
}finally{await browser.close();}

View File

@ -0,0 +1,199 @@
"""End-to-end upgrade acceptance in disposable Ubuntu with its own Docker daemon.
No customer host, Docker socket or data directory is mounted. Host Node is absent.
Build conductor:slice13-legacy from the archived 0.4.0 kit as described in TESTING.md.
"""
import pathlib,subprocess,secrets,json,time,os,hashlib,sys
ROOT=pathlib.Path(__file__).resolve().parent.parent
ARTIFACTS=ROOT/'test-results/slice13';ARTIFACTS.mkdir(parents=True,exist_ok=True)
name='conductor-slice13-'+secrets.token_hex(5)
fqdn='conductor.customer.test';stack='/opt/skeletonworks/conductor/'+fqdn
inner='conductor-conductor_customer_test';compose=stack+'/docker-compose.yml'
kit='/opt/conductor-test/conductor-upgrade';update=kit+'/update-conductor.sh'
target='gitea.skeletonworks.online/vwiebe/conductor:v1.1.0'
def command(args,input=None,check=True):
r=subprocess.run(args,input=input,text=True,capture_output=True)
if check and r.returncode:raise RuntimeError('Command failed: '+str(args[:6])+'\n'+r.stderr[-5000:])
return r
def host(args,input=None,check=True):return command(['docker','exec','-i',name]+args,input,check)
def write_files(files):
host(['python3','-c','import sys,json,pathlib\nfor name,content in json.load(sys.stdin).items():\n p=pathlib.Path(name);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(content)\n'],json.dumps(files))
def milestone(text):print(text,flush=True)
def invoke(args=[],check=True,prefix=[]):
r=host(prefix+['bash',update,'--fqdn',fqdn]+args,check=check)
if check:return json.loads(r.stdout)
assert r.returncode!=0,'Expected update refusal'
return r
def metadata():return json.loads(host(['docker','inspect',inner]).stdout)[0]
def files():
return json.loads(host(['python3','-c','import pathlib,hashlib,json\np=pathlib.Path("'+stack+'")\nprint(json.dumps({str(f.relative_to(p)):hashlib.sha256(f.read_bytes()).hexdigest() for f in p.iterdir() if f.is_file() and f.name!=".maintenance.lock"}))']).stdout)
def check_healthy():
for _ in range(60):
r=host(['docker','exec',inner,'node','-e','fetch("http://127.0.0.1:8080/api/health").then(r=>r.json()).then(x=>console.log(JSON.stringify(x)))'],check=False)
if r.returncode==0:return json.loads(r.stdout)
time.sleep(.5)
raise RuntimeError('Fixture unhealthy.')
def browser(phase):
r=command(['node',str(ROOT/'scripts/test-update-conductor.mjs'),name,port,phase],check=False)
(ARTIFACTS/(phase+'-browser.log')).write_text(r.stdout+'\n'+r.stderr)
if r.returncode:raise RuntimeError(r.stderr[-6000:])
milestone(r.stdout.strip())
def node(code):return host(['docker','exec',inner,'node','-e',code]).stdout
def copy_scripts():
for script in ['backup-conductor.sh','restore-conductor.sh']:
host(['cp',kit+'/'+script,'/opt/skeletonworks/scripts/'+script])
host(['chmod','755','/opt/skeletonworks/scripts/'+script])
try:
command(['docker','run','-d','--privileged','--cgroupns=private','--name',name,'--publish','127.0.0.1::443','conductor:slice12-test-host'])
for _ in range(80):
if host(['docker','info'],check=False).returncode==0:break
time.sleep(.25)
else:raise RuntimeError('Nested Docker did not start.')
assert host(['bash','-c','command -v node'],check=False).returncode!=0
host(['mkdir','-p','/opt/conductor-test','/opt/skeletonworks/scripts',stack])
command(['docker','cp',str(ROOT/'release/conductor-upgrade-1.1.0.tar.gz'),name+':/opt/conductor-test/kit.tar.gz'])
host(['tar','-xzf','/opt/conductor-test/kit.tar.gz','-C','/opt/conductor-test'])
copy_scripts()
# Transfer already built public/legacy images through a pipe into the isolated engine.
producer=subprocess.Popen(['docker','image','save',target,'conductor:slice13-legacy','gitea.skeletonworks.online/vwiebe/conductor:v1.0.0'],stdout=subprocess.PIPE)
consumer=subprocess.run(['docker','exec','-i',name,'docker','load'],stdin=producer.stdout,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
producer.stdout.close();assert producer.wait()==0 and consumer.returncode==0
host(['docker','network','create','skeletonworks_net'])
env='NODE_ENV=production\nPORT=8080\nCONDUCTOR_DATA_DIR=/data\nCONDUCTOR_SECRET_KEY='+('31'*32)+'\nCONDUCTOR_SESSION_KEY='+('42'*32)+'\n'
initial={'name':'conductor-customer-test','services':{'conductor':{'image':'conductor:slice13-legacy','container_name':inner,'restart':'unless-stopped','read_only':True,'tmpfs':['/tmp:rw,noexec,nosuid,size=64m'],'env_file':['.env'],'environment':{'SOCIAL_API_ORIGIN':'http://nodered-fixture:1880/social-scheduler','SOCIAL_INTERNAL_TOKEN':'slice13-internal-private-fixture-token','SOCIAL_WORKSPACE_ID':'customer'},'volumes':['conductor_data:/data'],'networks':['skeletonworks_net']}},'volumes':{'conductor_data':{'name':inner+'-data'}},'networks':{'skeletonworks_net':{'external':True}}}
result={'script':'setup-conductor','status':'success','container':inner,'url':'https://'+fqdn,'domain':fqdn,'files':{'stack':stack,'compose':compose,'env':stack+'/.env','secrets':stack+'/.secrets','result':stack+'/setup-result.json'}}
fixture=r'''
const http=require('http');
const snapshot={accounts:[{network:'bluesky',id:'fixture-account',handle:'customer.bsky.social',displayName:'Customer Bluesky',did:'did:plc:fixture',status:'connected',revision:7}],posts:[{id:'fixture-post',network:'bluesky',accountId:'fixture-account',accountHandle:'customer.bsky.social',text:'Scheduled before the Conductor upgrade.',media:null,mode:'scheduled',localDateTime:'2030-12-01T12:00',timeZone:'UTC',disambiguation:'reject',scheduledAt:'2030-12-01T12:00:00Z',status:'queued',revision:4,message:'',canEdit:true,canCancel:true,canRetry:false,publishedUrl:null,uri:null,cid:null,attempts:[]}],publishingEnabled:true,pendingCount:1,attentionCount:0};
let writes=0;
http.createServer((req,res)=>{
if(req.url==='/fixture-status'){res.end(JSON.stringify({writes,snapshot}));return;}
if(req.method!=='GET'){writes++;res.writeHead(405);res.end();return;}
if(req.headers.authorization!=='Bearer slice13-internal-private-fixture-token'||req.headers['x-social-workspace']!=='customer'){res.writeHead(401);res.end('{}');return;}
const value=req.url==='/social-scheduler/session'?{displayName:'Customer Administrator',role:req.headers['x-conductor-role'],workspaces:[{id:'customer',name:'Customer workspace'}],preview:false,publishingEnabled:true}:snapshot;
res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify(value));
}).listen(1880,'0.0.0.0');
'''
caddy='{\n local_certs\n auto_https disable_redirects\n}\n'+fqdn+' {\n reverse_proxy '+inner+':8080\n}\nunrelated.customer.test {\n respond "Neighbor preserved"\n}\n'
caddycompose={'services':{'caddy':{'image':'caddy:2.10.2','ports':['443:443'],'volumes':['./Caddyfile:/etc/caddy/Caddyfile','caddy_data:/data','caddy_config:/config'],'networks':['skeletonworks_net']}},'volumes':{'caddy_data':{},'caddy_config':{}},'networks':{'skeletonworks_net':{'external':True}}}
write_files({compose:json.dumps(initial),stack+'/.env':env,stack+'/.secrets':'CONDUCTOR_SECRET_KEY='+('31'*32)+'\nCONDUCTOR_SESSION_KEY='+('42'*32)+'\n',stack+'/setup-result.json':json.dumps(result),'/opt/conductor-test/api.cjs':fixture,'/opt/skeletonworks/caddy/Caddyfile':caddy,'/opt/skeletonworks/caddy/docker-compose.yml':json.dumps(caddycompose)})
host(['chmod','700',stack]);host(['chmod','600',compose,stack+'/.env',stack+'/.secrets',stack+'/setup-result.json'])
host(['chown','-R','admin:admin',stack])
host(['docker','run','-d','--name','nodered-fixture','--network','skeletonworks_net','--entrypoint','node','--mount','type=bind,src=/opt/conductor-test/api.cjs,dst=/fixture.cjs,readonly',target,'/fixture.cjs'])
host(['docker','compose','-f',compose,'up','-d'])
host(['docker','compose','-f','/opt/skeletonworks/caddy/docker-compose.yml','up','-d'])
check_healthy()
node('const d=require("./dist/db/database").default;d.prepare("INSERT INTO included_apps(id,manifest_json) VALUES(?,?)").run("social-scheduler",JSON.stringify({schemaVersion:1,id:"social-scheduler",name:"Social Scheduler",description:"Schedule posts.",version:"0.4.0",launchPath:"/social-scheduler",configurationPath:"/social-scheduler?view=accounts",audience:"all-users"}));d.close();')
port=json.loads(command(['docker','inspect',name]).stdout)[0]['NetworkSettings']['Ports']['443/tcp'][0]['HostPort']
browser('seed')
node('const d=require("./dist/db/database").default,e=require("./dist/lib/secretCrypto").encryptSecret({token:"customer-encrypted-secret"});d.prepare("INSERT INTO secrets(id,name,authentication_type,encrypted_value,iv,auth_tag) VALUES(?,?,?,?,?,?)").run("preserved-secret","Saved customer API","bearer",e.encryptedValue,e.iv,e.authTag);d.close();')
original_files=files();old_image=metadata()['Image'];old_volume=[x['Name'] for x in metadata()['Mounts'] if x['Destination']=='/data'][0]
backend=json.loads(host(['docker','inspect','nodered-fixture']).stdout)[0]
dry=invoke(['--dry-run']);assert dry['legacyAppsToMigrate']==['social-scheduler']
assert files()==original_files and metadata()['Image']==old_image
nonroot=host(['runuser','-u','admin','--','bash',update,'--fqdn',fqdn,'--dry-run'],check=False)
assert nonroot.returncode!=0 and 'sudo' in nonroot.stderr
# Corrupt migration assets must be rejected before service/data mutation.
asset=kit+'/conductor-app-packages/social-scheduler/app.css'
host(['cp',asset,asset+'.original']);host(['bash','-c','echo corrupted >> "$1"','fixture',asset])
assert 'checksum' in invoke(['--dry-run'],False).stderr
host(['mv',asset+'.original',asset])
assert files()==original_files and metadata()['Image']==old_image
host(['docker','tag','conductor:slice13-legacy','conductor-incompatible:v1.1.0'])
invoke(['--image','conductor-incompatible:v1.1.0'],False)
assert files()==original_files and metadata()['Image']==old_image
milestone('PASS: protected read-only dry-run; incompatible image/corrupt package refusal preserves live service.')
# A test-only Docker shim kills the updater before candidate creation, after its verified snapshot.
shim=r'''#!/usr/bin/python3
import os,sys,signal,pathlib
args=sys.argv[1:];flag=pathlib.Path('/opt/conductor-test/interrupt')
if flag.exists() and args[:2]==['run','-d'] and '--name' in args and args[args.index('--name')+1].startswith('conductor-upgrade-'):
flag.unlink();os.kill(os.getppid(),signal.SIGKILL);sys.exit(70)
os.execv('/usr/local/bin/docker',['docker']+args)
'''
write_files({'/opt/conductor-test/bin/docker':shim,'/opt/conductor-test/interrupt':'yes'})
host(['chmod','755','/opt/conductor-test/bin/docker'])
interrupted=invoke(['--image',target],False,['env','PATH=/opt/conductor-test/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'])
pending=json.loads(host(['cat',stack+'/.upgrade-state.json']).stdout)
assert pending['phase']=='backed-up',interrupted.stderr
assert invoke(['--dry-run'])['status']=='recovery-required'
r=invoke(['--image',target]);assert r['status']=='recovered'
assert metadata()['Image']==old_image
for file,value in original_files.items():assert files()[file]==value
milestone('PASS: actual SIGKILL interruption recovers the untouched original image, configuration and data.')
# A target with valid capability metadata but a broken server proves health-failure recovery.
write_files({'/opt/conductor-test/failure/Dockerfile':'FROM '+target+'\nCMD ["node","-e","process.exit(42)"]\n'})
host(['docker','build','-t','conductor-failure:v1.1.0','/opt/conductor-test/failure'])
failed=invoke(['--image','conductor-failure:v1.1.0'],False)
assert 'previous Conductor service is running again' in failed.stderr,failed.stderr
assert metadata()['Image']==old_image
for file,value in original_files.items():assert files()[file]==value
milestone('PASS: failed candidate health automatically restores the previous working service.')
upgraded=invoke(['--image',target]);assert upgraded['status']=='upgraded' and upgraded['migratedApps']==['social-scheduler']
success_backup=upgraded['backup']
assert metadata()['Image']!=old_image
assert files()['.env']==original_files['.env'] and files()['.secrets']==original_files['.secrets']
apps=json.loads(node('const a=require("./dist/db/installedApps");console.log(JSON.stringify(a.allInstalled().map(x=>({id:x.manifest.application.id,digest:x.digest}))));require("./dist/db/database").default.close();'))
assert len(apps)==1 and apps[0]['id']=='social-scheduler'
browser('upgraded')
before=files();assert invoke(['--image',target])['status']=='already-current';assert files()==before
# Generic independent app installed alongside Social must survive another image identity.
command(['docker','cp',str(ROOT/'examples/installed-app'),name+':/opt/conductor-test/example-package'])
host(['docker','exec',inner,'mkdir','-p','/tmp/example-package'])
host(['bash','-c','tar -C /opt/conductor-test/example-package -cf - . | docker exec -i "$1" tar -xf - -C /tmp/example-package','fixture',inner])
host(['docker','exec','-i',inner,'node','dist/scripts/installedApps.js','install','/tmp/example-package'],'')
write_files({'/opt/conductor-test/compatible/Dockerfile':'FROM '+target+'\nLABEL works.skeleton.upgrade-fixture="second-compatible-image"\n'})
host(['docker','build','-t','conductor-compatible:v1.1.0','/opt/conductor-test/compatible'])
assert invoke(['--image','conductor-compatible:v1.1.0'])['migratedApps']==[]
assert len(json.loads(node('console.log(JSON.stringify(require("./dist/db/installedApps").allInstalled().map(x=>x.manifest.application.id)));require("./dist/db/database").default.close();')))==2
milestone('PASS: legacy migration and repeat preserve passwords/keys, project/publication, API connection, queue view and a second independent app.')
# Matched wrappers still work with the shared lock, including restore's nested safety backup.
backup=json.loads(host(['runuser','-u','admin','--','/opt/skeletonworks/scripts/backup-conductor.sh','--fqdn',fqdn]).stdout)
restored=host(['bash','/opt/skeletonworks/scripts/restore-conductor.sh','--fqdn',fqdn,'--backup-file',backup['backup']['file'],'--force'])
check_healthy()
milestone('PASS: routine backup and restore, including its safety backup, share the upgrade maintenance lock.')
# Damaged upgrade snapshots must not stop the live service.
host(['cp',success_backup+'/data.tar',success_backup+'/data.original'])
host(['bash','-c','echo corrupt >> "$1"','fixture',success_backup+'/data.tar'])
current_image=metadata()['Image']
assert 'checksum' in invoke(['--rollback',success_backup,'--dry-run'],False).stderr
assert metadata()['Image']==current_image and metadata()['State']['Running']
host(['mv',success_backup+'/data.original',success_backup+'/data.tar'])
# Remove the unused legacy container image/volume to prove recovery uses the saved archive.
host(['docker','image','rm','-f','conductor:slice13-legacy','conductor-incompatible:v1.1.0'])
host(['docker','volume','rm',old_volume])
host(['docker','compose','-f',compose,'stop','conductor'])
rollback=invoke(['--rollback',success_backup,'--accept-data-rewind'])
assert rollback['status']=='restored' and metadata()['Image']==old_image
assert invoke(['--rollback',success_backup,'--accept-data-rewind'])['status']=='already-restored'
browser('legacy')
after_backend=json.loads(host(['docker','inspect','nodered-fixture']).stdout)[0]
assert after_backend['Id']==backend['Id'] and after_backend['State']['StartedAt']==backend['State']['StartedAt']
observed=json.loads(host(['docker','exec','nodered-fixture','node','-e','fetch("http://127.0.0.1:1880/fixture-status").then(r=>r.json()).then(x=>console.log(JSON.stringify(x)))']).stdout)
assert observed['writes']==0 and observed['snapshot']['posts'][0]['revision']==4
assert host(['cat','/opt/skeletonworks/caddy/Caddyfile']).stdout==caddy
assert host(['bash','-c','command -v node'],check=False).returncode!=0
# A separate standard 1.0.0 installation must upgrade without falsely adding Social Scheduler.
base_fqdn='conductor.standalone.test';base_stack='/opt/skeletonworks/conductor/'+base_fqdn
base_compose=base_stack+'/docker-compose.yml';base_inner='conductor-standalone'
base_config=json.loads(json.dumps(initial))
base_config['name']='conductor-standalone'
base_service=base_config['services']['conductor']
base_service['image']='gitea.skeletonworks.online/vwiebe/conductor:v1.0.0'
base_service['container_name']=base_inner;base_service['environment']={}
base_config['volumes']['conductor_data']['name']='conductor-standalone-data'
base_record={'script':'setup-conductor','status':'success','container':base_inner,'url':'https://'+base_fqdn,'domain':base_fqdn,'files':{'stack':base_stack,'compose':base_compose,'env':base_stack+'/.env','secrets':base_stack+'/.secrets','result':base_stack+'/setup-result.json'}}
write_files({base_compose:json.dumps(base_config),base_stack+'/.env':env,base_stack+'/.secrets':env,base_stack+'/setup-result.json':json.dumps(base_record)})
host(['docker','compose','-f',base_compose,'up','-d'])
for _ in range(50):
ready=host(['docker','exec',base_inner,'node','-e','fetch("http://127.0.0.1:8080/api/health").then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))'],check=False)
if ready.returncode==0:break
time.sleep(.5)
standalone=json.loads(host(['bash',update,'--fqdn',base_fqdn,'--image',target]).stdout)
assert standalone['status']=='upgraded' and standalone['migratedApps']==[]
assert json.loads(host(['docker','exec',base_inner,'node','dist/scripts/installedApps.js','list']).stdout)==[]
assert json.loads(host(['bash',update,'--fqdn',base_fqdn,'--image',target]).stdout)['status']=='already-current'
milestone('PASS: published standalone v1.0.0 upgrades to v1.1.0 and remains empty of uninstalled apps.')
(ARTIFACTS/'result.json').write_text(json.dumps({'passed':True,'environment':'Isolated Ubuntu 24.04 Docker daemon; no host Node.js','legacySource':'Original Social Scheduler 0.4.0 release kit, rebuilt without source changes','checks':['read-only protected dry-run','source/target compatibility failure','corrupt app refusal','actual SIGKILL and recovery','candidate health failure recovery','legacy frontend migration','preserved database records/encrypted secrets','same account/password','project/publication preserved','authenticated Social Scheduler connection and queued post display','title/favicon','repeat no-op','independent second app survives core replacement','routine backup/restore locking','damaged rollback archive refusal','saved image/data restore after original image/volume removal','rollback repeat no-op','backend container and state untouched','Caddy unchanged','published standalone v1.0.0 upgrade without seeded apps'],'liveVmChanged':False,'socialBackend':'Read-only API fixture, no real social connection/post'},indent=2)+'\n')
milestone('PASS: SLICE13 acceptance complete, including archived-image/data recovery and untouched backend/Caddy.')
finally:
command(['docker','rm','-f','-v',name],check=False)