Skip to content

Dev - #298

Open
vsilent wants to merge 55 commits into
mainfrom
dev
Open

Dev#298
vsilent wants to merge 55 commits into
mainfrom
dev

Conversation

@vsilent

@vsilent vsilent commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Release dev to main: 20 merged PRs, 55 commits.

Managed server billing for Quick Deploy

Team seats

Other

Deploy notes

  • Migrations run on container start (entrypoint): managed_usage, its Casbin rule, and the renamed casbin_admin_template_update.
  • Requires INTERNAL_SERVICES_ACCESS_KEY and STACKER_SERVICE_TOKEN in the environment. The internal managed-usage routes and the sweeper use them.
  • Verified end to end on dev 2026-10-17: Quick Deploy with a card, usage, Stripe meter, delete, suspend, and the month-end invoice with Stripe test clocks.

🤖 Generated with Claude Code

https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f

vsilent and others added 30 commits October 13, 2026 09:35
feat(team): share projects with members and invite by email
local-only-settings/ and .claude/ are local working files and were
committed by mistake (local-only-settings/ in 1ab5a95, .claude/agents in
438a6b5). Remove them from the repository and ignore local-only-settings/;
.claude/ was already ignored. The files stay on disk for whoever uses them.

Checked before removing: no credentials in either directory, only local
permission settings, placeholders such as ${DB_PASSWORD}, test values and
documentation IP ranges, so nothing needs rotating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The Vault health check built a bare HTTP client, without the private CA
(VAULT_CACERT) or the mTLS identity the Vault client uses. Against a Vault
behind a private CA it reported "unable to get local issuer certificate"
forever, while every real Vault call succeeded. Deployments that reach
Vault over an internal address were not affected, which is why only one
environment showed it.

The client setup (mTLS identity and CA) moves unchanged from
VaultClient::new into helpers::vault::http_client_builder, used by both
the client and the health check.

Test: tests/vault_health_tls.rs creates a private CA and a server
certificate with openssl, serves HTTPS with openssl s_server, and checks
that the health check reads Vault healthy with the CA configured and still
reports the TLS failure without it (skipped where openssl is missing).
Red on the old health check, green with the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
GET /server/project/{id} still checked for the owner only, so a member on
the Server step got "Could not load servers". It now uses the project
role guard: the owner and members get the servers they deploy to, a viewer
gets 403, anyone else the same 404 as for a missing project.

Tests in project_team_seats: member sees the owner's server, viewer 403,
outsider 404. The IDOR sweep and the route matrix still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
fix(team): let project members list the project's servers
fix(health): check Vault with the same TLS settings as the Vault client
…populated

marketplace_event and marketplace_template_event were never written.
Detail views and verified deploy completions now insert events; the
unauthenticated deploy counter does not, to avoid forged analytics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sent

Seeds the agreement, adds GET /api/admin/chat/recent and
GET /api/agreement/name/{name} with casbin rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fix: marketplace event wiring + admin AI chat analysis (consent-gated)
GET /api/v1/deployments/{hash}/member-access?user_id=... answers
owner/member/viewer/null for the deployment's project, so the User Service
can let a Team project member operate the owner's installation (agent
install, add-app, redeploy). Service token only, like the other
service-to-service routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
A project member deploys as the owner, so the install payload carried only
the owner's address and the member who started the deployment never got
the report. The payload now carries initiator_email for deploy, deploy with
a saved cloud and rollback by a member. It is set by the server only:
Payload is read from client-supplied project metadata, so the field is
never deserialized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The deployment report carries the server's SSH key and passwords. A member
who deploys now gets it only when the owner turned on server access for
members: project.members_server_access, off by default, set through
PATCH /project/{id}/server-access (owner only) and shown in /seats.
Without it the report goes to the owner only, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
A contract test across the CLI and the server: a stacker.yml with ports
bound to 127.0.0.1 goes through the CLI's build_project_body and then
through derive_public_ports_from_metadata and the compose port conversion.
Each half had its own unit test and the bug kept coming back: the CLI drops
the host IP, the server expects it with the mapping, and databases bound to
127.0.0.1 ended up open in the firewall and published on every interface.

