fix: bound the credential maintenance pass and gate the gitlab picker on a license

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75
This commit is contained in:
hugocasa
2026-09-03 12:29:15 +02:00
co-authored by Claude Opus 5
parent 3ac9fd23e3
commit 1b21708261
5 changed files with 42 additions and 6 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id, ws.git_sync\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id\n WHERE NOT w.deleted\n AND ws.git_sync IS NOT NULL\n AND jsonb_typeof(ws.git_sync->'repositories') = 'array'",
"query": "SELECT ws.workspace_id, ws.git_sync\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id\n WHERE NOT w.deleted\n AND ws.git_sync IS NOT NULL\n AND jsonb_typeof(ws.git_sync->'repositories') = 'array'\n ORDER BY (\n SELECT min((elem->'credential'->>'checked_at')::bigint)\n FROM jsonb_array_elements(ws.git_sync->'repositories') AS elem\n ) ASC NULLS FIRST",
"describe": {
"columns": [
{
@@ -22,5 +22,5 @@
true
]
},
"hash": "ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b"
"hash": "2184f787e6dffc76bfc28f2f2cefc244505996be71d9caec9c6d69e32a9c3d2c"
}
+1 -1
View File
@@ -1 +1 @@
03ef62314a7aa2a1ff4da23459a8fbf3753f495b
e38325c9bfd083b3b3308ee5ca96edbcbe308ae3
+31 -1
View File
@@ -4618,6 +4618,17 @@ const AUTO_PULL_POLL_SLACK_S: i64 = 30;
#[cfg(all(feature = "enterprise", feature = "private"))]
const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923;
/// Wall-clock budget for one credential maintenance pass, spent between
/// repositories rather than inside one.
///
/// `monitor_db` cancels every future in its `join!` at 600s, and an unreachable
/// GitLab costs a repository up to the client's 20s timeout, so enough of them
/// would take the whole pass — and the other maintenance futures with it. A
/// rotation must never be cancelled between GitLab issuing a token and Windmill
/// storing it, so the pass stops at a repository boundary instead and the
/// least-recently-checked ordering brings the rest along on the next tick.
const GIT_CREDENTIAL_PASS_BUDGET: Duration = Duration::from_secs(180);
/// Refresh every git-sync repository's credential status and rotate the ones near
/// expiry, so a token dies visibly (and usually not at all) rather than taking
/// sync down on its expiry date.
@@ -4670,17 +4681,27 @@ async fn maintain_git_credentials_inner(db: &Pool<Postgres>) -> error::Result<()
// Same deleted/archived exclusion as the auto-pull poller: a dead workspace's
// settings row survives, and rotating a token for one would be pure damage.
// Least-recently-checked first, so a pass that runs out of budget resumes
// where it stopped instead of re-checking the same head of the list forever.
// A repository with no recorded credential sorts first and costs only a
// database round trip: it has no token to introspect.
let rows = sqlx::query!(
r#"SELECT ws.workspace_id, ws.git_sync
FROM workspace_settings ws
JOIN workspace w ON w.id = ws.workspace_id
WHERE NOT w.deleted
AND ws.git_sync IS NOT NULL
AND jsonb_typeof(ws.git_sync->'repositories') = 'array'"#
AND jsonb_typeof(ws.git_sync->'repositories') = 'array'
ORDER BY (
SELECT min((elem->'credential'->>'checked_at')::bigint)
FROM jsonb_array_elements(ws.git_sync->'repositories') AS elem
) ASC NULLS FIRST"#
)
.fetch_all(db)
.await?;
let started = Instant::now();
let mut skipped = 0usize;
for row in rows {
let Some(git_sync) = row.git_sync else {
continue;
@@ -4697,6 +4718,10 @@ async fn maintain_git_credentials_inner(db: &Pool<Postgres>) -> error::Result<()
};
for repo in &settings.repositories {
if started.elapsed() >= GIT_CREDENTIAL_PASS_BUDGET {
skipped += 1;
continue;
}
let path = &repo.git_repo_resource_path;
// This refreshes and records the status on every repository it looks at,
// rotating only the ones near expiry, so it is the whole maintenance pass
@@ -4715,6 +4740,11 @@ async fn maintain_git_credentials_inner(db: &Pool<Postgres>) -> error::Result<()
}
}
}
if skipped > 0 {
tracing::info!(
"git credentials: pass budget reached, {skipped} repositories deferred to the next pass"
);
}
Ok(())
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { workspaceStore, userStore } from '$lib/stores'
import { workspaceStore, userStore, enterpriseLicense } from '$lib/stores'
import { GitSyncService, VariableService, type GitlabProject } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Popover from './meltComponents/Popover.svelte'
@@ -27,9 +27,12 @@
let applying = $state(false)
let listError: string | undefined = $state(undefined)
// The project listing is served by an enterprise-only route, so on a build
// without it the button would open a form whose first request 404s.
let show = $derived(
resourceType === 'git_repository' &&
!!$workspaceStore &&
!!$enterpriseLicense &&
($userStore?.is_admin || $userStore?.is_super_admin)
)
@@ -617,9 +617,12 @@
<div class="text-xs text-secondary">
{#if credentialDaysLeft === undefined}
Repository token does not expire.
{:else if repo.credential.rotatable}
{:else if repo.credential.rotatable && $enterpriseLicense}
Repository token expires on {repo.credential.expires_at}, and Windmill renews it
automatically.
{:else if repo.credential.rotatable}
Repository token expires on {repo.credential.expires_at}. Renewing it automatically
requires an enterprise license.
{:else}
Repository token expires on {repo.credential.expires_at}. Give it the api or self_rotate
scope to let Windmill renew it automatically.