Repository navigation
Conversation
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
chore: stop tracking local-only files
fix(team): let project members list the project's servers
fix(health): check Vault with the same TLS settings as the Vault client
CloudConnector for member fixes
…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
Feature/team member installations
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
Fix/loopback port bindings
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
Feature/admin bake endpoint
/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
Feature/service runtime fields
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release dev to main: 20 merged PRs, 55 commits.
Managed server billing for Quick Deploy
Team seats
Other
Deploy notes
INTERNAL_SERVICES_ACCESS_KEYandSTACKER_SERVICE_TOKENin the environment. The internal managed-usage routes and the sweeper use them.🤖 Generated with Claude Code
https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f