Fails on the current code: 5432, 6379 and 8025 reach the firewall and none
is published on 127.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The CLI sent "127.0.0.1:5432:5432" as host_port 5432 / container_port 5432,
so the server never saw the loopback binding: it opened 5432 in the cloud
firewall and the compose file published it on every interface. The CLI now
sends the whole mapping in container_port when it carries a host IP, the
form the server already parses (forms::project::port normalize_port_mapping)
for the firewall and for compose. parse_port_mapping itself is unchanged.

Regression: routes::project::deploy::loopback_ports_contract (previous
commit) now passes; lib 2238 passed, BDD 334 scenarios passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
strip_generated_field_values blanks every value of a generated/provided
field before a template is stored, so the author's secrets never ship. It
also blanked values that only name another variable, like
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}. That reference is how one
generated secret feeds two services; without it each key is regenerated
on its own and the app and MinIO get different values.

A value that is exactly ${NAME} or $NAME is now kept, in all five shapes
the stripper handles (JSON map, JSON key/value pair, JSON and YAML K=V
lists, YAML map). ${NAME:-default} is still blanked: the default can be
the secret itself.

Tests: helpers::redact strip_*_keeps_references_to_other_variables (fail
before the fix). lib 2240 passed, marketplace and field-policy integration
64 passed, BDD 334 scenarios passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
…plates

Keep ${VAR} references when stripping generated secrets from templates
bake turns a deployed build box into a Quick Deploy snapshot. It was not in
the image, so every bake meant compiling it by hand and `docker cp` into the
running container. CI now compiles it with the other release binaries,
archives and unpacks it, and both Dockerfile paths (prebuilt from CI and the
local builder stage) copy it to /app/bake.

tests/image_binaries_contract.rs checks every image binary on both paths
(failed for bake before this change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
POST /api/admin/bake {deployment_id, stack, health_url} bakes a deployed
build box into a Quick Deploy snapshot; GET /api/admin/bake/{id} reports
the job. Admins only (group_admin, admin_service), like template approval.

What the bake binary needs typed by hand comes from stacker itself: the
server row of the deployment's project (IP, SSH user and port), its SSH
key in Vault, and the latest approved version of the template with its
config_contract. Refused at once, with no job recorded and the box
untouched: unknown deployment, not exactly one server, a template that is
not approved, no or unusable contract, a health URL that is not on the
box, a box Hetzner does not know in the platform's project, a bake of the
same box already running.

The job (202 + id, status in the new bake_job table) then checks the
health URL, finds the stack's directory with `docker compose ls` and
requires every service up (`docker compose ps`), and runs the existing
chain unchanged: finalize_build_box, run_bake, baked_snapshot::record.
The build box is never removed.

Tests: helpers::bake_job (7, written failing first); tests/admin_bake.rs
(refusals, accepted job, status, non-admin refused; a mutation that drops
the Hetzner lookup fails it). lib + route matrix 2270 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
/me reports one role in no fixed order; a user with creator and root
could come out as creator and be refused admin routes. The User Service
now also lists every role in `roles`; root among them is the role
Casbin checks. A /me without `roles` behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Treat a user with the root role as admin whichever role /me lists first
The column came with migration 20261014120000 and is read and written by
its own queries, but the three SELECT * project queries were prepared
before it existed. Preparing them against a migrated database fails to
compile (no field members_server_access), so no sqlx query cache could
be regenerated. The field is kept out of API bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
A service can now say how it runs: restart ("no" for a job that runs
once), entrypoint, compose profiles, network aliases, deploy.replicas
and deploy.resources.reservations.devices (GPUs). Together they let one
stack carry CPU and GPU variants of a service under one name.

Every declared setting reaches each compose stacker writes from the
service, per shared-fixtures stack-service-runtime-fields: the CLI
compose, the author's own compose (deploy.compose_file), the compose the
server renders from the registered project, and the one the
status-panel agent applies. Tracing those paths also found settings
that were silently dropped:

- registering a project sent no depends_on, cap_add, cap_drop or
  devices, and the server form had no healthcheck, so a deploy started
  outside the CLI lost them;
- project_app rows never got depends_on or healthcheck, and had no
  place for capabilities or devices (new runtime JSONB column);
- the agent's compose never rendered command or entrypoint: the
  template asked for them but the render context did not carry them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
990b697 inserted a test between an existing #[test] and its function:
the new test got two #[test] attributes (a duplicated-attribute warning)
and the old one none, so it silently stopped running. It passes against
the current code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
robotizeit and others added 25 commits October 15, 2026 22:14
Every `deploy --force-new` leaves a server record in the project, and
Hetzner hands a new box an old box's IP, so a project soon holds several
servers and POST /api/admin/bake refused them all. The request now takes
an optional server_id - the Hetzner id, as the bake binary takes it - and
uses the newest record at the IP where Hetzner has that server. Nothing
is touched unless Hetzner confirms the id at one of the project's IPs; a
single-server project behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The bake stores the build box server type on the snapshot.
POST /api/v1/deploy/validate returns quick_deploy {server_type, rates}: the
type of the snapshot a clone of the stack uses, and every type daily rate
and monthly cap, so the price shown matches the type deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Run parameterize_replaces_secret_values_with_env_refs again
Bake: pick the build box by its Hetzner server_id
Record the bake server type; validate returns it with the rates
local-ai-packaged writes replicas: ${OLLAMA_CPU_REPLICAS}. POST
/api/v1/deploy/validate reads stacker.yml without filling variables, so
the reference arrived as the literal string and was refused with
"replicas must be a whole number, 0 or more". The same happened to every
CLI command that reads stacker.yml raw and writes it back.

A count is now a number or a ${VAR} reference (${NAME}, ${NAME:-1} and
the other compose modifiers, $NAME). Read raw, the reference is kept and
written to the generated compose and back to stacker.yml as is; filled,
it is the number. Anything else is still refused. A server-rendered
compose only carries a known number, so a reference is left out there.

Tests (fail before the fix): oneclick_deploy
replicas_from_an_env_reference_is_valid, stack_service_runtime_fields
a_count_written_as_a_variable_reference_is_kept. nextest 2879 passed,
36 skipped; BDD 334 scenarios passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The CLI fills ${VAR} before it reads stacker.yml, and the filled value is
a string. Every boolean and number refused it: privileged: ${PRIV} with
PRIV=false failed with "invalid type: string \"false\", expected a
boolean", and the same for healthcheck.retries, proxy.auto_detect,
deploy.server.port, ai.enabled/timeout, monitoring.status_panel,
metrics.enabled/telegraf, alerts.interval/on_recovery, hooks.notify and
the pipe retry settings. They now also read "true"/"false" and a whole
number written as a string; anything else is still refused.

Deploy validation reads stacker.yml without filling variables. A port
mapping with a variable in it ("${APP_PORT:-8080}:80",
"127.0.0.1:${DB_PORT}:5432") or a public port "${PUBLIC_PORT}/tcp" failed
E007/E005. The variable part is now let through and the rest of the
entry is still checked.

Still refused when unfilled: a boolean, a number other than replicas or a
device count, or a fixed-choice value written as ${VAR}.

Tests (fail before the fix): stacker_yml_variable_references. nextest
2883 passed, 36 skipped; BDD 334 scenarios passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Accept ${VAR} references in typed stacker.yml fields
Pure functions for Quick Deploy's metered Managed server
(config: shared-fixtures/api-contracts/managed-server-billing.json):

- resolve_daily_rate: the higher of the template's daily_rate and the
  server type's rate; the cap goes with the winning rate (a template
  without a cap: 30 days of its rate); a server type without a rate is
  refused.
- next_report: the earliest unreported day up to today (or the stop day),
  never more than what is left of the month's cap, the counter restarting
  on the 1st; missed days are caught up in order; days past the cap are
  marked with nothing sent.
- identifier: <deployment_hash>:<YYYY-MM-DD>.

Tests: services::managed_usage, 12 (red against stubs first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The clone access gate refused Free and Starter with "Marketplace installs
are available on Pro and Team plans" before they reached the card step.
With the Managed server now billed to every buyer, the plan has no reason
to gate Quick Deploy. AllowCardOnFile (used only by clone) skips the Pro
feature plan; a template's own required plan, ownership and the card for
a priced template are checked as before. RequireOwnership (CLI,
marketplace install, project deploy) keeps Pro.

Tests (red before the change): deferred_mode_lets_a_free_plan_user_deploy_
a_free_template, ..._asks_a_free_plan_user_for_a_card_on_a_priced_template,
..._still_requires_the_templates_own_plan; ownership_mode_still_requires_
the_pro_feature_plan guards the other paths. marketplace_access 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Two calls stacker makes for Quick Deploy's metered Managed server
(contract: shared-fixtures/api-contracts/managed-server-billing.json),
both with the service token, non-2xx mapped like the other billing calls:

- report_managed_usage -> POST /api/1.0/marketplace/billing/managed/usage
  (one day: user_id, deployment_hash, value_minor, identifier, timestamp)
- record_managed_billing_event -> POST .../managed/event
  (one managed_billing_events row)

Implemented on the client, the mock and the access-gate test double.

Tests (red against a stub): connectors::user_service::tests
report_managed_usage_posts_the_day_with_the_service_token,
report_managed_usage_without_a_card_is_payment_required,
record_managed_billing_event_posts_one_event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
The rate and cap a deployment is billed at, its lifecycle (pending,
active, stopped, suspended, closed) and how far the daily report to the
Stripe meter got (period_start, period_reported_minor, last_reported_on).
marketplace_install_authorization does not fit: it requires a template
and a payment-intent hold.

db::managed_usage: insert_pending, start (pending -> active), close
(deploy failed), stop (server deleted), suspend_for_user (unpaid
invoice), list_due (running servers, stopped / suspended ones up to the
day they stopped), record_day (refuses a day already recorded),
list_suspended_before, close_deleted_unpaid. Compile-time checked
queries; .sqlx updated (10 new query files, no casbin_rule files).

Tests (red against stubs): tests/managed_usage_db.rs, 6 passed, 0 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Before any record is created, clone now resolves what the Managed server
is billed at and refuses a deploy that cannot be billed:
- the template must be in the catalog (404 as before) and the lookup
  must succeed (503 instead of carrying on without billing);
- the server type must have a rate: 422 no_server_rate, no 0.87 fallback;
- the rate is the higher of the template's daily_rate and the type's;
- the buyer must have a verified card: 402, whatever the plan.

A pending managed_usage row is recorded just before the server is
created and closed with deploy_failed if the clone fails; usage starts
only when the deploy succeeds. The one-day hold for deployment_daily
templates is gone (it was never captured and expired after 24 hours);
the response's authorization_id is no longer set.

Tests (red against a stub): clone::tests a_server_type_without_a_rate_
is_refused_before_the_card, a_buyer_without_a_card_is_asked_for_one,
a_buyer_with_a_card_is_billed_at_the_resolved_rate. lib 2275 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Called by the User Service (contract: shared-fixtures/api-contracts/
managed-server-billing.json), authorised by require_internal_key
(x-internal-key) and admitted in Casbin for group_anonymous, like
agent/register:

- POST /api/internal/managed-usage/{deployment_hash}/start: the deploy
  succeeded, pending -> active (404 unknown, 409 not pending);
- POST /api/internal/managed-usage/{deployment_hash}/fail: the deploy
  failed, pending -> closed / deploy_failed;
- POST /api/internal/managed-usage/suspend {user_id}: the user's invoice
  is unpaid after Stripe's retries, every active server of the user is
  suspended (the invoice is per customer).

security_route_matrix classifies the three routes as Token / internal
service key.

Tests (red before the routes existed): tests/managed_usage_internal.rs,
4 passed, 0 skipped; security_route_matrix every_route_is_classified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
… VMs to admins

Nothing deletes a Quick Deploy VM in Hetzner (stacker's connector has no
delete; DELETE /server/{id} removes only stacker's record). Agreed
2026-10-16: usage stops when the buyer deletes the deployment and admins
delete the VM by hand.

- managed_usage.server_id: the Hetzner server id, recorded by clone right
  after it creates the VM.
- POST /api/internal/managed-usage/{deployment_hash}/stop (internal key,
  Casbin group_anonymous): active or suspended -> stopped, returns the
  server id for the admin alert.
- mark_delete_due replaces close_deleted_unpaid: after 3 days of
  suspension the row is closed with server_delete_due; nothing is deleted.

Tests: managed_usage_db 7, managed_usage_internal 5,
security_route_matrix; 17 passed, 0 skipped. .sqlx updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
…tainers

- services::managed_usage_sweeper, started with the server: every 10
  minutes it sends each day not yet reported (running servers up to
  today, deleted or suspended ones up to their stop day) as one meter
  event through the User Service; missed days are caught up in order,
  past days stamped late in their own day, days older than Stripe's
  35-day window skipped with an error; a failed send is not marked and
  is retried. A server suspended for 3 days is handed to the admins: a
  server_delete_due event with its Hetzner server id, then the row is
  closed. Nothing deletes a VM.
- managed-usage/suspend now also queues a stop for every app of each
  suspended deployment: containers stop, data stays.

Tests (red against a stub / before the change): managed_usage_sweeper 5,
managed_usage_internal suspend_queues_a_stop_for_every_container_of_the_
deployment. Full suite: nextest 2923 passed, 36 skipped; BDD 334
scenarios passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
20261016120000 was used twice: baked_snapshot_server_type (2b9eb5f, in
dev since #291 and applied on dev) and casbin_admin_template_update
(adb1779). sqlx keys _sqlx_migrations by version, so every fresh
database failed with a duplicate key on _sqlx_migrations_pkey and the
integration tests could not start the test app; a database that already
applied one of them refuses the other at boot.

casbin_admin_template_update moves to 20261016191200 (its commit time).
It only inserts Casbin rules with ON CONFLICT DO NOTHING, so applying it
under the new version is safe wherever it already ran.

Regression test (red on dev): migration_boot_contract
every_migration_version_is_used_once. migration_boot_contract 11 passed;
admin_jwt, admin_notifications_bell, agent_command_flow (failing in CI)
21 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
Give casbin_admin_template_update its own migration version
Quick Deploy: metered Managed server billing, no plan gate
The Docker CICD build job runs check, tests and clippy before the release
build on the same runner. Since the managed usage tests were added,
target/debug leaves too little disk for the release build and it fails with
"No space left on device" (run 38951715876, twice). Nothing after the
tests reads the debug artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
CI: free target/debug before the release build
…ed snapshot

Found on dev 2026-10-17 (linkding: stacker.yml 1.27.0, snapshot 1.0.0).
Logged as not confirmed; no code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f
…match

BUGS: Quick Deploy 404s on a stacker.yml / snapshot version mismatch
Comment on lines +15 to +17
let mut req = reqwest::Client::new().get(format!(
"{address}/api/v1/deployments/{hash}/member-access?user_id={user_id}"
));

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants