mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
@@ -23,6 +23,7 @@ sidebar: false
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;部署历史会保留安全的来源快照。
|
||||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
|
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
+2
-2
@@ -8879,7 +8879,7 @@ const docTemplate = `{
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
|
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -9112,7 +9112,7 @@ const docTemplate = `{
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
|
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -211,9 +211,16 @@ of_pages_projects
|
|||||||
GitHub:
|
GitHub:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SHA-256("github|" + owner_repo + "|" + selector + "|" + tag + "|" + asset_name)
|
LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
|
||||||
|
|
||||||
|
SHA-256(
|
||||||
|
"openflare:pages:github-release:v2" ||
|
||||||
|
LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
|
||||||
|
|
||||||
Remote:
|
Remote:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+2
-2
@@ -8872,7 +8872,7 @@
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
|
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -9105,7 +9105,7 @@
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
|
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-2
@@ -9468,7 +9468,7 @@ paths:
|
|||||||
- openflare-pages
|
- openflare-pages
|
||||||
/api/v1/d/pages/{id}/source/check:
|
/api/v1/d/pages/{id}/source/check:
|
||||||
post:
|
post:
|
||||||
description: Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
|
description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||||
parameters:
|
parameters:
|
||||||
- description: 项目 ID
|
- description: 项目 ID
|
||||||
in: path
|
in: path
|
||||||
@@ -9611,7 +9611,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
|
description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||||
parameters:
|
parameters:
|
||||||
- description: 项目 ID
|
- description: 项目 ID
|
||||||
in: path
|
in: path
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
|
|||||||
manual_upload: '本地上传',
|
manual_upload: '本地上传',
|
||||||
manual_url: 'URL 导入',
|
manual_url: 'URL 导入',
|
||||||
remote_url: 'Remote URL',
|
remote_url: 'Remote URL',
|
||||||
github_release: 'GitHub Release',
|
github_release: 'GitHub',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
||||||
@@ -172,7 +172,7 @@ export function DeploymentHistory({
|
|||||||
) : deployments.length === 0 ? (
|
) : deployments.length === 0 ? (
|
||||||
<EmptyStateWithBorder
|
<EmptyStateWithBorder
|
||||||
title='暂无部署'
|
title='暂无部署'
|
||||||
description='上传本地部署包,或配置 Remote URL 来源后同步发布。'
|
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
deployments.map((deployment) => {
|
deployments.map((deployment) => {
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Download, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
|
import {
|
||||||
|
Download,
|
||||||
|
Github,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -21,12 +38,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { AdminTaskService } from '@/lib/services/admin';
|
import { AdminTaskService } from '@/lib/services/admin';
|
||||||
import {
|
import {
|
||||||
type PagesSource,
|
type PagesSourceActionPayload,
|
||||||
type PagesSourceActionReceipt,
|
type PagesSourceActionReceipt,
|
||||||
type PagesSourceStatus,
|
type PagesSourceStatus,
|
||||||
PagesService,
|
PagesService,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deploymentsQueryKey,
|
deploymentsQueryKey,
|
||||||
@@ -34,7 +50,11 @@ import {
|
|||||||
projectsQueryKey,
|
projectsQueryKey,
|
||||||
sourceQueryKey,
|
sourceQueryKey,
|
||||||
} from '../../components/pages-utils';
|
} from '../../components/pages-utils';
|
||||||
import { PagesSourceDialog } from './pages-source-dialog';
|
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
|
||||||
|
import {
|
||||||
|
GitHubSourceDetails,
|
||||||
|
RemoteSourceDetails,
|
||||||
|
} from './pages-source-details';
|
||||||
|
|
||||||
const ACTION_POLL_INTERVAL = 2_000;
|
const ACTION_POLL_INTERVAL = 2_000;
|
||||||
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
|
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
|
||||||
@@ -59,10 +79,8 @@ interface ActiveAction {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function revisionSummary(source: PagesSource) {
|
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||||
if (source.source_type === 'manual' || !source.last_applied)
|
return action === 'check' ? '检查' : '同步并发布';
|
||||||
return '尚未应用';
|
|
||||||
return `${source.last_applied.label} · ${source.last_applied.revision.slice(0, 12)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PagesSourceCard({ projectId }: { projectId: number }) {
|
export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||||
@@ -70,11 +88,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
const handledExecutionID = useRef<string | null>(null);
|
const handledExecutionID = useRef<string | null>(null);
|
||||||
const sourcePollingStartedAt = useRef<number | null>(null);
|
const sourcePollingStartedAt = useRef<number | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>(
|
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
||||||
'manual',
|
|
||||||
);
|
|
||||||
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
||||||
const [actionTimedOut, setActionTimedOut] = useState(false);
|
const [actionTimedOut, setActionTimedOut] = useState(false);
|
||||||
|
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
|
||||||
|
|
||||||
const sourceQuery = useQuery({
|
const sourceQuery = useQuery({
|
||||||
queryKey: sourceQueryKey(projectId),
|
queryKey: sourceQueryKey(projectId),
|
||||||
@@ -102,8 +119,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
'task-execution',
|
'task-execution',
|
||||||
activeAction?.receipt.execution_id ?? '',
|
activeAction?.receipt.execution_id ?? '',
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
AdminTaskService.getTaskExecution(activeAction!.receipt.execution_id),
|
const executionID = activeAction?.receipt.execution_id;
|
||||||
|
if (!executionID) throw new Error('缺少任务执行 ID');
|
||||||
|
return AdminTaskService.getTaskExecution(executionID);
|
||||||
|
},
|
||||||
enabled: Boolean(activeAction) && !actionTimedOut,
|
enabled: Boolean(activeAction) && !actionTimedOut,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
if (actionTimedOut) return false;
|
if (actionTimedOut) return false;
|
||||||
@@ -114,6 +134,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const beginActionPolling = useCallback(
|
||||||
|
(receipt: PagesSourceActionReceipt) => {
|
||||||
|
handledExecutionID.current = null;
|
||||||
|
setActiveAction({ receipt, startedAt: Date.now() });
|
||||||
|
setActionTimedOut(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeAction || actionTimedOut) return;
|
if (!activeAction || actionTimedOut) return;
|
||||||
const elapsed = Date.now() - activeAction.startedAt;
|
const elapsed = Date.now() - activeAction.startedAt;
|
||||||
@@ -124,7 +153,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const execution = executionQuery.data;
|
const execution = executionQuery.data;
|
||||||
if (!execution || !['succeeded', 'failed'].includes(execution.status)) {
|
if (
|
||||||
|
!activeAction ||
|
||||||
|
!execution ||
|
||||||
|
!['succeeded', 'failed'].includes(execution.status)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handledExecutionID.current === execution.id) return;
|
if (handledExecutionID.current === execution.id) return;
|
||||||
@@ -142,21 +175,36 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||||
if (execution.status === 'succeeded') {
|
if (execution.status === 'succeeded') {
|
||||||
toast.success('部署源同步并发布完成');
|
toast.success(`部署源${actionLabel}完成`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(execution.error_message || '部署源同步失败');
|
toast.error(execution.error_message || `部署源${actionLabel}失败`);
|
||||||
}
|
}
|
||||||
setActiveAction(null);
|
setActiveAction(null);
|
||||||
setActionTimedOut(false);
|
setActionTimedOut(false);
|
||||||
}, [executionQuery.data, projectId, queryClient]);
|
}, [activeAction, executionQuery.data, projectId, queryClient]);
|
||||||
|
|
||||||
|
const checkMutation = useMutation({
|
||||||
|
mutationFn: () => PagesService.checkSource(projectId),
|
||||||
|
onSuccess: async (receipt) => {
|
||||||
|
beginActionPolling(receipt);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: sourceQueryKey(projectId),
|
||||||
|
});
|
||||||
|
toast.success('检查任务已提交');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: () => PagesService.syncSource(projectId, {}),
|
mutationFn: (payload: PagesSourceActionPayload) =>
|
||||||
|
PagesService.syncSource(projectId, payload),
|
||||||
onSuccess: async (receipt) => {
|
onSuccess: async (receipt) => {
|
||||||
handledExecutionID.current = null;
|
setAttentionDialogOpen(false);
|
||||||
setActiveAction({ receipt, startedAt: Date.now() });
|
beginActionPolling(receipt);
|
||||||
setActionTimedOut(false);
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: sourceQueryKey(projectId),
|
queryKey: sourceQueryKey(projectId),
|
||||||
});
|
});
|
||||||
@@ -176,13 +224,38 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
const sourceBusy =
|
const sourceBusy =
|
||||||
source?.source_type !== 'manual' &&
|
source?.source_type !== 'manual' &&
|
||||||
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
|
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
|
||||||
const actionsDisabled = syncMutation.isPending || executionBusy || sourceBusy;
|
const actionsDisabled =
|
||||||
|
checkMutation.isPending ||
|
||||||
|
syncMutation.isPending ||
|
||||||
|
executionBusy ||
|
||||||
|
sourceBusy;
|
||||||
|
const checkBusy =
|
||||||
|
checkMutation.isPending ||
|
||||||
|
(executionBusy && activeAction?.receipt.action === 'check') ||
|
||||||
|
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
|
||||||
|
const syncBusy =
|
||||||
|
syncMutation.isPending ||
|
||||||
|
(executionBusy && activeAction?.receipt.action === 'sync') ||
|
||||||
|
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
|
||||||
|
const dispatchError = checkMutation.error ?? syncMutation.error;
|
||||||
|
|
||||||
const openSourceDialog = (mode: 'manual' | 'remote_url') => {
|
const openSourceDialog = (mode: PagesSourceMode) => {
|
||||||
setDialogMode(mode);
|
setDialogMode(mode);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dispatchSync = () => {
|
||||||
|
checkMutation.reset();
|
||||||
|
if (
|
||||||
|
source?.source_type === 'github_release' &&
|
||||||
|
source.sync_status === 'attention'
|
||||||
|
) {
|
||||||
|
setAttentionDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncMutation.mutate({});
|
||||||
|
};
|
||||||
|
|
||||||
if (sourceQuery.isLoading) {
|
if (sourceQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -219,10 +292,22 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveSourceStatus = executionBusy
|
||||||
|
? activeAction?.receipt.action === 'check'
|
||||||
|
? 'checking'
|
||||||
|
: 'syncing'
|
||||||
|
: source.source_type === 'manual'
|
||||||
|
? undefined
|
||||||
|
: (source.sync_status ?? 'idle');
|
||||||
const status =
|
const status =
|
||||||
source.source_type === 'manual'
|
source.source_type === 'manual'
|
||||||
? null
|
? null
|
||||||
: SOURCE_STATUS[source.sync_status ?? 'idle'];
|
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
|
||||||
|
const attentionRevision =
|
||||||
|
source.source_type === 'github_release' &&
|
||||||
|
source.sync_status === 'attention'
|
||||||
|
? source.last_seen
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -250,47 +335,20 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : source.source_type === 'remote_url' ? (
|
) : source.source_type === 'remote_url' ? (
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<RemoteSourceDetails source={source} />
|
||||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
|
||||||
<span className='text-xs text-muted-foreground'>脱敏地址</span>
|
|
||||||
<code className='truncate text-sm'>{source.display_url}</code>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
|
||||||
<span className='text-xs text-muted-foreground'>网络策略</span>
|
|
||||||
<span className='text-sm font-medium'>
|
|
||||||
{source.remote_network_policy === 'trusted_internal'
|
|
||||||
? '受信内网模式'
|
|
||||||
: '公网安全模式'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
|
||||||
<span className='text-xs text-muted-foreground'>最近同步</span>
|
|
||||||
<span className='text-sm font-medium'>
|
|
||||||
{source.last_synced_at
|
|
||||||
? formatDateTime(source.last_synced_at)
|
|
||||||
: '尚未同步'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
|
||||||
<span className='text-xs text-muted-foreground'>
|
|
||||||
已应用 revision
|
|
||||||
</span>
|
|
||||||
<span className='font-mono text-sm'>
|
|
||||||
{revisionSummary(source)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{source.last_error ? (
|
|
||||||
<div className='md:col-span-2'>
|
|
||||||
<ErrorInline message={source.last_error} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className='rounded-lg border p-4 text-sm text-muted-foreground'>
|
<GitHubSourceDetails source={source} />
|
||||||
当前版本暂不提供该来源类型的编辑界面。
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dispatchError ? (
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
dispatchError instanceof Error
|
||||||
|
? dispatchError.message
|
||||||
|
: '来源任务提交失败'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{executionQuery.isError ? (
|
{executionQuery.isError ? (
|
||||||
<ErrorInline
|
<ErrorInline
|
||||||
message={
|
message={
|
||||||
@@ -327,30 +385,61 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
|
|
||||||
<CardFooter className='flex flex-wrap gap-2 border-t'>
|
<CardFooter className='flex flex-wrap gap-2 border-t'>
|
||||||
{source.source_type === 'manual' ? (
|
{source.source_type === 'manual' ? (
|
||||||
<Button
|
<>
|
||||||
type='button'
|
<Button
|
||||||
onClick={() => openSourceDialog('remote_url')}
|
type='button'
|
||||||
>
|
variant='outline'
|
||||||
<Download data-icon='inline-start' />
|
onClick={() => openSourceDialog('remote_url')}
|
||||||
配置 Remote URL
|
>
|
||||||
</Button>
|
<Download data-icon='inline-start' />
|
||||||
) : source.source_type === 'remote_url' ? (
|
配置 Remote URL
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
onClick={() => openSourceDialog('github_release')}
|
||||||
|
>
|
||||||
|
<Github data-icon='inline-start' />
|
||||||
|
配置 GitHub Release
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
onClick={() => openSourceDialog('remote_url')}
|
onClick={() => openSourceDialog(source.source_type)}
|
||||||
>
|
>
|
||||||
<Pencil data-icon='inline-start' />
|
<Pencil data-icon='inline-start' />
|
||||||
编辑来源
|
编辑来源
|
||||||
</Button>
|
</Button>
|
||||||
|
{source.source_type === 'github_release' ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
syncMutation.reset();
|
||||||
|
checkMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checkBusy ? (
|
||||||
|
<Spinner data-icon='inline-start' />
|
||||||
|
) : (
|
||||||
|
<Search data-icon='inline-start' />
|
||||||
|
)}
|
||||||
|
检查更新
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
disabled={actionsDisabled}
|
disabled={
|
||||||
onClick={() => syncMutation.mutate()}
|
actionsDisabled ||
|
||||||
|
(source.sync_status === 'attention' && !attentionRevision)
|
||||||
|
}
|
||||||
|
onClick={dispatchSync}
|
||||||
>
|
>
|
||||||
{actionsDisabled ? (
|
{syncBusy ? (
|
||||||
<Spinner data-icon='inline-start' />
|
<Spinner data-icon='inline-start' />
|
||||||
) : (
|
) : (
|
||||||
<Download data-icon='inline-start' />
|
<Download data-icon='inline-start' />
|
||||||
@@ -367,7 +456,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
切换回手动
|
切换回手动
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -387,7 +476,48 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
source={source}
|
source={source}
|
||||||
initialMode={dialogMode}
|
initialMode={dialogMode}
|
||||||
|
onActionDispatched={beginActionPolling}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={attentionDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!syncMutation.isPending) setAttentionDialogOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
|
||||||
|
<span className='break-all font-mono'>
|
||||||
|
{attentionRevision?.revision ??
|
||||||
|
'当前 revision 已不可用,请刷新后重试'}
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={syncMutation.isPending}>
|
||||||
|
取消
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={syncMutation.isPending || !attentionRevision}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!attentionRevision) return;
|
||||||
|
syncMutation.mutate({
|
||||||
|
confirmed_revision: attentionRevision.revision,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{syncMutation.isPending ? (
|
||||||
|
<Spinner data-icon='inline-start' />
|
||||||
|
) : null}
|
||||||
|
确认并发布
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { TriangleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
type PagesGitHubReleaseSource,
|
||||||
|
type PagesRemoteURLSource,
|
||||||
|
type PagesSourceRevision,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function revisionSummary(revision?: PagesSourceRevision) {
|
||||||
|
if (!revision) return '尚无记录';
|
||||||
|
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteSourceDetails({
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
source: PagesRemoteURLSource;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||||
|
<span className='text-xs text-muted-foreground'>脱敏地址</span>
|
||||||
|
<code className='truncate text-sm'>{source.display_url}</code>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>网络策略</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{source.remote_network_policy === 'trusted_internal'
|
||||||
|
? '受信内网模式'
|
||||||
|
: '公网安全模式'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{source.last_synced_at
|
||||||
|
? formatDateTime(source.last_synced_at)
|
||||||
|
: '尚未同步'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||||
|
<span className='text-xs text-muted-foreground'>已应用 revision</span>
|
||||||
|
<span className='font-mono text-sm'>
|
||||||
|
{revisionSummary(source.last_applied)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{source.last_error ? (
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<ErrorInline message={source.last_error} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubSourceDetails({
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
source: PagesGitHubReleaseSource;
|
||||||
|
}) {
|
||||||
|
const attentionRevision =
|
||||||
|
source.sync_status === 'attention' ? source.last_seen : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{attentionRevision ? (
|
||||||
|
<Alert variant='destructive'>
|
||||||
|
<TriangleAlert />
|
||||||
|
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
当前远端 revision
|
||||||
|
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
|
||||||
|
revision。
|
||||||
|
</p>
|
||||||
|
<code className='break-all'>{attentionRevision.revision}</code>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||||
|
<span className='text-xs text-muted-foreground'>GitHub 仓库</span>
|
||||||
|
<code className='truncate text-sm'>{source.github_repository}</code>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>Release 选择</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{source.release_selector === 'latest'
|
||||||
|
? '最新 Release'
|
||||||
|
: `固定 Tag · ${source.release_tag ?? '未提供'}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
||||||
|
<code className='truncate text-sm'>{source.asset_name}</code>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
||||||
|
<span className='font-mono text-sm'>
|
||||||
|
{revisionSummary(source.last_seen)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>当前已应用</span>
|
||||||
|
<span className='font-mono text-sm'>
|
||||||
|
{revisionSummary(source.last_applied)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>最近检查</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{source.last_checked_at
|
||||||
|
? formatDateTime(source.last_checked_at)
|
||||||
|
: '尚未检查'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
|
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{source.last_synced_at
|
||||||
|
? formatDateTime(source.last_synced_at)
|
||||||
|
: '尚未同步'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{source.last_error ? (
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<ErrorInline message={source.last_error} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|||||||
import {
|
import {
|
||||||
type PagesRemoteNetworkPolicy,
|
type PagesRemoteNetworkPolicy,
|
||||||
type PagesSource,
|
type PagesSource,
|
||||||
|
type PagesSourceActionReceipt,
|
||||||
|
type PagesSourceUpdatePayload,
|
||||||
PagesService,
|
PagesService,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
@@ -45,8 +48,19 @@ import {
|
|||||||
projectsQueryKey,
|
projectsQueryKey,
|
||||||
sourceQueryKey,
|
sourceQueryKey,
|
||||||
} from '../../components/pages-utils';
|
} from '../../components/pages-utils';
|
||||||
|
import {
|
||||||
|
type PagesGitHubSourceFormErrors,
|
||||||
|
type PagesGitHubSourceFormValue,
|
||||||
|
PagesSourceGitHubFields,
|
||||||
|
} from './pages-source-github-fields';
|
||||||
|
import {
|
||||||
|
validGitHubAssetName,
|
||||||
|
validGitHubReleaseTag,
|
||||||
|
validGitHubRepositoryURL,
|
||||||
|
} from './pages-source-validation';
|
||||||
|
|
||||||
|
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
|
||||||
|
|
||||||
type SourceMode = 'manual' | 'remote_url';
|
|
||||||
type Confirmation = 'trusted_internal' | 'manual' | null;
|
type Confirmation = 'trusted_internal' | 'manual' | null;
|
||||||
|
|
||||||
interface PagesSourceDialogProps {
|
interface PagesSourceDialogProps {
|
||||||
@@ -54,7 +68,21 @@ interface PagesSourceDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
source: PagesSource;
|
source: PagesSource;
|
||||||
initialMode?: SourceMode;
|
initialMode?: PagesSourceMode;
|
||||||
|
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GITHUB_ASSET = 'dist.zip';
|
||||||
|
const DEFAULT_GITHUB_CHECK_INTERVAL = 60;
|
||||||
|
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
|
||||||
|
repository: '',
|
||||||
|
releaseTag: '',
|
||||||
|
assetName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function githubRepositoryURL(repository: string) {
|
||||||
|
const value = repository.trim();
|
||||||
|
return value ? `https://github.com/${value}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PagesSourceDialog({
|
export function PagesSourceDialog({
|
||||||
@@ -63,21 +91,33 @@ export function PagesSourceDialog({
|
|||||||
projectId,
|
projectId,
|
||||||
source,
|
source,
|
||||||
initialMode,
|
initialMode,
|
||||||
|
onActionDispatched,
|
||||||
}: PagesSourceDialogProps) {
|
}: PagesSourceDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [mode, setMode] = useState<SourceMode>('manual');
|
const [mode, setMode] = useState<PagesSourceMode>('manual');
|
||||||
const [networkPolicy, setNetworkPolicy] =
|
const [networkPolicy, setNetworkPolicy] =
|
||||||
useState<PagesRemoteNetworkPolicy>('public');
|
useState<PagesRemoteNetworkPolicy>('public');
|
||||||
const [replaceURL, setReplaceURL] = useState(false);
|
const [replaceURL, setReplaceURL] = useState(false);
|
||||||
const [remoteURL, setRemoteURL] = useState('');
|
const [remoteURL, setRemoteURL] = useState('');
|
||||||
|
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
|
||||||
const [urlError, setURLError] = useState('');
|
const [urlError, setURLError] = useState('');
|
||||||
|
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
|
||||||
|
repositoryURL: '',
|
||||||
|
releaseSelector: 'latest',
|
||||||
|
releaseTag: '',
|
||||||
|
assetName: DEFAULT_GITHUB_ASSET,
|
||||||
|
});
|
||||||
|
const [githubErrors, setGitHubErrors] =
|
||||||
|
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
||||||
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) {
|
||||||
const nextMode =
|
setRemoteURL('');
|
||||||
initialMode ??
|
setRevealRemoteURL(false);
|
||||||
(source.source_type === 'remote_url' ? 'remote_url' : 'manual');
|
return;
|
||||||
|
}
|
||||||
|
const nextMode = initialMode ?? source.source_type;
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setNetworkPolicy(
|
setNetworkPolicy(
|
||||||
source.source_type === 'remote_url'
|
source.source_type === 'remote_url'
|
||||||
@@ -86,7 +126,27 @@ export function PagesSourceDialog({
|
|||||||
);
|
);
|
||||||
setReplaceURL(source.source_type !== 'remote_url');
|
setReplaceURL(source.source_type !== 'remote_url');
|
||||||
setRemoteURL('');
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
setURLError('');
|
setURLError('');
|
||||||
|
setGitHubForm({
|
||||||
|
repositoryURL:
|
||||||
|
source.source_type === 'github_release'
|
||||||
|
? githubRepositoryURL(source.github_repository)
|
||||||
|
: '',
|
||||||
|
releaseSelector:
|
||||||
|
source.source_type === 'github_release'
|
||||||
|
? source.release_selector
|
||||||
|
: 'latest',
|
||||||
|
releaseTag:
|
||||||
|
source.source_type === 'github_release'
|
||||||
|
? (source.release_tag ?? '')
|
||||||
|
: '',
|
||||||
|
assetName:
|
||||||
|
source.source_type === 'github_release'
|
||||||
|
? source.asset_name
|
||||||
|
: DEFAULT_GITHUB_ASSET,
|
||||||
|
});
|
||||||
|
setGitHubErrors(EMPTY_GITHUB_ERRORS);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
}, [initialMode, open, source]);
|
}, [initialMode, open, source]);
|
||||||
|
|
||||||
@@ -102,19 +162,17 @@ export function PagesSourceDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (payload: PagesSourceUpdatePayload) =>
|
||||||
PagesService.updateSource(projectId, {
|
PagesService.updateSource(projectId, payload),
|
||||||
source_type: 'remote_url',
|
|
||||||
remote_url_set: replaceURL,
|
|
||||||
remote_url: replaceURL ? remoteURL.trim() : '',
|
|
||||||
remote_network_policy: networkPolicy,
|
|
||||||
}),
|
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
|
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
|
||||||
|
if (result.check_task) onActionDispatched?.(result.check_task);
|
||||||
await invalidateSourceState();
|
await invalidateSourceState();
|
||||||
toast.success('部署源已更新');
|
toast.success('部署源已更新');
|
||||||
if (result.warning) toast.warning(result.warning);
|
if (result.warning) toast.warning(result.warning);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -129,6 +187,8 @@ export function PagesSourceDialog({
|
|||||||
await invalidateSourceState();
|
await invalidateSourceState();
|
||||||
toast.success('已切换回手动部署');
|
toast.success('已切换回手动部署');
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -138,6 +198,13 @@ export function PagesSourceDialog({
|
|||||||
|
|
||||||
const isPending = updateMutation.isPending || deleteMutation.isPending;
|
const isPending = updateMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
|
const remotePayload = (): PagesSourceUpdatePayload => ({
|
||||||
|
source_type: 'remote_url',
|
||||||
|
remote_url_set: replaceURL,
|
||||||
|
remote_url: replaceURL ? remoteURL.trim() : '',
|
||||||
|
remote_network_policy: networkPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
const submitRemote = () => {
|
const submitRemote = () => {
|
||||||
if (replaceURL) {
|
if (replaceURL) {
|
||||||
const value = remoteURL.trim();
|
const value = remoteURL.trim();
|
||||||
@@ -158,21 +225,86 @@ export function PagesSourceDialog({
|
|||||||
setConfirmation('trusted_internal');
|
setConfirmation('trusted_internal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateMutation.mutate();
|
updateMutation.mutate(remotePayload());
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGitHub = () => {
|
||||||
|
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
|
||||||
|
const nextRepositoryError = validGitHubRepositoryURL(
|
||||||
|
normalizedRepositoryURL,
|
||||||
|
)
|
||||||
|
? ''
|
||||||
|
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
|
||||||
|
const nextReleaseTagError =
|
||||||
|
githubForm.releaseSelector === 'tag' &&
|
||||||
|
!validGitHubReleaseTag(githubForm.releaseTag)
|
||||||
|
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
|
||||||
|
: '';
|
||||||
|
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||||
|
? ''
|
||||||
|
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||||
|
|
||||||
|
setGitHubErrors({
|
||||||
|
repository: nextRepositoryError,
|
||||||
|
releaseTag: nextReleaseTagError,
|
||||||
|
assetName: nextAssetNameError,
|
||||||
|
});
|
||||||
|
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: PagesSourceUpdatePayload =
|
||||||
|
githubForm.releaseSelector === 'latest'
|
||||||
|
? {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: normalizedRepositoryURL,
|
||||||
|
release_selector: 'latest',
|
||||||
|
release_tag: '',
|
||||||
|
asset_name: githubForm.assetName,
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes:
|
||||||
|
source.source_type === 'github_release' &&
|
||||||
|
source.release_selector === 'latest' &&
|
||||||
|
source.check_interval_minutes
|
||||||
|
? source.check_interval_minutes
|
||||||
|
: DEFAULT_GITHUB_CHECK_INTERVAL,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: normalizedRepositoryURL,
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: githubForm.releaseTag,
|
||||||
|
asset_name: githubForm.assetName,
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
};
|
||||||
|
updateMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (mode === 'manual') {
|
switch (mode) {
|
||||||
if (source.source_type === 'manual') {
|
case 'manual':
|
||||||
onOpenChange(false);
|
if (source.source_type === 'manual') {
|
||||||
} else {
|
onOpenChange(false);
|
||||||
setConfirmation('manual');
|
} else {
|
||||||
}
|
setConfirmation('manual');
|
||||||
return;
|
}
|
||||||
|
return;
|
||||||
|
case 'remote_url':
|
||||||
|
submitRemote();
|
||||||
|
return;
|
||||||
|
case 'github_release':
|
||||||
|
submitGitHub();
|
||||||
}
|
}
|
||||||
submitRemote();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitLabel =
|
||||||
|
mode === 'manual'
|
||||||
|
? '使用手动部署'
|
||||||
|
: mode === 'remote_url'
|
||||||
|
? '保存 Remote 来源'
|
||||||
|
: '保存 GitHub 来源';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -185,8 +317,7 @@ export function PagesSourceDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>部署源设置</DialogTitle>
|
<DialogTitle>部署源设置</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
手动部署与 Remote URL
|
手动部署、Remote URL 与 GitHub Release 使用独立配置。
|
||||||
各自保持独立配置;后续仓库构建来源会作为新的来源类型接入。
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -198,10 +329,19 @@ export function PagesSourceDialog({
|
|||||||
variant='outline'
|
variant='outline'
|
||||||
value={mode}
|
value={mode}
|
||||||
aria-labelledby='pages-source-mode'
|
aria-labelledby='pages-source-mode'
|
||||||
className='grid w-full grid-cols-2'
|
className='grid w-full grid-cols-1 sm:grid-cols-3'
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === 'manual' || value === 'remote_url') {
|
if (
|
||||||
|
value === 'manual' ||
|
||||||
|
value === 'remote_url' ||
|
||||||
|
value === 'github_release'
|
||||||
|
) {
|
||||||
setMode(value);
|
setMode(value);
|
||||||
|
if (value !== 'remote_url') {
|
||||||
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
|
setURLError('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -211,9 +351,17 @@ export function PagesSourceDialog({
|
|||||||
<ToggleGroupItem value='remote_url' className='w-full'>
|
<ToggleGroupItem value='remote_url' className='w-full'>
|
||||||
Remote URL
|
Remote URL
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value='github_release' className='w-full'>
|
||||||
|
GitHub Release
|
||||||
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<FieldDescription>
|
<FieldDescription>
|
||||||
手动部署由管理员上传本地包;Remote URL 通过显式同步下载并发布。
|
<span>
|
||||||
|
远端来源只负责发现内容,发布结果始终保留为不可变部署。
|
||||||
|
</span>
|
||||||
|
<span className='block'>
|
||||||
|
仓库源码构建将在后续作为独立来源类型提供。
|
||||||
|
</span>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -224,7 +372,7 @@ export function PagesSourceDialog({
|
|||||||
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
|
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
) : (
|
) : mode === 'remote_url' ? (
|
||||||
<>
|
<>
|
||||||
<Field data-invalid={Boolean(urlError)}>
|
<Field data-invalid={Boolean(urlError)}>
|
||||||
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
||||||
@@ -240,24 +388,48 @@ export function PagesSourceDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReplaceURL(true);
|
setReplaceURL(true);
|
||||||
setRemoteURL('');
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
更换地址
|
更换地址
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<div className='flex gap-2'>
|
||||||
id='pages-remote-url'
|
<Input
|
||||||
type='url'
|
id='pages-remote-url'
|
||||||
placeholder='https://artifacts.example.com/site.zip?token=...'
|
type={revealRemoteURL ? 'url' : 'password'}
|
||||||
value={remoteURL}
|
placeholder='https://artifacts.example.com/site.zip?token=...'
|
||||||
aria-invalid={Boolean(urlError)}
|
value={remoteURL}
|
||||||
autoComplete='off'
|
aria-invalid={Boolean(urlError)}
|
||||||
onChange={(event) => {
|
autoComplete='off'
|
||||||
setRemoteURL(event.target.value);
|
className='min-w-0 flex-1'
|
||||||
setURLError('');
|
onChange={(event) => {
|
||||||
}}
|
setRemoteURL(event.target.value);
|
||||||
/>
|
setURLError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
aria-label={
|
||||||
|
revealRemoteURL
|
||||||
|
? '隐藏 Remote URL'
|
||||||
|
: '显示 Remote URL'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
revealRemoteURL
|
||||||
|
? '隐藏 Remote URL'
|
||||||
|
: '显示 Remote URL'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setRevealRemoteURL((visible) => !visible)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{revealRemoteURL ? <EyeOff /> : <Eye />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<FieldDescription>
|
<FieldDescription>
|
||||||
{urlError ||
|
{urlError ||
|
||||||
@@ -274,6 +446,7 @@ export function PagesSourceDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReplaceURL(false);
|
setReplaceURL(false);
|
||||||
setRemoteURL('');
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
setURLError('');
|
setURLError('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -313,6 +486,14 @@ export function PagesSourceDialog({
|
|||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<PagesSourceGitHubFields
|
||||||
|
value={githubForm}
|
||||||
|
errors={githubErrors}
|
||||||
|
defaultAssetName={DEFAULT_GITHUB_ASSET}
|
||||||
|
onChange={setGitHubForm}
|
||||||
|
onErrorsChange={setGitHubErrors}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
@@ -321,13 +502,17 @@ export function PagesSourceDialog({
|
|||||||
type='button'
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => {
|
||||||
|
setRemoteURL('');
|
||||||
|
setRevealRemoteURL(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='button' disabled={isPending} onClick={handleSubmit}>
|
<Button type='button' disabled={isPending} onClick={handleSubmit}>
|
||||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||||
{mode === 'manual' ? '使用手动部署' : '保存 Remote 来源'}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -361,7 +546,7 @@ export function PagesSourceDialog({
|
|||||||
if (confirmation === 'manual') {
|
if (confirmation === 'manual') {
|
||||||
deleteMutation.mutate();
|
deleteMutation.mutate();
|
||||||
} else if (confirmation === 'trusted_internal') {
|
} else if (confirmation === 'trusted_internal') {
|
||||||
updateMutation.mutate();
|
updateMutation.mutate(remotePayload());
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export interface PagesGitHubSourceFormValue {
|
||||||
|
repositoryURL: string;
|
||||||
|
releaseSelector: PagesGitHubReleaseSelector;
|
||||||
|
releaseTag: string;
|
||||||
|
assetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagesGitHubSourceFormErrors {
|
||||||
|
repository: string;
|
||||||
|
releaseTag: string;
|
||||||
|
assetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagesSourceGitHubFieldsProps {
|
||||||
|
value: PagesGitHubSourceFormValue;
|
||||||
|
errors: PagesGitHubSourceFormErrors;
|
||||||
|
defaultAssetName: string;
|
||||||
|
onChange: (value: PagesGitHubSourceFormValue) => void;
|
||||||
|
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesSourceGitHubFields({
|
||||||
|
value,
|
||||||
|
errors,
|
||||||
|
defaultAssetName,
|
||||||
|
onChange,
|
||||||
|
onErrorsChange,
|
||||||
|
}: PagesSourceGitHubFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field data-invalid={Boolean(errors.repository)}>
|
||||||
|
<FieldLabel htmlFor='pages-github-repository'>
|
||||||
|
GitHub 仓库 URL
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id='pages-github-repository'
|
||||||
|
type='url'
|
||||||
|
placeholder='https://github.com/owner/repo'
|
||||||
|
value={value.repositoryURL}
|
||||||
|
aria-invalid={Boolean(errors.repository)}
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({ ...value, repositoryURL: event.target.value });
|
||||||
|
onErrorsChange({ ...errors, repository: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{errors.repository || '仅支持公开 github.com 仓库。'}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
|
||||||
|
<ToggleGroup
|
||||||
|
type='single'
|
||||||
|
variant='outline'
|
||||||
|
value={value.releaseSelector}
|
||||||
|
aria-labelledby='pages-github-selector'
|
||||||
|
className='grid w-full grid-cols-2'
|
||||||
|
onValueChange={(selector) => {
|
||||||
|
if (selector === 'latest' || selector === 'tag') {
|
||||||
|
onChange({ ...value, releaseSelector: selector });
|
||||||
|
onErrorsChange({ ...errors, releaseTag: '' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value='latest' className='w-full'>
|
||||||
|
最新 Release
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value='tag' className='w-full'>
|
||||||
|
固定 Tag
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
<FieldDescription>
|
||||||
|
当前阶段由管理员手动检查并决定是否发布。
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{value.releaseSelector === 'tag' ? (
|
||||||
|
<Field data-invalid={Boolean(errors.releaseTag)}>
|
||||||
|
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id='pages-github-tag'
|
||||||
|
placeholder='v1.2.3'
|
||||||
|
value={value.releaseTag}
|
||||||
|
aria-invalid={Boolean(errors.releaseTag)}
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({ ...value, releaseTag: event.target.value });
|
||||||
|
onErrorsChange({ ...errors, releaseTag: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{errors.releaseTag || '精确检查并同步指定 tag。'}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.assetName)}>
|
||||||
|
<FieldLabel htmlFor='pages-github-asset'>
|
||||||
|
Release Asset 文件名
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id='pages-github-asset'
|
||||||
|
placeholder={defaultAssetName}
|
||||||
|
value={value.assetName}
|
||||||
|
aria-invalid={Boolean(errors.assetName)}
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange({ ...value, assetName: event.target.value });
|
||||||
|
onErrorsChange({ ...errors, assetName: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
function hasWellFormedUnicode(value: string) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
const next = value.charCodeAt(index + 1);
|
||||||
|
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
||||||
|
index += 1;
|
||||||
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validGitHubSafeText(value: string) {
|
||||||
|
return (
|
||||||
|
value !== '' &&
|
||||||
|
new TextEncoder().encode(value).byteLength <= 255 &&
|
||||||
|
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
|
||||||
|
value,
|
||||||
|
) &&
|
||||||
|
hasWellFormedUnicode(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validGitHubRepositoryURL(raw: string) {
|
||||||
|
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
|
||||||
|
if (!match) return false;
|
||||||
|
|
||||||
|
const [, host, owner, rawRepository] = match;
|
||||||
|
const repository = rawRepository.replace(/\.git$/u, '');
|
||||||
|
return (
|
||||||
|
host.toLowerCase() === 'github.com' &&
|
||||||
|
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
|
||||||
|
/^[a-z0-9._-]+$/iu.test(repository) &&
|
||||||
|
repository.length <= 100 &&
|
||||||
|
!['.', '..'].includes(repository)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validGitHubAssetName(value: string) {
|
||||||
|
return (
|
||||||
|
validGitHubSafeText(value) &&
|
||||||
|
value !== '.' &&
|
||||||
|
value !== '..' &&
|
||||||
|
!value.includes('/') &&
|
||||||
|
!value.includes('\\')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validGitHubReleaseTag(value: string) {
|
||||||
|
const components = value.split('/');
|
||||||
|
return (
|
||||||
|
validGitHubSafeText(value) &&
|
||||||
|
!value.endsWith('.') &&
|
||||||
|
!value.includes('..') &&
|
||||||
|
!value.includes('@{') &&
|
||||||
|
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
|
||||||
|
value.includes(character),
|
||||||
|
) &&
|
||||||
|
components.every(
|
||||||
|
(component) =>
|
||||||
|
component !== '' &&
|
||||||
|
!component.startsWith('.') &&
|
||||||
|
!component.endsWith('.lock'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='alert'
|
||||||
|
role='alert'
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='alert-title'
|
||||||
|
className={cn(
|
||||||
|
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='alert-description'
|
||||||
|
className={cn(
|
||||||
|
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertDescription, AlertTitle };
|
||||||
@@ -109,7 +109,11 @@ export type {
|
|||||||
OriginMutationPayload,
|
OriginMutationPayload,
|
||||||
PagesDeployment,
|
PagesDeployment,
|
||||||
PagesDeploymentFile,
|
PagesDeploymentFile,
|
||||||
|
PagesGitHubLatestSourceUpdatePayload,
|
||||||
|
PagesGitHubReleaseSelector,
|
||||||
PagesGitHubReleaseSource,
|
PagesGitHubReleaseSource,
|
||||||
|
PagesGitHubSourceUpdatePayload,
|
||||||
|
PagesGitHubTagSourceUpdatePayload,
|
||||||
PagesManualSource,
|
PagesManualSource,
|
||||||
PagesProject,
|
PagesProject,
|
||||||
PagesProjectPayload,
|
PagesProjectPayload,
|
||||||
@@ -121,6 +125,7 @@ export type {
|
|||||||
PagesSourceActionReceipt,
|
PagesSourceActionReceipt,
|
||||||
PagesSourceRevision,
|
PagesSourceRevision,
|
||||||
PagesSourceStatus,
|
PagesSourceStatus,
|
||||||
|
PagesSourceUpdatePayload,
|
||||||
PagesSourceUpdateResult,
|
PagesSourceUpdateResult,
|
||||||
AcmeAccountItem,
|
AcmeAccountItem,
|
||||||
DnsAccountItem,
|
DnsAccountItem,
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import type {
|
|||||||
PagesDeploymentUploadPayload,
|
PagesDeploymentUploadPayload,
|
||||||
PagesProject,
|
PagesProject,
|
||||||
PagesProjectPayload,
|
PagesProjectPayload,
|
||||||
PagesRemoteSourceUpdatePayload,
|
|
||||||
PagesSource,
|
PagesSource,
|
||||||
PagesSourceActionPayload,
|
PagesSourceActionPayload,
|
||||||
PagesSourceActionReceipt,
|
PagesSourceActionReceipt,
|
||||||
|
PagesSourceUpdatePayload,
|
||||||
PagesSourceUpdateResult,
|
PagesSourceUpdateResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export class PagesService extends OpenFlareBaseService {
|
|||||||
|
|
||||||
static updateSource(
|
static updateSource(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
payload: PagesRemoteSourceUpdatePayload,
|
payload: PagesSourceUpdatePayload,
|
||||||
): Promise<PagesSourceUpdateResult> {
|
): Promise<PagesSourceUpdateResult> {
|
||||||
return this.post<PagesSourceUpdateResult>(
|
return this.post<PagesSourceUpdateResult>(
|
||||||
`/${projectId}/source/update`,
|
`/${projectId}/source/update`,
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ export type PagesSourceStatus =
|
|||||||
|
|
||||||
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
|
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
|
||||||
|
|
||||||
|
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
|
||||||
|
|
||||||
export interface PagesSourceRevision {
|
export interface PagesSourceRevision {
|
||||||
revision: string;
|
revision: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -493,15 +495,15 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
|
|||||||
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
|
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
|
||||||
source_type: 'github_release';
|
source_type: 'github_release';
|
||||||
github_repository: string;
|
github_repository: string;
|
||||||
release_selector: 'latest' | 'tag';
|
release_selector: PagesGitHubReleaseSelector;
|
||||||
release_tag: string;
|
release_tag?: string;
|
||||||
asset_name: string;
|
asset_name: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled?: false;
|
||||||
check_interval_minutes: number;
|
check_interval_minutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 部署源使用判别联合,后续仓库构建来源只需增加独立 variant,
|
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
|
||||||
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
||||||
*/
|
*/
|
||||||
export type PagesSource =
|
export type PagesSource =
|
||||||
@@ -514,6 +516,35 @@ export interface PagesRemoteSourceUpdatePayload {
|
|||||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PagesGitHubSourceUpdateBase {
|
||||||
|
source_type: 'github_release';
|
||||||
|
repository_url: string;
|
||||||
|
asset_name: string;
|
||||||
|
auto_update_enabled: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||||
|
release_selector: 'latest';
|
||||||
|
release_tag: '';
|
||||||
|
check_interval_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||||
|
release_selector: 'tag';
|
||||||
|
release_tag: string;
|
||||||
|
check_interval_minutes: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PagesGitHubSourceUpdatePayload =
|
||||||
|
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
|
||||||
|
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
|
||||||
|
*/
|
||||||
|
export type PagesSourceUpdatePayload =
|
||||||
|
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
|
||||||
|
|
||||||
export interface PagesSourceActionPayload {
|
export interface PagesSourceActionPayload {
|
||||||
confirmed_revision?: string;
|
confirmed_revision?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,74 @@ describe('PagesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends the complete GitHub latest discriminator payload', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(
|
||||||
|
response({
|
||||||
|
source: { source_type: 'manual' },
|
||||||
|
check_task: null,
|
||||||
|
warning: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await PagesService.updateSource(12, {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'latest',
|
||||||
|
release_tag: '',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/pages/12/source/update',
|
||||||
|
{
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'latest',
|
||||||
|
release_tag: '',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(
|
||||||
|
response({
|
||||||
|
source: { source_type: 'manual' },
|
||||||
|
check_task: null,
|
||||||
|
warning: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await PagesService.updateSource(12, {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'v1.2.3',
|
||||||
|
asset_name: 'site.tar.gz',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/pages/12/source/update',
|
||||||
|
{
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'v1.2.3',
|
||||||
|
asset_name: 'site.tar.gz',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('uploads only the package multipart field', async () => {
|
it('uploads only the package multipart field', async () => {
|
||||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||||
const file = new File(['site'], 'site.zip', {
|
const file = new File(['site'], 'site.zip', {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
|
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
|
||||||
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||||
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
|
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||||
import { AdminTaskService } from '@/lib/services/admin';
|
|
||||||
import {
|
import {
|
||||||
|
AdminTaskService,
|
||||||
|
type TaskExecution,
|
||||||
|
type TaskExecutionStatus,
|
||||||
|
} from '@/lib/services/admin';
|
||||||
|
import {
|
||||||
|
type PagesDeployment,
|
||||||
|
type PagesGitHubReleaseSource,
|
||||||
type PagesRemoteURLSource,
|
type PagesRemoteURLSource,
|
||||||
PagesService,
|
PagesService,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
@@ -21,6 +27,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
|||||||
getSource: vi.fn(),
|
getSource: vi.fn(),
|
||||||
updateSource: vi.fn(),
|
updateSource: vi.fn(),
|
||||||
deleteSource: vi.fn(),
|
deleteSource: vi.fn(),
|
||||||
|
checkSource: vi.fn(),
|
||||||
syncSource: vi.fn(),
|
syncSource: vi.fn(),
|
||||||
listDeployments: vi.fn(),
|
listDeployments: vi.fn(),
|
||||||
listDeploymentFiles: vi.fn(),
|
listDeploymentFiles: vi.fn(),
|
||||||
@@ -67,11 +74,73 @@ const remoteSource: PagesRemoteURLSource = {
|
|||||||
last_error: '',
|
last_error: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const githubLatestSource: PagesGitHubReleaseSource = {
|
||||||
|
source_type: 'github_release',
|
||||||
|
github_repository: 'openflare/site',
|
||||||
|
release_selector: 'latest',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
sync_status: 'update_available',
|
||||||
|
update_available: true,
|
||||||
|
last_seen: {
|
||||||
|
revision: 'b'.repeat(64),
|
||||||
|
label: 'v1.2.3',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
last_applied: {
|
||||||
|
revision: 'a'.repeat(64),
|
||||||
|
label: 'v1.2.2',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
last_checked_at: '2026-07-19T10:00:00Z',
|
||||||
|
last_synced_at: '2026-07-18T10:00:00Z',
|
||||||
|
next_check_at: '2026-07-19T11:00:00Z',
|
||||||
|
last_error: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubAttentionRevision = 'c'.repeat(64);
|
||||||
|
const githubAttentionSource: PagesGitHubReleaseSource = {
|
||||||
|
...githubLatestSource,
|
||||||
|
sync_status: 'attention',
|
||||||
|
update_available: true,
|
||||||
|
last_seen: {
|
||||||
|
revision: githubAttentionRevision,
|
||||||
|
label: 'v1.2.3',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function taskExecution(
|
||||||
|
status: TaskExecutionStatus,
|
||||||
|
errorMessage = '',
|
||||||
|
): TaskExecution {
|
||||||
|
return {
|
||||||
|
id: '42',
|
||||||
|
task_id: 'manual_of_pages_source_action_1',
|
||||||
|
task_type: 'of_pages_source_action',
|
||||||
|
task_name: 'Pages 来源动作',
|
||||||
|
status,
|
||||||
|
retryable: false,
|
||||||
|
max_retry: 0,
|
||||||
|
retry_count: 0,
|
||||||
|
log: '',
|
||||||
|
error_message: errorMessage,
|
||||||
|
result: '',
|
||||||
|
duration: 1,
|
||||||
|
payload: '',
|
||||||
|
triggered_by: 'admin:1',
|
||||||
|
created_at: '2026-07-19T10:00:00Z',
|
||||||
|
updated_at: '2026-07-19T10:00:01Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('Pages source UI', () => {
|
describe('Pages source UI', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(PagesService.getSource).mockReset();
|
vi.mocked(PagesService.getSource).mockReset();
|
||||||
vi.mocked(PagesService.updateSource).mockReset();
|
vi.mocked(PagesService.updateSource).mockReset();
|
||||||
vi.mocked(PagesService.deleteSource).mockReset();
|
vi.mocked(PagesService.deleteSource).mockReset();
|
||||||
|
vi.mocked(PagesService.checkSource).mockReset();
|
||||||
vi.mocked(PagesService.syncSource).mockReset();
|
vi.mocked(PagesService.syncSource).mockReset();
|
||||||
vi.mocked(PagesService.listDeployments).mockReset();
|
vi.mocked(PagesService.listDeployments).mockReset();
|
||||||
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||||
@@ -81,7 +150,8 @@ describe('Pages source UI', () => {
|
|||||||
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
|
it('offers the three Phase 2 source types without future repository build controls', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
source_type: 'manual',
|
source_type: 'manual',
|
||||||
});
|
});
|
||||||
@@ -92,8 +162,327 @@ describe('Pages source UI', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '配置 Remote URL' }),
|
screen.getByRole('button', { name: '配置 Remote URL' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
).toBeVisible();
|
||||||
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
|
source_type: 'manual',
|
||||||
|
});
|
||||||
|
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||||
|
source: githubLatestSource,
|
||||||
|
check_task: null,
|
||||||
|
warning: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('GitHub 仓库 URL'),
|
||||||
|
'https://github.com/openflare/site',
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
||||||
|
'dist.zip',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'latest',
|
||||||
|
release_tag: '',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-canonical GitHub repository URL paths', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
|
source_type: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
|
||||||
|
await user.type(repositoryInput, 'https://github.com//openflare/site');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await user.clear(repositoryInput);
|
||||||
|
await user.type(repositoryInput, 'https://github.com/openflare/site/');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
|
source_type: 'manual',
|
||||||
|
});
|
||||||
|
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||||
|
source: {
|
||||||
|
...githubLatestSource,
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'v1.2.3',
|
||||||
|
check_interval_minutes: undefined,
|
||||||
|
next_check_at: null,
|
||||||
|
},
|
||||||
|
check_task: null,
|
||||||
|
warning: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('GitHub 仓库 URL'),
|
||||||
|
'https://github.com/openflare/site',
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('Release tag'),
|
||||||
|
'release/candidate#1&channel=stable',
|
||||||
|
);
|
||||||
|
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('Release Asset 文件名'),
|
||||||
|
' site?arch=amd64#stable.zip ',
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||||
|
source_type: 'github_release',
|
||||||
|
repository_url: 'https://github.com/openflare/site',
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'release/candidate#1&channel=stable',
|
||||||
|
asset_name: ' site?arch=amd64#stable.zip ',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe GitHub tag and asset values before saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
|
source_type: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('GitHub 仓库 URL'),
|
||||||
|
'https://github.com/openflare/site',
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||||
|
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
|
||||||
|
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('Release Asset 文件名'),
|
||||||
|
'../dist.zip',
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls the initial check receipt and represents the queued window locally', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const idleGitHubSource: PagesGitHubReleaseSource = {
|
||||||
|
...githubLatestSource,
|
||||||
|
sync_status: 'idle',
|
||||||
|
update_available: false,
|
||||||
|
};
|
||||||
|
vi.mocked(PagesService.getSource)
|
||||||
|
.mockResolvedValueOnce({ source_type: 'manual' })
|
||||||
|
.mockResolvedValue(idleGitHubSource);
|
||||||
|
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||||
|
source: idleGitHubSource,
|
||||||
|
check_task: {
|
||||||
|
task_id: 'manual_of_pages_source_action_1',
|
||||||
|
execution_id: '42',
|
||||||
|
action: 'check',
|
||||||
|
},
|
||||||
|
warning: '',
|
||||||
|
});
|
||||||
|
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||||
|
taskExecution('pending'),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('GitHub 仓库 URL'),
|
||||||
|
'https://github.com/openflare/site',
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||||
|
expect(screen.getByText('检查中')).toBeVisible();
|
||||||
|
});
|
||||||
|
const checkButton = screen.getByRole('button', { name: /检查更新/ });
|
||||||
|
expect(checkButton).toBeDisabled();
|
||||||
|
expect(within(checkButton).getByRole('status')).toBeVisible();
|
||||||
|
expect(PagesService.checkSource).not.toHaveBeenCalled();
|
||||||
|
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows GitHub release state and hides Phase 3 controls', async () => {
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||||
|
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||||
|
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||||
|
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||||
|
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||||
|
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||||
|
task_id: 'manual_of_pages_source_action_1',
|
||||||
|
execution_id: '42',
|
||||||
|
action: 'check',
|
||||||
|
});
|
||||||
|
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||||
|
taskExecution('succeeded'),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
|
||||||
|
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||||
|
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a GitHub check dispatch error', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||||
|
vi.mocked(PagesService.checkSource).mockRejectedValue(
|
||||||
|
new Error('GitHub API 暂不可用'),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a TaskExecution polling error with an explicit retry', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||||
|
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||||
|
task_id: 'manual_of_pages_source_action_1',
|
||||||
|
execution_id: '42',
|
||||||
|
action: 'check',
|
||||||
|
});
|
||||||
|
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
|
||||||
|
new Error('任务状态暂时不可读取'),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the exact currently displayed revision for attention sync', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
|
||||||
|
vi.mocked(PagesService.syncSource).mockResolvedValue({
|
||||||
|
task_id: 'manual_of_pages_source_action_1',
|
||||||
|
execution_id: '42',
|
||||||
|
action: 'sync',
|
||||||
|
});
|
||||||
|
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||||
|
taskExecution('succeeded'),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert');
|
||||||
|
expect(
|
||||||
|
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '同步并发布' }));
|
||||||
|
const dialog = screen.getByRole('alertdialog');
|
||||||
|
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
|
||||||
|
await user.click(
|
||||||
|
within(dialog).getByRole('button', { name: '确认并发布' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
|
||||||
|
confirmed_revision: githubAttentionRevision,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never reuses the masked URL as an editable value', async () => {
|
it('never reuses the masked URL as an editable value', async () => {
|
||||||
@@ -119,7 +508,13 @@ describe('Pages source UI', () => {
|
|||||||
'https://artifacts.example.com/site.zip?token=...',
|
'https://artifacts.example.com/site.zip?token=...',
|
||||||
);
|
);
|
||||||
expect(input).toHaveValue('');
|
expect(input).toHaveValue('');
|
||||||
|
expect(input).toHaveAttribute('type', 'password');
|
||||||
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
||||||
|
expect(
|
||||||
|
screen.queryByText('https://new.example.com/site.zip?token=new'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
|
||||||
|
expect(input).toHaveAttribute('type', 'url');
|
||||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -130,6 +525,17 @@ describe('Pages source UI', () => {
|
|||||||
remote_network_policy: 'public',
|
remote_network_policy: 'public',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||||
|
const reopenedInput = screen.getByPlaceholderText(
|
||||||
|
'https://artifacts.example.com/site.zip?token=...',
|
||||||
|
);
|
||||||
|
expect(reopenedInput).toHaveValue('');
|
||||||
|
expect(reopenedInput).toHaveAttribute('type', 'password');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a second confirmation for trusted internal networking', async () => {
|
it('requires a second confirmation for trusted internal networking', async () => {
|
||||||
@@ -169,24 +575,9 @@ describe('Pages source UI', () => {
|
|||||||
execution_id: '42',
|
execution_id: '42',
|
||||||
action: 'sync',
|
action: 'sync',
|
||||||
});
|
});
|
||||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
|
vi.mocked(AdminTaskService.getTaskExecution)
|
||||||
id: '42',
|
.mockResolvedValueOnce(taskExecution('pending'))
|
||||||
task_id: 'manual_of_pages_source_action_1',
|
.mockResolvedValue(taskExecution('succeeded'));
|
||||||
task_type: 'of_pages_source_action',
|
|
||||||
task_name: 'Pages 来源动作',
|
|
||||||
status: 'succeeded',
|
|
||||||
retryable: false,
|
|
||||||
max_retry: 0,
|
|
||||||
retry_count: 0,
|
|
||||||
log: '',
|
|
||||||
error_message: '',
|
|
||||||
result: '',
|
|
||||||
duration: 1,
|
|
||||||
payload: '',
|
|
||||||
triggered_by: 'admin:1',
|
|
||||||
created_at: '2026-07-19T10:00:00Z',
|
|
||||||
updated_at: '2026-07-19T10:00:01Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
@@ -196,6 +587,14 @@ describe('Pages source UI', () => {
|
|||||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
|
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
|
||||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||||
});
|
});
|
||||||
|
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
{ timeout: 3_500 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the actual project entry and no one-off URL upload tab', () => {
|
it('shows the actual project entry and no one-off URL upload tab', () => {
|
||||||
@@ -224,4 +623,30 @@ describe('Pages source UI', () => {
|
|||||||
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
|
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
|
||||||
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
|
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
|
||||||
|
const deployment: PagesDeployment = {
|
||||||
|
id: 51,
|
||||||
|
project_id: 9,
|
||||||
|
deployment_number: 7,
|
||||||
|
checksum: 'd'.repeat(64),
|
||||||
|
status: 'active',
|
||||||
|
file_count: 12,
|
||||||
|
total_size: 4_096,
|
||||||
|
created_by: 'user:1',
|
||||||
|
source_type: 'github_release',
|
||||||
|
source_label: 'v1.2.3',
|
||||||
|
trigger_type: 'manual_sync',
|
||||||
|
created_at: '2026-07-19T10:00:00Z',
|
||||||
|
activated_at: '2026-07-19T10:00:01Z',
|
||||||
|
};
|
||||||
|
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||||
|
|
||||||
|
renderWithQuery(
|
||||||
|
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
|
||||||
|
expect(screen.getByText('当前生产部署')).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
validGitHubAssetName,
|
||||||
|
validGitHubReleaseTag,
|
||||||
|
validGitHubRepositoryURL,
|
||||||
|
} from '@/app/(main)/pages/detail/components/pages-source-validation';
|
||||||
|
|
||||||
|
describe('Pages GitHub source validation', () => {
|
||||||
|
it('accepts only canonical public GitHub repository URLs', () => {
|
||||||
|
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
|
||||||
|
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
'http://github.com/acme/site',
|
||||||
|
'https://github.com//acme/site',
|
||||||
|
'https://github.com/acme/site/',
|
||||||
|
'https://github.com/acme/site/extra',
|
||||||
|
'https://github.com/acme/./site',
|
||||||
|
'https://github.com/acme/../site',
|
||||||
|
'https://github.com/acme/%73ite',
|
||||||
|
'https://github.com/acme/site?token=secret',
|
||||||
|
'https://github.com:443/acme/site',
|
||||||
|
String.raw`https://github.com/acme\site`,
|
||||||
|
];
|
||||||
|
for (const value of invalid) {
|
||||||
|
expect(validGitHubRepositoryURL(value), value).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors Git ref rules while preserving legal release tag characters', () => {
|
||||||
|
const valid = [
|
||||||
|
'@',
|
||||||
|
'release/v1#stable&channel=prod',
|
||||||
|
'foo.LOCK',
|
||||||
|
'中文/发布=稳定',
|
||||||
|
];
|
||||||
|
for (const value of valid) {
|
||||||
|
expect(validGitHubReleaseTag(value), value).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
'',
|
||||||
|
'release v1',
|
||||||
|
'release~v1',
|
||||||
|
'release^v1',
|
||||||
|
'release:v1',
|
||||||
|
'release?v1',
|
||||||
|
'release*v1',
|
||||||
|
'release[v1',
|
||||||
|
String.raw`release\v1`,
|
||||||
|
'release..v1',
|
||||||
|
'release@{v1',
|
||||||
|
'release//v1',
|
||||||
|
'/release',
|
||||||
|
'release/',
|
||||||
|
'release.',
|
||||||
|
'.release',
|
||||||
|
'release/.candidate',
|
||||||
|
'release/v1.lock',
|
||||||
|
'release\nsecret',
|
||||||
|
'release\u2028secret',
|
||||||
|
'release\u202esecret',
|
||||||
|
'a'.repeat(256),
|
||||||
|
];
|
||||||
|
for (const value of invalid) {
|
||||||
|
expect(validGitHubReleaseTag(value), value).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves exact legal asset names and rejects path or display controls', () => {
|
||||||
|
const valid = [
|
||||||
|
'dist.zip',
|
||||||
|
'dist?channel=stable&part#1.zip',
|
||||||
|
' dist.zip ',
|
||||||
|
'build=production.zip',
|
||||||
|
];
|
||||||
|
for (const value of valid) {
|
||||||
|
expect(validGitHubAssetName(value), value).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
'',
|
||||||
|
'.',
|
||||||
|
'..',
|
||||||
|
'../dist.zip',
|
||||||
|
String.raw`dir\dist.zip`,
|
||||||
|
'dist\n.zip',
|
||||||
|
'dist\u2028.zip',
|
||||||
|
'dist\u202e.zip',
|
||||||
|
'a'.repeat(256),
|
||||||
|
'\ud800',
|
||||||
|
];
|
||||||
|
for (const value of invalid) {
|
||||||
|
expect(validGitHubAssetName(value), value).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,51 +5,63 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
const (
|
const (
|
||||||
errPagesProjectNotFound = "pages 项目不存在"
|
errPagesProjectNotFound = "pages 项目不存在"
|
||||||
errPagesSlugExists = "pages 项目标识已存在"
|
errPagesSlugExists = "pages 项目标识已存在"
|
||||||
errPagesNameRequired = "pages 项目名称不能为空"
|
errPagesNameRequired = "pages 项目名称不能为空"
|
||||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||||
errPagesActorMissing = "无法识别当前用户"
|
errPagesActorMissing = "无法识别当前用户"
|
||||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||||
errPagesSourceNotFound = "pages 部署源不存在"
|
errPagesSourceNotFound = "pages 部署源不存在"
|
||||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||||
errPagesSourceTypeUnsupported = "当前阶段仅支持远程地址部署源"
|
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
|
||||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
|
||||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
|
||||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
|
||||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
|
||||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
|
||||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
|
||||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
|
||||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
|
||||||
|
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
|
||||||
|
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
|
||||||
|
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
|
||||||
|
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
|
||||||
|
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||||
|
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||||
|
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||||
|
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||||
|
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||||
|
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||||
|
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||||
|
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
githubReleaseSelectorLatest = "latest"
|
||||||
|
githubReleaseSelectorTag = "tag"
|
||||||
|
githubSourceIdentityDomain = "openflare:pages:github-release:v2"
|
||||||
|
initialCheckRetryDelay = 5 * time.Minute
|
||||||
|
githubRepositoryPathParts = 2
|
||||||
|
githubCheckJitterRange = 301
|
||||||
|
githubCheckJitterCenter = 150
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
githubOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
|
||||||
|
githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type githubSourceConfig struct {
|
||||||
|
Repository string
|
||||||
|
Selector string
|
||||||
|
Tag string
|
||||||
|
AssetName string
|
||||||
|
CheckInterval int
|
||||||
|
SourceIdentity string
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGitHubSourceInput(input SourceUpdateInput) error {
|
||||||
|
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||||
|
return errors.New(errPagesSourceTypeUnsupported)
|
||||||
|
}
|
||||||
|
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
|
||||||
|
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
|
||||||
|
return errors.New(errPagesSourceGitHubFields)
|
||||||
|
}
|
||||||
|
if input.AutoUpdateEnabled {
|
||||||
|
return errors.New(errPagesSourceAutoNotAvailable)
|
||||||
|
}
|
||||||
|
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||||
|
if selector == "" {
|
||||||
|
selector = githubReleaseSelectorLatest
|
||||||
|
}
|
||||||
|
assetName := input.AssetName
|
||||||
|
if assetName == "" {
|
||||||
|
assetName = defaultGitHubAssetName
|
||||||
|
}
|
||||||
|
if !validGitHubAssetName(assetName) {
|
||||||
|
return errors.New(errPagesSourceAssetNameInvalid)
|
||||||
|
}
|
||||||
|
switch selector {
|
||||||
|
case githubReleaseSelectorLatest:
|
||||||
|
if input.ReleaseTag != "" {
|
||||||
|
return errors.New(errPagesSourceSelectorInvalid)
|
||||||
|
}
|
||||||
|
interval := input.CheckIntervalMinutes
|
||||||
|
if interval != 0 && (interval < minimumCheckInterval || interval > maximumCheckInterval) {
|
||||||
|
return errors.New(errPagesSourceCheckInterval)
|
||||||
|
}
|
||||||
|
case githubReleaseSelectorTag:
|
||||||
|
if !validGitHubReleaseTagConfig(input.ReleaseTag) || input.CheckIntervalMinutes != 0 {
|
||||||
|
return errors.New(errPagesSourceSelectorInvalid)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New(errPagesSourceSelectorInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGitHubSourceConfig(input SourceUpdateInput) (githubSourceConfig, error) {
|
||||||
|
repository, err := normalizeGitHubRepositoryURL(input.RepositoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return githubSourceConfig{}, err
|
||||||
|
}
|
||||||
|
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||||
|
if selector == "" {
|
||||||
|
selector = githubReleaseSelectorLatest
|
||||||
|
}
|
||||||
|
tag := input.ReleaseTag
|
||||||
|
assetName := input.AssetName
|
||||||
|
if assetName == "" {
|
||||||
|
assetName = defaultGitHubAssetName
|
||||||
|
}
|
||||||
|
interval := input.CheckIntervalMinutes
|
||||||
|
if selector == githubReleaseSelectorLatest && interval == 0 {
|
||||||
|
interval = defaultCheckInterval
|
||||||
|
}
|
||||||
|
return githubSourceConfig{
|
||||||
|
Repository: repository,
|
||||||
|
Selector: selector,
|
||||||
|
Tag: tag,
|
||||||
|
AssetName: assetName,
|
||||||
|
CheckInterval: interval,
|
||||||
|
SourceIdentity: buildGitHubSourceIdentity(repository, selector, tag, assetName),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGitHubSourceIdentity(repository, selector, tag, assetName string) string {
|
||||||
|
fields := [...]string{repository, selector, tag, assetName}
|
||||||
|
encoded := make([]byte, 0, len(githubSourceIdentityDomain)+len(fields)*8+
|
||||||
|
len(repository)+len(selector)+len(tag)+len(assetName))
|
||||||
|
encoded = append(encoded, githubSourceIdentityDomain...)
|
||||||
|
var fieldLength [8]byte
|
||||||
|
for _, field := range fields {
|
||||||
|
// Go strings hold the validated UTF-8 bytes used by GitHub. Prefixing each
|
||||||
|
// field with its byte length prevents delimiter characters from creating
|
||||||
|
// ambiguous identities across field boundaries.
|
||||||
|
binary.BigEndian.PutUint64(fieldLength[:], uint64(len(field)))
|
||||||
|
encoded = append(encoded, fieldLength[:]...)
|
||||||
|
encoded = append(encoded, field...)
|
||||||
|
}
|
||||||
|
identityHash := sha256.Sum256(encoded)
|
||||||
|
return hex.EncodeToString(identityHash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGitHubRepositoryURL(raw string) (string, error) {
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
|
||||||
|
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
|
||||||
|
strings.Contains(raw, "#") ||
|
||||||
|
parsed.EscapedPath() != parsed.Path || !strings.HasPrefix(parsed.Path, "/") ||
|
||||||
|
strings.HasPrefix(parsed.Path, "//") || strings.HasSuffix(parsed.Path, "/") {
|
||||||
|
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
|
||||||
|
if len(parts) != githubRepositoryPathParts {
|
||||||
|
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||||
|
}
|
||||||
|
owner := parts[0]
|
||||||
|
repository := parts[1]
|
||||||
|
repository = strings.TrimSuffix(repository, ".git")
|
||||||
|
if !githubOwnerPattern.MatchString(owner) || !githubRepoPattern.MatchString(repository) ||
|
||||||
|
len(repository) > 100 || repository == "." || repository == ".." {
|
||||||
|
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||||
|
}
|
||||||
|
return owner + "/" + repository, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGitHubReleaseTagConfig(value string) bool {
|
||||||
|
if !validGitHubReleaseDisplayTag(value) ||
|
||||||
|
strings.ContainsAny(value, " ~^:?*[\\") || strings.HasPrefix(value, "/") ||
|
||||||
|
strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".") ||
|
||||||
|
strings.Contains(value, "//") || strings.Contains(value, "..") || strings.Contains(value, "@{") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for component := range strings.SplitSeq(value, "/") {
|
||||||
|
if strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGitHubReleaseDisplayTag(value string) bool {
|
||||||
|
if value == "" || len(value) > 255 || !utf8.ValidString(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if unsafeGitHubInputRune(character) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGitHubAssetName(value string) bool {
|
||||||
|
if value == "" || len(value) > 255 || !utf8.ValidString(value) ||
|
||||||
|
path.Base(value) != value || strings.Contains(value, "\\") ||
|
||||||
|
value == "." || value == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if unsafeGitHubInputRune(character) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeGitHubInputRune(character rune) bool {
|
||||||
|
return unicode.IsControl(character) || character == '\u2028' || character == '\u2029' ||
|
||||||
|
character == '\u061c' || character == '\u200e' || character == '\u200f' ||
|
||||||
|
(character >= '\u202a' && character <= '\u202e') ||
|
||||||
|
(character >= '\u2066' && character <= '\u2069')
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateGitHubSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||||
|
var project model.PagesProject
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
config, err := buildGitHubSourceConfig(input)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !hasExisting {
|
||||||
|
return true, createGitHubSourceTx(tx, projectID, config)
|
||||||
|
}
|
||||||
|
if !githubSourceConfigChanged(existing, config) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var runtime model.PagesProjectSourceRuntime
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||||
|
Where("source_id = ?", existing.ID).First(&runtime).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
identityChanged := existing.SourceIdentity != config.SourceIdentity
|
||||||
|
if err := tx.Model(existing).Updates(githubSourceUpdates(config, existing.ConfigVersion+1)).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := resetRuntimeAfterGitHubUpdate(tx, &runtime, config, identityChanged); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGitHubSourceTx(tx *gorm.DB, projectID uint, config githubSourceConfig) error {
|
||||||
|
source := &model.PagesProjectSource{
|
||||||
|
ProjectID: projectID,
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
GitHubRepository: config.Repository,
|
||||||
|
ReleaseSelector: config.Selector,
|
||||||
|
ReleaseTag: config.Tag,
|
||||||
|
AssetName: config.AssetName,
|
||||||
|
AutoUpdateEnabled: false,
|
||||||
|
CheckIntervalMinutes: config.CheckInterval,
|
||||||
|
ConfigVersion: 1,
|
||||||
|
SourceIdentity: config.SourceIdentity,
|
||||||
|
}
|
||||||
|
if err := tx.Create(source).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime := &model.PagesProjectSourceRuntime{SourceID: source.ID, SyncStatus: pagesSourceStatusIdle}
|
||||||
|
if config.Selector == githubReleaseSelectorLatest {
|
||||||
|
next := nextGitHubCheckAt(time.Now(), source.ID, config.CheckInterval)
|
||||||
|
runtime.NextCheckAt = &next
|
||||||
|
}
|
||||||
|
return tx.Create(runtime).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"source_type": PagesSourceTypeGitHubRelease,
|
||||||
|
"remote_url": "",
|
||||||
|
"remote_network_policy": "",
|
||||||
|
"github_repository": config.Repository,
|
||||||
|
"release_selector": config.Selector,
|
||||||
|
"release_tag": config.Tag,
|
||||||
|
"asset_name": config.AssetName,
|
||||||
|
sourceColumnAutoUpdateEnabled: false,
|
||||||
|
"check_interval_minutes": config.CheckInterval,
|
||||||
|
sourceColumnConfigVersion: version,
|
||||||
|
"source_identity": config.SourceIdentity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
|
||||||
|
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
|
||||||
|
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
|
||||||
|
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||||
|
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled ||
|
||||||
|
existing.CheckIntervalMinutes != config.CheckInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRuntimeAfterGitHubUpdate(
|
||||||
|
tx *gorm.DB,
|
||||||
|
runtime *model.PagesProjectSourceRuntime,
|
||||||
|
config githubSourceConfig,
|
||||||
|
identityChanged bool,
|
||||||
|
) error {
|
||||||
|
if err := resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var nextCheckAt any
|
||||||
|
if config.Selector == githubReleaseSelectorLatest {
|
||||||
|
next := nextGitHubCheckAt(time.Now(), runtime.SourceID, config.CheckInterval)
|
||||||
|
nextCheckAt = &next
|
||||||
|
}
|
||||||
|
return tx.Model(runtime).Update("next_check_at", nextCheckAt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextGitHubCheckAt(now time.Time, sourceID uint, intervalMinutes int) time.Time {
|
||||||
|
// A stable, bounded offset avoids a thundering herd without persisting
|
||||||
|
// another scheduling field. Scanner Phase 3 reuses this calculation.
|
||||||
|
jitterSeconds := int64(sourceID%githubCheckJitterRange) - githubCheckJitterCenter
|
||||||
|
return now.Add(time.Duration(intervalMinutes)*time.Minute + time.Duration(jitterSeconds)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markInitialCheckDispatchFailed(ctx context.Context, sourceID uint, configVersion int) {
|
||||||
|
updates := map[string]any{
|
||||||
|
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||||
|
sourceRuntimeColumnLastError: errPagesSourceInitialCheckWarning,
|
||||||
|
}
|
||||||
|
var source model.PagesProjectSource
|
||||||
|
if err := db.DB(ctx).Where("id = ? AND config_version = ?", sourceID, configVersion).First(&source).Error; err != nil {
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
logger.ErrorF(ctx, "[PagesSource] load initial check source snapshot failed: source_id=%d error=%v", sourceID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||||
|
next := time.Now().Add(initialCheckRetryDelay)
|
||||||
|
updates["next_check_at"] = &next
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||||
|
Where("source_id = ?", sourceID).
|
||||||
|
Where("lease_expires_at IS NULL OR lease_expires_at <= ?", now).
|
||||||
|
Where("EXISTS (SELECT 1 FROM of_pages_project_sources source WHERE source.id = ? AND source.config_version = ?)", sourceID, configVersion).
|
||||||
|
Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, result.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/task"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const githubSourceDetailProvider = "github"
|
||||||
|
|
||||||
|
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
type githubSourceProviderDomainError struct {
|
||||||
|
message string
|
||||||
|
permanent bool
|
||||||
|
retryAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (domainError *githubSourceProviderDomainError) Error() string {
|
||||||
|
return domainError.message
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubReleaseAPI interface {
|
||||||
|
Resolve(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||||
|
Download(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newGitHubReleaseClient = func() githubReleaseAPI {
|
||||||
|
return githubrelease.NewClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubSourceTarget struct {
|
||||||
|
Revision string
|
||||||
|
Detail sourceDetail
|
||||||
|
DetailJSON string
|
||||||
|
Release githubrelease.Release
|
||||||
|
Asset githubrelease.Asset
|
||||||
|
RetryAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubCheckTaskResult struct {
|
||||||
|
Message string
|
||||||
|
Detail string
|
||||||
|
Stale bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedGitHubSource struct {
|
||||||
|
target *githubSourceTarget
|
||||||
|
download *githubrelease.DownloadResult
|
||||||
|
format pagesarchive.Format
|
||||||
|
manifest *deploymentManifest
|
||||||
|
ingestState *sourceIngestState
|
||||||
|
limits pagesLimits
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkGitHubSource(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
) (*githubCheckTaskResult, error) {
|
||||||
|
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
|
||||||
|
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||||
|
}
|
||||||
|
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||||
|
client := newGitHubReleaseClient()
|
||||||
|
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||||
|
Repository: snapshot.GitHubRepository,
|
||||||
|
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||||
|
Tag: snapshot.ReleaseTag,
|
||||||
|
AssetName: snapshot.AssetName,
|
||||||
|
ETag: snapshot.ETag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||||
|
retryAt, _ := githubrelease.RetryAt(err)
|
||||||
|
domainErr := githubSourceDomainError(err)
|
||||||
|
if failErr := failGitHubCheckLease(ctx, snapshot, domainErr.Error(), retryAt); failErr != nil {
|
||||||
|
if errors.Is(failErr, errSourceFinalFence) {
|
||||||
|
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||||
|
}
|
||||||
|
return nil, failErr
|
||||||
|
}
|
||||||
|
return nil, domainErr
|
||||||
|
}
|
||||||
|
if result.NotModified {
|
||||||
|
if err := finishGitHubCheckNotModified(ctx, snapshot, result); err != nil {
|
||||||
|
if errors.Is(err, errSourceFinalFence) {
|
||||||
|
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &githubCheckTaskResult{Message: "GitHub Release 检查完成,内容未变化"}, nil
|
||||||
|
}
|
||||||
|
target, err := buildGitHubSourceTarget(result.Release, result.Asset, result.RetryAt)
|
||||||
|
if err != nil {
|
||||||
|
retryAt := time.Time{}
|
||||||
|
if result.RetryAt != nil {
|
||||||
|
retryAt = result.RetryAt.UTC()
|
||||||
|
}
|
||||||
|
if failErr := failGitHubCheckLease(ctx, snapshot, err.Error(), retryAt); failErr != nil {
|
||||||
|
if errors.Is(failErr, errSourceFinalFence) {
|
||||||
|
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||||
|
}
|
||||||
|
return nil, failErr
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, err := finishGitHubCheckTarget(ctx, snapshot, result, target)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errSourceFinalFence) {
|
||||||
|
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
|
||||||
|
message := "GitHub Release 检查完成"
|
||||||
|
switch status {
|
||||||
|
case pagesSourceStatusUpdateAvailable:
|
||||||
|
message = "发现新的 GitHub Release 部署包"
|
||||||
|
case pagesSourceStatusAttention:
|
||||||
|
message = "检测到同一 Release 的资源被替换,需要确认"
|
||||||
|
}
|
||||||
|
return &githubCheckTaskResult{Message: message, Detail: string(detail)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGitHubSourceTarget(
|
||||||
|
release githubrelease.Release,
|
||||||
|
asset githubrelease.Asset,
|
||||||
|
retryAt *time.Time,
|
||||||
|
) (*githubSourceTarget, error) {
|
||||||
|
digest := strings.ToLower(strings.TrimSpace(asset.Digest))
|
||||||
|
if digest != "" && !githubDigestPattern.MatchString(digest) {
|
||||||
|
return nil, errors.New(errPagesSourceDigestInvalid)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(release.ID) == "" || strings.TrimSpace(asset.ID) == "" ||
|
||||||
|
!validGitHubReleaseDisplayTag(release.Tag) || !validGitHubAssetName(asset.Name) ||
|
||||||
|
asset.State != "uploaded" || asset.UpdatedAt.IsZero() {
|
||||||
|
return nil, errors.New(errPagesSourceReleaseNotFound)
|
||||||
|
}
|
||||||
|
updatedAt := asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
rawRevision := "github:" + release.ID + ":" + asset.ID + ":" + updatedAt + ":" + digest
|
||||||
|
sum := sha256.Sum256([]byte(rawRevision))
|
||||||
|
detail := sourceDetail{
|
||||||
|
Provider: githubSourceDetailProvider,
|
||||||
|
Tag: release.Tag,
|
||||||
|
AssetName: asset.Name,
|
||||||
|
ReleaseID: release.ID,
|
||||||
|
AssetID: asset.ID,
|
||||||
|
AssetUpdatedAt: updatedAt,
|
||||||
|
Digest: digest,
|
||||||
|
}
|
||||||
|
detailJSON, err := json.Marshal(detail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errPagesSourceSyncFailed)
|
||||||
|
}
|
||||||
|
return &githubSourceTarget{
|
||||||
|
Revision: hex.EncodeToString(sum[:]),
|
||||||
|
Detail: detail,
|
||||||
|
DetailJSON: string(detailJSON),
|
||||||
|
Release: release,
|
||||||
|
Asset: asset,
|
||||||
|
RetryAt: retryAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishGitHubCheckNotModified(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
result githubrelease.ResolveResult,
|
||||||
|
) error {
|
||||||
|
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||||
|
updates["etag"] = result.ETag
|
||||||
|
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
|
||||||
|
return tx.Model(runtime).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishGitHubCheckTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
result githubrelease.ResolveResult,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
) (string, error) {
|
||||||
|
status := pagesSourceStatusIdle
|
||||||
|
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status = targetRuntimeStatus(target, runtime.LastAppliedRevision, runtime.LastAppliedDetail)
|
||||||
|
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||||
|
updates["etag"] = result.ETag
|
||||||
|
updates["last_seen_revision"] = target.Revision
|
||||||
|
updates["last_seen_detail"] = target.DetailJSON
|
||||||
|
updates[sourceRuntimeColumnSyncStatus] = status
|
||||||
|
return tx.Model(runtime).Updates(updates).Error
|
||||||
|
})
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubCheckTerminalUpdates(
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
now time.Time,
|
||||||
|
retryAt *time.Time,
|
||||||
|
) map[string]any {
|
||||||
|
updates := map[string]any{
|
||||||
|
sourceRuntimeColumnLastError: "",
|
||||||
|
sourceRuntimeColumnLastCheckedAt: &now,
|
||||||
|
sourceRuntimeColumnLeaseToken: "",
|
||||||
|
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||||
|
}
|
||||||
|
updates["next_check_at"] = nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextCheckAfterGitHubResponse(
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
now time.Time,
|
||||||
|
retryAt *time.Time,
|
||||||
|
) any {
|
||||||
|
if snapshot.ReleaseSelector != githubReleaseSelectorLatest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
next := nextGitHubCheckAt(now, snapshot.SourceID, snapshot.CheckIntervalMinutes)
|
||||||
|
if retryAt != nil && retryAt.After(next) {
|
||||||
|
next = retryAt.UTC()
|
||||||
|
}
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockOwnedSourceRuntime(
|
||||||
|
tx *gorm.DB,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
) (*model.PagesProjectSourceRuntime, time.Time, error) {
|
||||||
|
var runtime model.PagesProjectSourceRuntime
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||||
|
Where("source_id = ?", snapshot.SourceID).First(&runtime).Error; err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if runtime.LeaseToken != snapshot.LeaseToken || runtime.LeaseExpiresAt == nil ||
|
||||||
|
!runtime.LeaseExpiresAt.After(now) {
|
||||||
|
return nil, time.Time{}, errSourceFinalFence
|
||||||
|
}
|
||||||
|
return &runtime, now, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func failGitHubCheckLease(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
message string,
|
||||||
|
retryAt time.Time,
|
||||||
|
) error {
|
||||||
|
now := time.Now()
|
||||||
|
next := now.Add(initialCheckRetryDelay)
|
||||||
|
if retryAt.After(next) {
|
||||||
|
next = retryAt.UTC()
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||||
|
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
|
||||||
|
sourceRuntimeColumnLastCheckedAt: &now,
|
||||||
|
sourceRuntimeColumnLeaseToken: "",
|
||||||
|
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||||
|
}
|
||||||
|
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||||
|
updates["next_check_at"] = &next
|
||||||
|
} else {
|
||||||
|
updates["next_check_at"] = nil
|
||||||
|
}
|
||||||
|
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||||
|
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||||
|
Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected != 1 {
|
||||||
|
return errSourceFinalFence
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetRuntimeStatus(
|
||||||
|
target *githubSourceTarget,
|
||||||
|
appliedRevision string,
|
||||||
|
appliedDetail string,
|
||||||
|
) string {
|
||||||
|
if target == nil || target.Revision == appliedRevision {
|
||||||
|
return pagesSourceStatusIdle
|
||||||
|
}
|
||||||
|
applied := sourceDetail{}
|
||||||
|
if unmarshalSourceDetail(appliedDetail, &applied) == nil && target.Detail.ReleaseID != "" &&
|
||||||
|
target.Detail.ReleaseID == applied.ReleaseID {
|
||||||
|
return pagesSourceStatusAttention
|
||||||
|
}
|
||||||
|
return pagesSourceStatusUpdateAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func preflightGitHubSyncConfirmation(ctx context.Context, sourceID uint, confirmedRevision string) error {
|
||||||
|
var runtime model.PagesProjectSourceRuntime
|
||||||
|
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
replacement := sourceHasSameReleaseReplacement(&runtime)
|
||||||
|
if replacement && confirmedRevision == "" {
|
||||||
|
return errors.New(errPagesSourceConfirmationNeeded)
|
||||||
|
}
|
||||||
|
if confirmedRevision != "" && (!replacement || confirmedRevision != runtime.LastSeenRevision) {
|
||||||
|
return errors.New(errPagesSourceConfirmationStale)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncGitHubSource(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
actor string,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
|
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||||
|
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
|
||||||
|
return nil, errors.New(errPagesSourceActionInvalid)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
resultErr = finalizeGitHubSyncFailure(ctx, snapshot, resultErr)
|
||||||
|
}()
|
||||||
|
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
|
||||||
|
ctx, snapshot, pagesSourceSyncLeaseDuration, pagesSourceHeartbeatInterval,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return sourceHeartbeatOutcome(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = heartbeat.stop() }()
|
||||||
|
|
||||||
|
client := newGitHubReleaseClient()
|
||||||
|
target, guardedOutcome, err := resolveAndGuardGitHubSync(
|
||||||
|
workCtx, client, snapshot, targetRevision, confirmedRevision,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if guardedOutcome != nil {
|
||||||
|
return guardedOutcome, nil
|
||||||
|
}
|
||||||
|
prepared, err := prepareGitHubSyncPackage(workCtx, client, snapshot, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cleanupErr := prepared.download.Cleanup(); cleanupErr != nil {
|
||||||
|
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer compensateSourceIngest(ctx, snapshot, prepared.ingestState)
|
||||||
|
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||||
|
return sourceHeartbeatOutcome(heartbeatErr)
|
||||||
|
}
|
||||||
|
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !renewed {
|
||||||
|
return &sourceSyncOutcome{Stale: true}, nil
|
||||||
|
}
|
||||||
|
return activatePreparedGitHubSource(ctx, snapshot, actor, prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeGitHubSyncFailure(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
resultErr error,
|
||||||
|
) error {
|
||||||
|
if resultErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
finalizerErr := persistGitHubSyncFailure(cleanupCtx, snapshot, resultErr)
|
||||||
|
if finalizerErr == nil {
|
||||||
|
return resultErr
|
||||||
|
}
|
||||||
|
logger.WarnF(
|
||||||
|
cleanupCtx,
|
||||||
|
"[PagesSource] finalize GitHub sync failure failed: source_id=%d source_error=%s error=%v",
|
||||||
|
snapshot.SourceID, safeGitHubSourceError(resultErr), finalizerErr,
|
||||||
|
)
|
||||||
|
// final fence 丢失表示已有新任务接管 runtime,不应覆盖;数据库
|
||||||
|
// finalizer 失败则保持可重试,避免继承永久错误或 provider deadline 分类。
|
||||||
|
if errors.Is(finalizerErr, errSourceFinalFence) {
|
||||||
|
return resultErr
|
||||||
|
}
|
||||||
|
return errors.New(errPagesSourceSyncFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistGitHubSyncFailure(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
resultErr error,
|
||||||
|
) error {
|
||||||
|
var domainError *githubSourceProviderDomainError
|
||||||
|
if errors.As(resultErr, &domainError) && domainError.retryAt != nil {
|
||||||
|
return failGitHubCheckLease(ctx, snapshot, domainError.message, *domainError.retryAt)
|
||||||
|
}
|
||||||
|
return failSourceLease(ctx, snapshot, safeGitHubSourceError(resultErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func activatePreparedGitHubSource(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
actor string,
|
||||||
|
prepared *preparedGitHubSource,
|
||||||
|
) (*sourceSyncOutcome, error) {
|
||||||
|
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
|
||||||
|
deployment, reused, referenced, err := commitSourceDeployment(
|
||||||
|
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
|
||||||
|
prepared.target.Detail, prepared.target.DetailJSON, actor, prepared.manifest,
|
||||||
|
prepared.ingestState.Result, prepared.ingestState.HasIngest, prepared.target.RetryAt,
|
||||||
|
)
|
||||||
|
prepared.ingestState.Referenced = referenced
|
||||||
|
if errors.Is(err, errSourceFinalFence) {
|
||||||
|
return &sourceSyncOutcome{Stale: true}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prepared.ingestState.Referenced = prepared.ingestState.HasIngest && deployment.UploadID == prepared.ingestState.Result.Upload.ID
|
||||||
|
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, prepared.limits.HistoryCount, 0); pruneErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[PagesSource] strict prune failed after GitHub sync: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, pruneErr)
|
||||||
|
}
|
||||||
|
view := buildDeploymentView(deployment)
|
||||||
|
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAndGuardGitHubSync(
|
||||||
|
ctx context.Context,
|
||||||
|
client githubReleaseAPI,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
|
) (*githubSourceTarget, *sourceSyncOutcome, error) {
|
||||||
|
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||||
|
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||||
|
Repository: snapshot.GitHubRepository,
|
||||||
|
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||||
|
Tag: snapshot.ReleaseTag,
|
||||||
|
AssetName: snapshot.AssetName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||||
|
return nil, nil, githubSourceDomainError(err)
|
||||||
|
}
|
||||||
|
if resolved.NotModified {
|
||||||
|
return nil, nil, errors.New(errPagesSourceReleaseNotFound)
|
||||||
|
}
|
||||||
|
target, err := buildGitHubSourceTarget(resolved.Release, resolved.Asset, resolved.RetryAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, &githubSourceProviderDomainError{
|
||||||
|
message: safeGitHubSourceError(err),
|
||||||
|
permanent: isPermanentSourceSyncError(err),
|
||||||
|
retryAt: resolved.RetryAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guardedOutcome, err := guardGitHubSyncTarget(ctx, snapshot, target, targetRevision, confirmedRevision)
|
||||||
|
return target, guardedOutcome, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardGitHubSyncTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
|
) (*sourceSyncOutcome, error) {
|
||||||
|
status := targetRuntimeStatus(target, snapshot.LastAppliedRevision, snapshot.LastAppliedDetail)
|
||||||
|
if targetRevision != "" && targetRevision != target.Revision {
|
||||||
|
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, "", true, true)
|
||||||
|
}
|
||||||
|
if confirmedRevision != "" && (confirmedRevision != snapshot.LastSeenRevision || confirmedRevision != target.Revision) {
|
||||||
|
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationStale, false, false)
|
||||||
|
}
|
||||||
|
if status == pagesSourceStatusAttention && confirmedRevision != target.Revision {
|
||||||
|
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationNeeded, false, false)
|
||||||
|
}
|
||||||
|
if confirmedRevision != "" && status != pagesSourceStatusAttention {
|
||||||
|
return nil, errors.New(errPagesSourceConfirmationStale)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseGuardedGitHubTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
status string,
|
||||||
|
lastError string,
|
||||||
|
expedite bool,
|
||||||
|
staleSuccess bool,
|
||||||
|
) (*sourceSyncOutcome, error) {
|
||||||
|
err := releaseGitHubSyncWithoutActivation(ctx, snapshot, target, status, lastError, expedite, target.RetryAt)
|
||||||
|
if errors.Is(err, errSourceFinalFence) || (err == nil && staleSuccess) {
|
||||||
|
return &sourceSyncOutcome{Stale: true}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errors.New(lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareGitHubSyncPackage(
|
||||||
|
ctx context.Context,
|
||||||
|
client githubReleaseAPI,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
) (*preparedGitHubSource, error) {
|
||||||
|
limits := resolvePagesLimits(ctx)
|
||||||
|
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||||
|
download, err := client.Download(ctx, githubrelease.DownloadRequest{
|
||||||
|
Repository: snapshot.GitHubRepository,
|
||||||
|
Asset: target.Asset,
|
||||||
|
MaxBytes: limits.PackageBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnF(ctx, "[PagesSource] GitHub download failed: source_id=%d repo=%s asset=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, snapshot.AssetName, err)
|
||||||
|
return nil, githubSourceDomainError(err)
|
||||||
|
}
|
||||||
|
prepared, err := inspectAndIngestGitHubPackage(ctx, snapshot, target, download, limits)
|
||||||
|
if err != nil {
|
||||||
|
if cleanupErr := download.Cleanup(); cleanupErr != nil {
|
||||||
|
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package after preparation failure failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectAndIngestGitHubPackage(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
download *githubrelease.DownloadResult,
|
||||||
|
limits pagesLimits,
|
||||||
|
) (*preparedGitHubSource, error) {
|
||||||
|
if download.SHA256 == "" || download.Path == "" {
|
||||||
|
return nil, errors.New(errPagesSourceSyncFailed)
|
||||||
|
}
|
||||||
|
if target.Detail.Digest != "" && "sha256:"+download.SHA256 != target.Detail.Digest {
|
||||||
|
return nil, errors.New(errPagesSourceDigestMismatch)
|
||||||
|
}
|
||||||
|
format, ok := pagesarchive.DetectFormatFromName(target.Asset.Name)
|
||||||
|
var err error
|
||||||
|
if !ok {
|
||||||
|
format, _, err = detectRemoteSourceFormat(download.Path, target.Asset.Name, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
|
||||||
|
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ingestState, err := resolveGitHubSourceIngest(ctx, snapshot, target, download, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &preparedGitHubSource{
|
||||||
|
target: target, download: download, format: format,
|
||||||
|
manifest: manifest, ingestState: ingestState, limits: limits,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGitHubSourceIngest(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
download *githubrelease.DownloadResult,
|
||||||
|
format pagesarchive.Format,
|
||||||
|
) (*sourceIngestState, error) {
|
||||||
|
if _, err := findSourceDeployment(ctx, snapshot.ProjectID, snapshot.SourceIdentity, target.Revision); err == nil {
|
||||||
|
return &sourceIngestState{}, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
|
||||||
|
result, err := ingestPagesDeploymentPackageWithSource(
|
||||||
|
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sourceIngestState{Result: result, HasIngest: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseGitHubSyncWithoutActivation(
|
||||||
|
ctx context.Context,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
target *githubSourceTarget,
|
||||||
|
status string,
|
||||||
|
lastError string,
|
||||||
|
expedite bool,
|
||||||
|
retryAt *time.Time,
|
||||||
|
) error {
|
||||||
|
now := time.Now()
|
||||||
|
nextCheckAt := nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||||
|
if expedite && snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||||
|
next := now.Add(initialCheckRetryDelay)
|
||||||
|
if retryAt != nil && retryAt.After(next) {
|
||||||
|
next = retryAt.UTC()
|
||||||
|
}
|
||||||
|
nextCheckAt = &next
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"last_seen_revision": target.Revision,
|
||||||
|
"last_seen_detail": target.DetailJSON,
|
||||||
|
sourceRuntimeColumnSyncStatus: status,
|
||||||
|
sourceRuntimeColumnLastError: lastError,
|
||||||
|
sourceRuntimeColumnLastCheckedAt: &now,
|
||||||
|
"next_check_at": nextCheckAt,
|
||||||
|
sourceRuntimeColumnLeaseToken: "",
|
||||||
|
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||||
|
}
|
||||||
|
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||||
|
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||||
|
Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected != 1 {
|
||||||
|
return errSourceFinalFence
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeGitHubSourceError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return errPagesSourceSyncFailed
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(err.Error())
|
||||||
|
for _, safeMessage := range []string{
|
||||||
|
errPagesSourceSyncFailed,
|
||||||
|
errPagesSourceReleaseNotFound,
|
||||||
|
errPagesSourceDigestInvalid,
|
||||||
|
errPagesSourceDigestMismatch,
|
||||||
|
errPagesSourceConfirmationNeeded,
|
||||||
|
errPagesSourceConfirmationStale,
|
||||||
|
errPagesPackageURLTooLarge,
|
||||||
|
errPagesPackageEmpty,
|
||||||
|
errPagesPackageUnsupported,
|
||||||
|
errPagesPackageInvalid,
|
||||||
|
errPagesPackageExtractedTooLarge,
|
||||||
|
errPagesPackageFileTooLarge,
|
||||||
|
errPagesEntryFileMissing,
|
||||||
|
} {
|
||||||
|
if message == safeMessage {
|
||||||
|
return safeMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errPagesSourceSyncFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubSourceDomainError(err error) error {
|
||||||
|
message := errPagesSourceSyncFailed
|
||||||
|
retryAt, hasRetryAt := githubrelease.RetryAt(err)
|
||||||
|
var retryDeadline *time.Time
|
||||||
|
if hasRetryAt {
|
||||||
|
retryDeadline = &retryAt
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return &githubSourceProviderDomainError{message: message, permanent: false}
|
||||||
|
}
|
||||||
|
if githubrelease.IsDigestError(err) {
|
||||||
|
message = errPagesSourceDigestMismatch
|
||||||
|
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||||
|
}
|
||||||
|
if githubrelease.IsNotFound(err) {
|
||||||
|
message = errPagesSourceReleaseNotFound
|
||||||
|
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||||
|
}
|
||||||
|
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
|
||||||
|
message = errPagesPackageURLTooLarge
|
||||||
|
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||||
|
}
|
||||||
|
if errors.Is(err, githubrelease.ErrEmptyAsset) {
|
||||||
|
message = errPagesPackageEmpty
|
||||||
|
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||||
|
}
|
||||||
|
return &githubSourceProviderDomainError{
|
||||||
|
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipGitHubActionRetry(err error) bool {
|
||||||
|
var domainError *githubSourceProviderDomainError
|
||||||
|
return errors.As(err, &domainError) && (domainError.permanent || domainError.retryAt != nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) {
|
||||||
|
firstInput := SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||||
|
ReleaseSelector: githubReleaseSelectorTag,
|
||||||
|
ReleaseTag: "release|foo",
|
||||||
|
AssetName: "bar.zip",
|
||||||
|
}
|
||||||
|
secondInput := SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||||
|
ReleaseSelector: githubReleaseSelectorTag,
|
||||||
|
ReleaseTag: "release",
|
||||||
|
AssetName: "foo|bar.zip",
|
||||||
|
}
|
||||||
|
firstConfig, err := buildGitHubSourceConfig(firstInput)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildGitHubSourceConfig(first) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
secondConfig, err := buildGitHubSourceConfig(secondInput)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildGitHubSourceConfig(second) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
legacyIdentityInput := func(config githubSourceConfig) string {
|
||||||
|
return "github|" + config.Repository + "|" + config.Selector + "|" +
|
||||||
|
config.Tag + "|" + config.AssetName
|
||||||
|
}
|
||||||
|
if firstLegacy, secondLegacy := legacyIdentityInput(firstConfig), legacyIdentityInput(secondConfig); firstLegacy != secondLegacy {
|
||||||
|
t.Fatalf("legacy identity inputs differ: %q != %q; collision fixture is invalid", firstLegacy, secondLegacy)
|
||||||
|
}
|
||||||
|
if firstConfig.SourceIdentity == secondConfig.SourceIdentity {
|
||||||
|
t.Fatalf("length-prefixed identities collide: %q", firstConfig.SourceIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-identity-collision")
|
||||||
|
firstSource, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, firstInput)
|
||||||
|
if got, want := firstSource.SourceIdentity, firstConfig.SourceIdentity; got != want {
|
||||||
|
t.Fatalf("first source identity = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedAt := time.Now().Add(-time.Minute)
|
||||||
|
syncedAt := time.Now().Add(-30 * time.Second)
|
||||||
|
nextCheckAt := time.Now().Add(time.Hour)
|
||||||
|
leaseExpiresAt := time.Now().Add(time.Minute)
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||||
|
Where("source_id = ?", firstSource.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"etag": `"old-etag"`,
|
||||||
|
"last_seen_revision": strings.Repeat("a", 64),
|
||||||
|
"last_seen_detail": `{"provider":"github_release","tag":"release|foo"}`,
|
||||||
|
"last_applied_revision": strings.Repeat("b", 64),
|
||||||
|
"last_applied_detail": `{"provider":"github_release","tag":"older"}`,
|
||||||
|
"sync_status": pagesSourceStatusSyncing,
|
||||||
|
"last_error": "old error",
|
||||||
|
"last_checked_at": &checkedAt,
|
||||||
|
"last_synced_at": &syncedAt,
|
||||||
|
"next_check_at": &nextCheckAt,
|
||||||
|
"lease_expires_at": &leaseExpiresAt,
|
||||||
|
"lease_token": "old-lease",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed runtime cursors error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondSource, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, secondInput)
|
||||||
|
if secondSource.ID != firstSource.ID {
|
||||||
|
t.Errorf("updated source ID = %d, want unchanged %d", secondSource.ID, firstSource.ID)
|
||||||
|
}
|
||||||
|
if got, want := secondSource.SourceIdentity, secondConfig.SourceIdentity; got != want {
|
||||||
|
t.Errorf("updated source identity = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := secondSource.ConfigVersion, firstSource.ConfigVersion+1; got != want {
|
||||||
|
t.Errorf("updated source config version = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if runtime.ETag != "" || runtime.LastSeenRevision != "" || runtime.LastSeenDetail != "" ||
|
||||||
|
runtime.LastAppliedRevision != "" || runtime.LastAppliedDetail != "" {
|
||||||
|
t.Errorf("identity change retained runtime cursors: %+v", runtime)
|
||||||
|
}
|
||||||
|
if runtime.LastCheckedAt != nil || runtime.LastSyncedAt != nil || runtime.NextCheckAt != nil {
|
||||||
|
t.Errorf(
|
||||||
|
"identity change retained runtime timestamps: checked=%v synced=%v next=%v",
|
||||||
|
runtime.LastCheckedAt,
|
||||||
|
runtime.LastSyncedAt,
|
||||||
|
runtime.NextCheckAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" ||
|
||||||
|
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||||
|
t.Errorf(
|
||||||
|
"identity change retained runtime state: status=%q error=%q lease=(%q, %v)",
|
||||||
|
runtime.SyncStatus,
|
||||||
|
runtime.LastError,
|
||||||
|
runtime.LeaseToken,
|
||||||
|
runtime.LeaseExpiresAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,888 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeGitHubReleaseClient struct {
|
||||||
|
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||||
|
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeGitHubReleaseClient) Resolve(
|
||||||
|
ctx context.Context,
|
||||||
|
request githubrelease.ResolveRequest,
|
||||||
|
) (githubrelease.ResolveResult, error) {
|
||||||
|
return client.resolve(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeGitHubReleaseClient) Download(
|
||||||
|
ctx context.Context,
|
||||||
|
request githubrelease.DownloadRequest,
|
||||||
|
) (*githubrelease.DownloadResult, error) {
|
||||||
|
return client.download(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
|
||||||
|
t.Helper()
|
||||||
|
previous := newGitHubReleaseClient
|
||||||
|
newGitHubReleaseClient = func() githubReleaseAPI { return client }
|
||||||
|
t.Cleanup(func() { newGitHubReleaseClient = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustConfigureGitHubSourceWithoutDispatch(
|
||||||
|
t *testing.T,
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uint,
|
||||||
|
input SourceUpdateInput,
|
||||||
|
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||||
|
t.Helper()
|
||||||
|
if err := validateGitHubSourceInput(input); err != nil {
|
||||||
|
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, err := updateGitHubSourceTx(tx, projectID, input)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
|
||||||
|
}
|
||||||
|
return mustLoadPagesSource(t, ctx, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLoadPagesSource(
|
||||||
|
t *testing.T,
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uint,
|
||||||
|
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||||
|
t.Helper()
|
||||||
|
var source model.PagesProjectSource
|
||||||
|
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
||||||
|
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
|
||||||
|
}
|
||||||
|
var runtime model.PagesProjectSourceRuntime
|
||||||
|
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||||
|
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
|
||||||
|
}
|
||||||
|
return &source, &runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
setupPagesSourceDispatchTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-config")
|
||||||
|
input := SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/OpenFlare/site.git",
|
||||||
|
}
|
||||||
|
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
|
||||||
|
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
|
||||||
|
}
|
||||||
|
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
|
||||||
|
}
|
||||||
|
var actionPayload SourceActionPayload
|
||||||
|
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
|
||||||
|
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
|
||||||
|
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
|
||||||
|
}
|
||||||
|
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
|
||||||
|
t.Errorf("GitHubRepository = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
|
||||||
|
t.Errorf("ReleaseSelector = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
|
||||||
|
t.Errorf("AssetName = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
|
||||||
|
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
|
||||||
|
t.Errorf("SourceIdentity = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if runtime.NextCheckAt == nil {
|
||||||
|
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskCount int64
|
||||||
|
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
|
||||||
|
t.Fatalf("count initial checks error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||||
|
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
var noOpTaskCount int64
|
||||||
|
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
|
||||||
|
t.Fatalf("count no-op checks error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if noOpTaskCount != taskCount {
|
||||||
|
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := "provider-switch-secret"
|
||||||
|
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeRemoteURL,
|
||||||
|
RemoteURLSet: true,
|
||||||
|
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||||
|
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
|
||||||
|
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
|
||||||
|
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
|
||||||
|
}
|
||||||
|
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||||
|
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
github, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
|
||||||
|
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
|
||||||
|
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
}, "user:9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
|
||||||
|
}
|
||||||
|
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
|
||||||
|
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
|
||||||
|
}
|
||||||
|
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
|
||||||
|
runtime.LastError != errPagesSourceInitialCheckWarning {
|
||||||
|
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSourceRejectsUnsafeOrPhaseThreeFields(t *testing.T) {
|
||||||
|
tests := []SourceUpdateInput{
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AutoUpdateEnabled: true},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
|
||||||
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
|
||||||
|
}
|
||||||
|
for _, input := range tests {
|
||||||
|
if err := validateGitHubSourceInput(input); err == nil {
|
||||||
|
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
|
||||||
|
tests := []SourceUpdateInput{
|
||||||
|
{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
AssetName: "dist?channel=stable&part#1.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
AssetName: " dist.zip ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
ReleaseSelector: "tag",
|
||||||
|
ReleaseTag: "release/v1#stable&build=1",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b.git",
|
||||||
|
ReleaseSelector: "tag",
|
||||||
|
ReleaseTag: "@",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
ReleaseSelector: "tag",
|
||||||
|
ReleaseTag: "release/v1.LOCK",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, input := range tests {
|
||||||
|
if err := validateGitHubSourceInput(input); err != nil {
|
||||||
|
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
staleVersion := source.ConfigVersion
|
||||||
|
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
|
||||||
|
t.Fatalf("increment source config version error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
|
||||||
|
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-check")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
appliedRevision := strings.Repeat("a", 64)
|
||||||
|
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||||
|
"etag": `"old-etag"`,
|
||||||
|
"last_applied_revision": appliedRevision,
|
||||||
|
"last_applied_detail": appliedDetail,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
var gotETag string
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
gotETag = request.ETag
|
||||||
|
return githubrelease.ResolveResult{
|
||||||
|
ETag: `"new-etag"`,
|
||||||
|
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
|
||||||
|
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called during check, want resolve only")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||||
|
if err != nil || outcome != sourceLeaseAcquired {
|
||||||
|
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
|
||||||
|
}
|
||||||
|
result, err := checkGitHubSource(ctx, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if result.Stale {
|
||||||
|
t.Error("checkGitHubSource() stale = true, want false")
|
||||||
|
}
|
||||||
|
if got, want := gotETag, `"old-etag"`; got != want {
|
||||||
|
t.Errorf("Resolve ETag = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
|
||||||
|
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
|
||||||
|
}
|
||||||
|
view, err := GetSource(ctx, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSource() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
|
||||||
|
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
|
||||||
|
}
|
||||||
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
|
||||||
|
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
|
||||||
|
}
|
||||||
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
|
||||||
|
t.Errorf("preflight exact confirmation error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-304")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called for 304 check")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||||
|
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
|
||||||
|
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||||
|
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
|
||||||
|
}
|
||||||
|
var deployments int64
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||||
|
t.Fatalf("count deployments error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if deployments != 0 {
|
||||||
|
t.Errorf("deployments after check = %d, want 0", deployments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
appliedRevision := strings.Repeat("a", 64)
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||||
|
"last_applied_revision": appliedRevision,
|
||||||
|
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed applied runtime error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{
|
||||||
|
Release: githubrelease.Release{ID: "100", Tag: "v1"},
|
||||||
|
Asset: githubrelease.Asset{
|
||||||
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||||
|
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
RetryAt: &retryAt,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called after target mismatch")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
|
||||||
|
}
|
||||||
|
if outcome == nil || !outcome.Stale {
|
||||||
|
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusAttention {
|
||||||
|
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
|
||||||
|
}
|
||||||
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||||
|
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||||
|
}
|
||||||
|
var deployments int64
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||||
|
t.Fatalf("count mismatch deployments error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if deployments != 0 {
|
||||||
|
t.Errorf("target mismatch deployments = %d, want 0", deployments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
return nil, errors.New("unexpected")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||||
|
"lease_token": "new-owner",
|
||||||
|
"lease_expires_at": time.Now().Add(time.Minute),
|
||||||
|
"sync_status": pagesSourceStatusSyncing,
|
||||||
|
"last_error": "new-owner-state",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("replace lease owner error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
result, err := checkGitHubSource(ctx, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
|
||||||
|
}
|
||||||
|
if result == nil || !result.Stale {
|
||||||
|
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
|
||||||
|
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||||
|
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
return nil, errors.New("unexpected")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||||
|
_, err := checkGitHubSource(ctx, snapshot)
|
||||||
|
if err == nil || err.Error() != errPagesSourceSyncFailed {
|
||||||
|
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
|
||||||
|
}
|
||||||
|
if !shouldSkipGitHubActionRetry(err) {
|
||||||
|
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
|
||||||
|
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{
|
||||||
|
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||||
|
Asset: githubrelease.Asset{
|
||||||
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||||
|
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||||
|
},
|
||||||
|
RetryAt: &retryAt,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called after invalid check target")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||||
|
_, err := checkGitHubSource(ctx, snapshot)
|
||||||
|
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||||
|
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||||
|
}
|
||||||
|
if !isPermanentSourceSyncError(err) {
|
||||||
|
t.Error("invalid check target classification = retryable, want permanent")
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||||
|
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||||
|
t.Errorf(
|
||||||
|
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||||
|
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||||
|
errPagesSourceDigestInvalid, retryAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{
|
||||||
|
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||||
|
Asset: githubrelease.Asset{
|
||||||
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||||
|
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||||
|
},
|
||||||
|
RetryAt: &retryAt,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called after invalid sync target")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||||
|
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||||
|
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||||
|
}
|
||||||
|
if !isPermanentSourceSyncError(err) {
|
||||||
|
t.Error("invalid sync target classification = retryable, want permanent")
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||||
|
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||||
|
t.Errorf(
|
||||||
|
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||||
|
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||||
|
errPagesSourceDigestInvalid, retryAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
retryDate bool
|
||||||
|
}{
|
||||||
|
{name: "bad request", status: http.StatusBadRequest},
|
||||||
|
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
|
||||||
|
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
var retryAt *time.Time
|
||||||
|
if test.retryDate {
|
||||||
|
deadline := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
retryAt = &deadline
|
||||||
|
}
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||||
|
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called after provider check failure")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, err := json.Marshal(SourceActionPayload{
|
||||||
|
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
|
||||||
|
Action: sourceActionCheck, Actor: "user:7",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
|
||||||
|
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
|
||||||
|
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||||
|
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
|
||||||
|
}
|
||||||
|
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
|
||||||
|
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceSyncTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
|
||||||
|
packageHash := sha256.Sum256(packageBytes)
|
||||||
|
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
|
||||||
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
|
||||||
|
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
|
||||||
|
client := &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
|
||||||
|
},
|
||||||
|
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
path := filepath.Join(t.TempDir(), "download")
|
||||||
|
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
return &githubrelease.DownloadResult{
|
||||||
|
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
useFakeGitHubReleaseClient(t, client)
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||||
|
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
|
||||||
|
}
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
|
||||||
|
}
|
||||||
|
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
|
||||||
|
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
|
||||||
|
}
|
||||||
|
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
|
||||||
|
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
|
||||||
|
}
|
||||||
|
if got, want := deployment.SourceLabel, "release/v2"; got != want {
|
||||||
|
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
|
||||||
|
deployment.CreatedBy != "user:7" {
|
||||||
|
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
|
||||||
|
}
|
||||||
|
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
|
||||||
|
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
|
||||||
|
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||||
|
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
|
||||||
|
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceSyncTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
release := githubrelease.Release{ID: "300", Tag: "v3"}
|
||||||
|
asset := githubrelease.Asset{
|
||||||
|
ID: "12", Name: "dist.zip", State: "uploaded",
|
||||||
|
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
target, err := buildGitHubSourceTarget(release, asset, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||||
|
"last_seen_revision": target.Revision,
|
||||||
|
"last_seen_detail": target.DetailJSON,
|
||||||
|
"last_applied_revision": strings.Repeat("a", 64),
|
||||||
|
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
|
||||||
|
"sync_status": pagesSourceStatusAttention,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed replacement cursor error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
|
||||||
|
packageHash := sha256.Sum256(packageBytes)
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
path := filepath.Join(t.TempDir(), "confirmed.zip")
|
||||||
|
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
return &githubrelease.DownloadResult{
|
||||||
|
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||||
|
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
|
||||||
|
}
|
||||||
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||||
|
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
|
||||||
|
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
|
||||||
|
handler := &SourceActionHandler{}
|
||||||
|
revision := strings.Repeat("a", 64)
|
||||||
|
invalid := []SourceActionPayload{
|
||||||
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
|
||||||
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
|
||||||
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
|
||||||
|
}
|
||||||
|
for _, payload := range invalid {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
if normalized, err := handler.ValidatePayload(raw); err == nil {
|
||||||
|
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valid := []SourceActionPayload{
|
||||||
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision},
|
||||||
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", ConfirmedRevision: revision},
|
||||||
|
}
|
||||||
|
for _, payload := range valid {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
if _, err := handler.ValidatePayload(raw); err != nil {
|
||||||
|
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
provider error
|
||||||
|
want string
|
||||||
|
permanent bool
|
||||||
|
skipRetry bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
|
||||||
|
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
|
||||||
|
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rate limit", provider: &githubrelease.Error{
|
||||||
|
Kind: githubrelease.ErrMetadata, StatusCode: 429,
|
||||||
|
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
|
||||||
|
},
|
||||||
|
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
|
||||||
|
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
|
||||||
|
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
domainErr := githubSourceDomainError(test.provider)
|
||||||
|
if got := domainErr.Error(); got != test.want {
|
||||||
|
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
var typedDomainError *githubSourceProviderDomainError
|
||||||
|
if !errors.As(domainErr, &typedDomainError) {
|
||||||
|
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
|
||||||
|
}
|
||||||
|
if got := typedDomainError.permanent; got != test.permanent {
|
||||||
|
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
|
||||||
|
}
|
||||||
|
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
|
||||||
|
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
|
||||||
|
}
|
||||||
|
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
|
||||||
|
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
|
||||||
|
ctx := setupPagesSourceSyncTest(t)
|
||||||
|
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
|
||||||
|
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
|
||||||
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||||
|
SourceType: PagesSourceTypeGitHubRelease,
|
||||||
|
RepositoryURL: "https://github.com/a/b",
|
||||||
|
})
|
||||||
|
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
|
||||||
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||||
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||||
|
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
|
||||||
|
},
|
||||||
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||||
|
t.Fatal("Download called for stale confirmation")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||||
|
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
|
||||||
|
if err == nil || err.Error() != errPagesSourceConfirmationStale {
|
||||||
|
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
|
||||||
|
}
|
||||||
|
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
|
||||||
|
}
|
||||||
|
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
|
||||||
|
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -822,8 +822,8 @@ func fenceDeploymentActivationSource(
|
|||||||
audit.SourceIdentity = state.Source.SourceIdentity
|
audit.SourceIdentity = state.Source.SourceIdentity
|
||||||
audit.AutoDisabled = state.Source.AutoUpdateEnabled
|
audit.AutoDisabled = state.Source.AutoUpdateEnabled
|
||||||
if err := tx.Model(state.Source).Updates(map[string]any{
|
if err := tx.Model(state.Source).Updates(map[string]any{
|
||||||
"config_version": state.Source.ConfigVersion + 1,
|
sourceColumnConfigVersion: state.Source.ConfigVersion + 1,
|
||||||
"auto_update_enabled": false,
|
sourceColumnAutoUpdateEnabled: false,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -856,8 +856,8 @@ func switchActiveDeploymentTx(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(deployment).Updates(map[string]any{
|
if err := tx.Model(deployment).Updates(map[string]any{
|
||||||
"status": model.PagesDeploymentStatusActive,
|
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||||
"activated_at": &now,
|
"activated_at": &now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
|
|||||||
errPagesSourceRemoteURLMode,
|
errPagesSourceRemoteURLMode,
|
||||||
errPagesSourceRemoteURLInvalid,
|
errPagesSourceRemoteURLInvalid,
|
||||||
errPagesSourceNetworkPolicy,
|
errPagesSourceNetworkPolicy,
|
||||||
|
errPagesSourceGitHubFields,
|
||||||
|
errPagesSourceRepositoryInvalid,
|
||||||
|
errPagesSourceSelectorInvalid,
|
||||||
|
errPagesSourceAssetNameInvalid,
|
||||||
|
errPagesSourceCheckInterval,
|
||||||
|
errPagesSourceAutoNotAvailable,
|
||||||
|
errPagesSourceReleaseNotFound,
|
||||||
|
errPagesSourceDigestInvalid,
|
||||||
|
errPagesSourceDigestMismatch,
|
||||||
|
errPagesSourceConfirmationNeeded,
|
||||||
|
errPagesSourceConfirmationStale,
|
||||||
errPagesSourceCheckUnsupported,
|
errPagesSourceCheckUnsupported,
|
||||||
errPagesSourceActionInvalid:
|
errPagesSourceActionInvalid:
|
||||||
response.AbortBadRequest(c, err.Error())
|
response.AbortBadRequest(c, err.Error())
|
||||||
@@ -253,7 +264,7 @@ func GetSourceHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
|
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
|
||||||
// @Summary 更新 Pages 部署源
|
// @Summary 更新 Pages 部署源
|
||||||
// @Description Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
|
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||||
// @Tags openflare-pages
|
// @Tags openflare-pages
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -275,7 +286,11 @@ func UpdateSourceHandler(c *gin.Context) {
|
|||||||
if !decodeStrictJSON(c, &input, false) {
|
if !decodeStrictJSON(c, &input, false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := UpdateSource(c.Request.Context(), projectID, input)
|
actor, ok := currentPagesActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
|
||||||
if handleSourceLogicError(c, err) {
|
if handleSourceLogicError(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -309,7 +324,7 @@ func DeleteSourceHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// CheckSourceHandler 请求检查 Pages 部署源。
|
// CheckSourceHandler 请求检查 Pages 部署源。
|
||||||
// @Summary 检查 Pages 部署源
|
// @Summary 检查 Pages 部署源
|
||||||
// @Description Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
|
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||||
// @Tags openflare-pages
|
// @Tags openflare-pages
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
PagesSourceTypeManual = "manual"
|
PagesSourceTypeManual = "manual"
|
||||||
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
||||||
PagesSourceTypeRemoteURL = "remote_url"
|
PagesSourceTypeRemoteURL = "remote_url"
|
||||||
// PagesSourceTypeGitHubRelease is reserved for Phase 2.
|
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
|
||||||
PagesSourceTypeGitHubRelease = "github_release"
|
PagesSourceTypeGitHubRelease = "github_release"
|
||||||
|
|
||||||
pagesSourceStatusIdle = "idle"
|
pagesSourceStatusIdle = "idle"
|
||||||
@@ -37,6 +37,10 @@ const (
|
|||||||
pagesSourceStatusAttention = "attention"
|
pagesSourceStatusAttention = "attention"
|
||||||
|
|
||||||
defaultRemoteAssetLabel = "pages-package"
|
defaultRemoteAssetLabel = "pages-package"
|
||||||
|
defaultGitHubAssetName = "dist.zip"
|
||||||
|
defaultCheckInterval = 60
|
||||||
|
minimumCheckInterval = 5
|
||||||
|
maximumCheckInterval = 1440
|
||||||
)
|
)
|
||||||
|
|
||||||
// SourceUpdateInput is the discriminated source configuration payload.
|
// SourceUpdateInput is the discriminated source configuration payload.
|
||||||
@@ -72,7 +76,7 @@ type SourceView struct {
|
|||||||
ReleaseSelector string `json:"release_selector,omitempty"`
|
ReleaseSelector string `json:"release_selector,omitempty"`
|
||||||
ReleaseTag string `json:"release_tag,omitempty"`
|
ReleaseTag string `json:"release_tag,omitempty"`
|
||||||
AssetName string `json:"asset_name,omitempty"`
|
AssetName string `json:"asset_name,omitempty"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled,omitempty"`
|
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
|
||||||
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
||||||
SyncStatus string `json:"sync_status,omitempty"`
|
SyncStatus string `json:"sync_status,omitempty"`
|
||||||
UpdateAvailable bool `json:"update_available,omitempty"`
|
UpdateAvailable bool `json:"update_available,omitempty"`
|
||||||
@@ -99,10 +103,15 @@ type SourceUpdateResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sourceDetail struct {
|
type sourceDetail struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Label string `json:"label"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
AssetName string `json:"asset_name,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
ReleaseID string `json:"release_id,omitempty"`
|
LegacyLabel string `json:"label,omitempty"`
|
||||||
|
AssetName string `json:"asset_name,omitempty"`
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
AssetID string `json:"asset_id,omitempty"`
|
||||||
|
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type remoteSourceConfig struct {
|
type remoteSourceConfig struct {
|
||||||
@@ -126,14 +135,43 @@ func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
|||||||
return buildSourceView(source, runtime)
|
return buildSourceView(source, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSource creates or updates a Remote URL source and its 1:1 runtime row.
|
// UpdateSource creates or updates a source. Direct callers use the system actor;
|
||||||
|
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
|
||||||
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
|
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
|
||||||
if err := validateRemoteSourceInput(input); err != nil {
|
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSourceAs persists source configuration and queues the first GitHub check
|
||||||
|
// after commit when the GitHub configuration was materially changed.
|
||||||
|
func UpdateSourceAs(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uint,
|
||||||
|
input SourceUpdateInput,
|
||||||
|
actor string,
|
||||||
|
) (*SourceUpdateResult, error) {
|
||||||
|
if !validPagesSourceActor(actor) {
|
||||||
|
return nil, errors.New(errPagesSourceActionInvalid)
|
||||||
|
}
|
||||||
|
if err := validateSourceUpdateInput(input); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
var persistedSource model.PagesProjectSource
|
||||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return updateRemoteSourceTx(tx, projectID, input)
|
var err error
|
||||||
|
switch strings.TrimSpace(input.SourceType) {
|
||||||
|
case PagesSourceTypeRemoteURL:
|
||||||
|
changed, err = updateRemoteSourceTx(tx, projectID, input)
|
||||||
|
case PagesSourceTypeGitHubRelease:
|
||||||
|
changed, err = updateGitHubSourceTx(tx, projectID, input)
|
||||||
|
default:
|
||||||
|
err = errors.New(errPagesSourceTypeUnsupported)
|
||||||
|
}
|
||||||
|
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("project_id = ?", projectID).First(&persistedSource).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -143,24 +181,34 @@ func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &SourceUpdateResult{Source: view, Warning: ""}, nil
|
result := &SourceUpdateResult{Source: view, Warning: ""}
|
||||||
|
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
|
||||||
|
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
|
||||||
|
if dispatchErr != nil {
|
||||||
|
result.Warning = errPagesSourceInitialCheckWarning
|
||||||
|
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
|
||||||
|
} else {
|
||||||
|
result.CheckTask = receipt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) error {
|
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||||
var project model.PagesProject
|
var project model.PagesProject
|
||||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if !hasExisting {
|
if !hasExisting {
|
||||||
return createRemoteSourceTx(tx, projectID, config)
|
return true, createRemoteSourceTx(tx, projectID, config)
|
||||||
}
|
}
|
||||||
return updateExistingRemoteSourceTx(tx, existing, config)
|
return updateExistingRemoteSourceTx(tx, existing, config)
|
||||||
}
|
}
|
||||||
@@ -227,33 +275,33 @@ func updateExistingRemoteSourceTx(
|
|||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
existing *model.PagesProjectSource,
|
existing *model.PagesProjectSource,
|
||||||
config remoteSourceConfig,
|
config remoteSourceConfig,
|
||||||
) error {
|
) (bool, error) {
|
||||||
if !remoteSourceConfigChanged(existing, config) {
|
if !remoteSourceConfigChanged(existing, config) {
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var runtime model.PagesProjectSourceRuntime
|
var runtime model.PagesProjectSourceRuntime
|
||||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||||
Where("source_id = ?", existing.ID).
|
Where("source_id = ?", existing.ID).
|
||||||
First(&runtime).Error; err != nil {
|
First(&runtime).Error; err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
identityChanged := existing.SourceIdentity != config.Identity
|
identityChanged := existing.SourceIdentity != config.Identity
|
||||||
if err := tx.Model(existing).Updates(map[string]any{
|
if err := tx.Model(existing).Updates(map[string]any{
|
||||||
"source_type": PagesSourceTypeRemoteURL,
|
"source_type": PagesSourceTypeRemoteURL,
|
||||||
"remote_url": config.URL,
|
"remote_url": config.URL,
|
||||||
"remote_network_policy": config.Policy,
|
"remote_network_policy": config.Policy,
|
||||||
"github_repository": "",
|
"github_repository": "",
|
||||||
"release_selector": "",
|
"release_selector": "",
|
||||||
"release_tag": "",
|
"release_tag": "",
|
||||||
"asset_name": "",
|
"asset_name": "",
|
||||||
"auto_update_enabled": false,
|
sourceColumnAutoUpdateEnabled: false,
|
||||||
"check_interval_minutes": 0,
|
"check_interval_minutes": 0,
|
||||||
"config_version": existing.ConfigVersion + 1,
|
sourceColumnConfigVersion: existing.ConfigVersion + 1,
|
||||||
"source_identity": config.Identity,
|
"source_identity": config.Identity,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
return resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
return true, resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||||
@@ -328,6 +376,19 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSourceUpdateInput(input SourceUpdateInput) error {
|
||||||
|
switch strings.TrimSpace(input.SourceType) {
|
||||||
|
case PagesSourceTypeRemoteURL:
|
||||||
|
return validateRemoteSourceInput(input)
|
||||||
|
case PagesSourceTypeGitHubRelease:
|
||||||
|
return validateGitHubSourceInput(input)
|
||||||
|
case "":
|
||||||
|
return errors.New(errPagesSourceTypeRequired)
|
||||||
|
default:
|
||||||
|
return errors.New(errPagesSourceTypeUnsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
||||||
if input.RemoteURLSet {
|
if input.RemoteURLSet {
|
||||||
return strings.TrimSpace(input.RemoteURL), nil
|
return strings.TrimSpace(input.RemoteURL), nil
|
||||||
@@ -430,7 +491,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
|||||||
view.ReleaseSelector = source.ReleaseSelector
|
view.ReleaseSelector = source.ReleaseSelector
|
||||||
view.ReleaseTag = source.ReleaseTag
|
view.ReleaseTag = source.ReleaseTag
|
||||||
view.AssetName = source.AssetName
|
view.AssetName = source.AssetName
|
||||||
view.AutoUpdateEnabled = source.AutoUpdateEnabled
|
autoUpdateEnabled := source.AutoUpdateEnabled
|
||||||
|
view.AutoUpdateEnabled = &autoUpdateEnabled
|
||||||
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
||||||
default:
|
default:
|
||||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||||
@@ -441,7 +503,7 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
|||||||
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||||
detail := sourceDetail{}
|
detail := sourceDetail{}
|
||||||
_ = unmarshalSourceDetail(detailJSON, &detail)
|
_ = unmarshalSourceDetail(detailJSON, &detail)
|
||||||
label := strings.TrimSpace(detail.Label)
|
label := sourceDetailLabel(detail)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = defaultRemoteAssetLabel
|
label = defaultRemoteAssetLabel
|
||||||
}
|
}
|
||||||
@@ -452,6 +514,19 @@ func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sourceDetailLabel(detail sourceDetail) string {
|
||||||
|
if detail.Provider == githubSourceDetailProvider {
|
||||||
|
if label := strings.TrimSpace(detail.Tag); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(detail.LegacyLabel)
|
||||||
|
}
|
||||||
|
if label := strings.TrimSpace(detail.DisplayName); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(detail.LegacyLabel)
|
||||||
|
}
|
||||||
|
|
||||||
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
|
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
|
||||||
if detail == nil || strings.TrimSpace(raw) == "" {
|
if detail == nil || strings.TrimSpace(raw) == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ const (
|
|||||||
sourceRuntimeErrorMaxBytes = 512
|
sourceRuntimeErrorMaxBytes = 512
|
||||||
sourceRevisionHexLength = 64
|
sourceRevisionHexLength = 64
|
||||||
|
|
||||||
|
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
|
||||||
|
sourceColumnConfigVersion = "config_version"
|
||||||
sourceRuntimeColumnSyncStatus = "sync_status"
|
sourceRuntimeColumnSyncStatus = "sync_status"
|
||||||
sourceRuntimeColumnLastError = "last_error"
|
sourceRuntimeColumnLastError = "last_error"
|
||||||
|
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
|
||||||
sourceRuntimeColumnLeaseToken = "lease_token"
|
sourceRuntimeColumnLeaseToken = "lease_token"
|
||||||
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
|
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
|
||||||
|
pagesDeploymentColumnStatus = "status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceLeaseOutcome string
|
type sourceLeaseOutcome string
|
||||||
@@ -50,6 +54,16 @@ type sourceExecutionSnapshot struct {
|
|||||||
SourceIdentity string
|
SourceIdentity string
|
||||||
RemoteURL string
|
RemoteURL string
|
||||||
RemoteNetworkPolicy string
|
RemoteNetworkPolicy string
|
||||||
|
GitHubRepository string
|
||||||
|
ReleaseSelector string
|
||||||
|
ReleaseTag string
|
||||||
|
AssetName string
|
||||||
|
CheckIntervalMinutes int
|
||||||
|
ETag string
|
||||||
|
LastSeenRevision string
|
||||||
|
LastSeenDetail string
|
||||||
|
LastAppliedRevision string
|
||||||
|
LastAppliedDetail string
|
||||||
RootDir string
|
RootDir string
|
||||||
EntryFile string
|
EntryFile string
|
||||||
LeaseToken string
|
LeaseToken string
|
||||||
@@ -161,6 +175,16 @@ func loadSourceExecutionSnapshot(
|
|||||||
SourceIdentity: source.SourceIdentity,
|
SourceIdentity: source.SourceIdentity,
|
||||||
RemoteURL: source.RemoteURL,
|
RemoteURL: source.RemoteURL,
|
||||||
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
||||||
|
GitHubRepository: source.GitHubRepository,
|
||||||
|
ReleaseSelector: source.ReleaseSelector,
|
||||||
|
ReleaseTag: source.ReleaseTag,
|
||||||
|
AssetName: source.AssetName,
|
||||||
|
CheckIntervalMinutes: source.CheckIntervalMinutes,
|
||||||
|
ETag: runtime.ETag,
|
||||||
|
LastSeenRevision: runtime.LastSeenRevision,
|
||||||
|
LastSeenDetail: runtime.LastSeenDetail,
|
||||||
|
LastAppliedRevision: runtime.LastAppliedRevision,
|
||||||
|
LastAppliedDetail: runtime.LastAppliedDetail,
|
||||||
RootDir: project.RootDir,
|
RootDir: project.RootDir,
|
||||||
EntryFile: project.EntryFile,
|
EntryFile: project.EntryFile,
|
||||||
LeaseToken: token,
|
LeaseToken: token,
|
||||||
|
|||||||
@@ -226,12 +226,14 @@ func syncRemoteSource(
|
|||||||
ctx,
|
ctx,
|
||||||
snapshot,
|
snapshot,
|
||||||
prepared.Candidate.Checksum,
|
prepared.Candidate.Checksum,
|
||||||
|
prepared.Candidate.Checksum,
|
||||||
prepared.Detail,
|
prepared.Detail,
|
||||||
prepared.DetailJSON,
|
prepared.DetailJSON,
|
||||||
actor,
|
actor,
|
||||||
prepared.Manifest,
|
prepared.Manifest,
|
||||||
ingestState.Result,
|
ingestState.Result,
|
||||||
ingestState.HasIngest,
|
ingestState.HasIngest,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
ingestState.Referenced = referenced
|
ingestState.Referenced = referenced
|
||||||
if errors.Is(err, errSourceFinalFence) {
|
if errors.Is(err, errSourceFinalFence) {
|
||||||
@@ -288,7 +290,7 @@ func prepareRemoteSource(
|
|||||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
|
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||||
detailJSON, err := json.Marshal(detail)
|
detailJSON, err := json.Marshal(detail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||||
@@ -336,7 +338,7 @@ func resolveSourceIngest(
|
|||||||
prepared.Candidate.Checksum,
|
prepared.Candidate.Checksum,
|
||||||
snapshot.ProjectID,
|
snapshot.ProjectID,
|
||||||
snapshot.SourceID,
|
snapshot.SourceID,
|
||||||
prepared.Detail.Label,
|
sourceDetailLabel(prepared.Detail),
|
||||||
prepared.Candidate.Format,
|
prepared.Candidate.Format,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -384,12 +386,14 @@ func commitSourceDeployment(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
snapshot *sourceExecutionSnapshot,
|
snapshot *sourceExecutionSnapshot,
|
||||||
revision string,
|
revision string,
|
||||||
|
packageChecksum string,
|
||||||
detail sourceDetail,
|
detail sourceDetail,
|
||||||
detailJSON string,
|
detailJSON string,
|
||||||
actor string,
|
actor string,
|
||||||
manifest *deploymentManifest,
|
manifest *deploymentManifest,
|
||||||
ingestResult upload.IngestResult,
|
ingestResult upload.IngestResult,
|
||||||
hasIngest bool,
|
hasIngest bool,
|
||||||
|
nextCheckNotBefore *time.Time,
|
||||||
) (*model.PagesDeployment, bool, bool, error) {
|
) (*model.PagesDeployment, bool, bool, error) {
|
||||||
if snapshot == nil || manifest == nil {
|
if snapshot == nil || manifest == nil {
|
||||||
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
||||||
@@ -403,7 +407,7 @@ func commitSourceDeployment(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, targetReused, err := resolveSourceDeploymentTx(
|
target, targetReused, err := resolveSourceDeploymentTx(
|
||||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,7 +421,7 @@ func commitSourceDeployment(
|
|||||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
|
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
committed = *target
|
committed = *target
|
||||||
@@ -491,6 +495,7 @@ func resolveSourceDeploymentTx(
|
|||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
state *sourceCommitState,
|
state *sourceCommitState,
|
||||||
revision string,
|
revision string,
|
||||||
|
packageChecksum string,
|
||||||
detail sourceDetail,
|
detail sourceDetail,
|
||||||
detailJSON string,
|
detailJSON string,
|
||||||
actor string,
|
actor string,
|
||||||
@@ -515,7 +520,7 @@ func resolveSourceDeploymentTx(
|
|||||||
return nil, false, errSourceFinalFence
|
return nil, false, errSourceFinalFence
|
||||||
}
|
}
|
||||||
return createSourceDeploymentTx(
|
return createSourceDeploymentTx(
|
||||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
|
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +528,7 @@ func createSourceDeploymentTx(
|
|||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
state *sourceCommitState,
|
state *sourceCommitState,
|
||||||
revision string,
|
revision string,
|
||||||
|
packageChecksum string,
|
||||||
detail sourceDetail,
|
detail sourceDetail,
|
||||||
detailJSON string,
|
detailJSON string,
|
||||||
actor string,
|
actor string,
|
||||||
@@ -541,7 +547,7 @@ func createSourceDeploymentTx(
|
|||||||
target := &model.PagesDeployment{
|
target := &model.PagesDeployment{
|
||||||
ProjectID: state.Project.ID,
|
ProjectID: state.Project.ID,
|
||||||
DeploymentNumber: maxNumber + 1,
|
DeploymentNumber: maxNumber + 1,
|
||||||
Checksum: revision,
|
Checksum: packageChecksum,
|
||||||
Status: model.PagesDeploymentStatusUploaded,
|
Status: model.PagesDeploymentStatusUploaded,
|
||||||
UploadID: ingestResult.Upload.ID,
|
UploadID: ingestResult.Upload.ID,
|
||||||
FileCount: manifest.FileCount,
|
FileCount: manifest.FileCount,
|
||||||
@@ -550,7 +556,7 @@ func createSourceDeploymentTx(
|
|||||||
SourceType: state.Source.SourceType,
|
SourceType: state.Source.SourceType,
|
||||||
SourceIdentity: &identity,
|
SourceIdentity: &identity,
|
||||||
SourceRevision: &revisionValue,
|
SourceRevision: &revisionValue,
|
||||||
SourceLabel: detail.Label,
|
SourceLabel: sourceDetailLabel(detail),
|
||||||
SourceMeta: detailJSON,
|
SourceMeta: detailJSON,
|
||||||
TriggerType: pagesSourceTriggerManualSync,
|
TriggerType: pagesSourceTriggerManualSync,
|
||||||
}
|
}
|
||||||
@@ -629,6 +635,7 @@ func activateSourceDeploymentTx(
|
|||||||
target *model.PagesDeployment,
|
target *model.PagesDeployment,
|
||||||
revision string,
|
revision string,
|
||||||
detailJSON string,
|
detailJSON string,
|
||||||
|
nextCheckNotBefore *time.Time,
|
||||||
) error {
|
) error {
|
||||||
if err := tx.Model(&model.PagesDeployment{}).
|
if err := tx.Model(&model.PagesDeployment{}).
|
||||||
Where("project_id = ?", state.Project.ID).
|
Where("project_id = ?", state.Project.ID).
|
||||||
@@ -636,8 +643,8 @@ func activateSourceDeploymentTx(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(target).Updates(map[string]any{
|
if err := tx.Model(target).Updates(map[string]any{
|
||||||
"status": model.PagesDeploymentStatusActive,
|
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||||
"activated_at": &state.Now,
|
"activated_at": &state.Now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -645,6 +652,15 @@ func activateSourceDeploymentTx(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
finishedAt := sourceCommitNow()
|
finishedAt := sourceCommitNow()
|
||||||
|
var nextCheckAt any
|
||||||
|
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
|
||||||
|
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||||
|
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
|
||||||
|
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
|
||||||
|
next = nextCheckNotBefore.UTC()
|
||||||
|
}
|
||||||
|
nextCheckAt = &next
|
||||||
|
}
|
||||||
result := tx.Model(&model.PagesProjectSourceRuntime{}).
|
result := tx.Model(&model.PagesProjectSourceRuntime{}).
|
||||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
|
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
|
||||||
state.Runtime.SourceID,
|
state.Runtime.SourceID,
|
||||||
@@ -658,9 +674,9 @@ func activateSourceDeploymentTx(
|
|||||||
"last_applied_detail": detailJSON,
|
"last_applied_detail": detailJSON,
|
||||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
||||||
sourceRuntimeColumnLastError: "",
|
sourceRuntimeColumnLastError: "",
|
||||||
"last_checked_at": &finishedAt,
|
sourceRuntimeColumnLastCheckedAt: &finishedAt,
|
||||||
"last_synced_at": &finishedAt,
|
"last_synced_at": &finishedAt,
|
||||||
"next_check_at": nil,
|
"next_check_at": nextCheckAt,
|
||||||
sourceRuntimeColumnLeaseToken: "",
|
sourceRuntimeColumnLeaseToken: "",
|
||||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -388,12 +388,14 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
|||||||
ctx,
|
ctx,
|
||||||
snapshot,
|
snapshot,
|
||||||
deployment.Checksum,
|
deployment.Checksum,
|
||||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: deployment.SourceLabel},
|
deployment.Checksum,
|
||||||
|
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
|
||||||
deployment.SourceMeta,
|
deployment.SourceMeta,
|
||||||
"user:5",
|
"user:5",
|
||||||
&deploymentManifest{},
|
&deploymentManifest{},
|
||||||
upload.IngestResult{},
|
upload.IngestResult{},
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
if !errors.Is(err, errSourceFinalFence) {
|
if !errors.Is(err, errSourceFinalFence) {
|
||||||
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
|
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
|
||||||
@@ -552,7 +554,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
|||||||
SourceIdentity: &identity,
|
SourceIdentity: &identity,
|
||||||
SourceRevision: &revision,
|
SourceRevision: &revision,
|
||||||
SourceLabel: "deleted.zip",
|
SourceLabel: "deleted.zip",
|
||||||
SourceMeta: `{"provider":"remote_url","label":"deleted.zip"}`,
|
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||||
TriggerType: pagesSourceTriggerManualSync,
|
TriggerType: pagesSourceTriggerManualSync,
|
||||||
}
|
}
|
||||||
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
||||||
@@ -575,12 +577,14 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
|||||||
ctx,
|
ctx,
|
||||||
snapshot,
|
snapshot,
|
||||||
revision,
|
revision,
|
||||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: "deleted.zip"},
|
revision,
|
||||||
`{"provider":"remote_url","label":"deleted.zip"}`,
|
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
|
||||||
|
`{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||||
"user:1",
|
"user:1",
|
||||||
manifest,
|
manifest,
|
||||||
upload.IngestResult{},
|
upload.IngestResult{},
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
if !errors.Is(err, errSourceFinalFence) {
|
if !errors.Is(err, errSourceFinalFence) {
|
||||||
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
|
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|||||||
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
|
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
|
||||||
!validPagesSourceActor(input.Actor) ||
|
!validPagesSourceActor(input.Actor) ||
|
||||||
!validOptionalSourceRevision(input.TargetRevision) ||
|
!validOptionalSourceRevision(input.TargetRevision) ||
|
||||||
!validOptionalSourceRevision(input.ConfirmedRevision) {
|
!validOptionalSourceRevision(input.ConfirmedRevision) ||
|
||||||
|
(input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "")) ||
|
||||||
|
(input.TargetRevision != "" && input.ConfirmedRevision != "") ||
|
||||||
|
(input.TargetRevision != "" && input.Actor != pagesSourceCreatedBySystem) ||
|
||||||
|
(input.ConfirmedRevision != "" && !strings.HasPrefix(input.Actor, "user:")) {
|
||||||
return nil, errors.New(errPagesSourceActionInvalid)
|
return nil, errors.New(errPagesSourceActionInvalid)
|
||||||
}
|
}
|
||||||
return json.Marshal(input)
|
return json.Marshal(input)
|
||||||
@@ -110,10 +114,13 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
|||||||
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||||
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
||||||
}
|
}
|
||||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||||
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
||||||
}
|
}
|
||||||
if input.TargetRevision != "" || input.ConfirmedRevision != "" {
|
if source.SourceType == PagesSourceTypeRemoteURL && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||||
|
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||||
|
}
|
||||||
|
if input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +139,43 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
|||||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := syncRemoteSource(ctx, snapshot, input.Actor)
|
if input.Action == sourceActionCheck {
|
||||||
|
return executeGitHubCheckAction(ctx, snapshot)
|
||||||
|
}
|
||||||
|
return executeSourceSyncAction(ctx, &source, snapshot, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
|
||||||
|
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
|
||||||
|
if checkErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
|
||||||
|
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
|
||||||
|
return nil, task.PermanentError(checkErr.Error())
|
||||||
|
}
|
||||||
|
return nil, errors.New(errPagesSourceSyncFailed)
|
||||||
|
}
|
||||||
|
if checkResult == nil || checkResult.Stale {
|
||||||
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||||
|
}
|
||||||
|
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeSourceSyncAction(
|
||||||
|
ctx context.Context,
|
||||||
|
source *model.PagesProjectSource,
|
||||||
|
snapshot *sourceExecutionSnapshot,
|
||||||
|
input SourceActionPayload,
|
||||||
|
) (*task.TaskResult, error) {
|
||||||
|
var result *sourceSyncOutcome
|
||||||
|
var err error
|
||||||
|
if source.SourceType == PagesSourceTypeGitHubRelease {
|
||||||
|
result, err = syncGitHubSource(ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision)
|
||||||
|
} else {
|
||||||
|
result, err = syncRemoteSource(ctx, snapshot, input.Actor)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
|
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
|
||||||
if isPermanentSourceSyncError(err) {
|
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
|
||||||
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
||||||
}
|
}
|
||||||
return nil, errors.New(errPagesSourceSyncFailed)
|
return nil, errors.New(errPagesSourceSyncFailed)
|
||||||
@@ -191,13 +231,19 @@ func isPermanentSourceSyncError(err error) bool {
|
|||||||
}
|
}
|
||||||
message := err.Error()
|
message := err.Error()
|
||||||
return strings.Contains(message, errPagesPackageUnsupported) ||
|
return strings.Contains(message, errPagesPackageUnsupported) ||
|
||||||
|
strings.Contains(message, errPagesPackageURLTooLarge) ||
|
||||||
strings.Contains(message, errPagesPackageInvalid) ||
|
strings.Contains(message, errPagesPackageInvalid) ||
|
||||||
strings.Contains(message, errPagesPackageEmpty) ||
|
strings.Contains(message, errPagesPackageEmpty) ||
|
||||||
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
|
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
|
||||||
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
||||||
strings.Contains(message, errPagesEntryFileMissing) ||
|
strings.Contains(message, errPagesEntryFileMissing) ||
|
||||||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
||||||
strings.Contains(message, errPagesSourceNetworkPolicy)
|
strings.Contains(message, errPagesSourceNetworkPolicy) ||
|
||||||
|
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
||||||
|
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
||||||
|
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
||||||
|
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
|
||||||
|
strings.Contains(message, errPagesSourceConfirmationStale)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
|
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
|
||||||
@@ -207,8 +253,20 @@ func DispatchSourceAction(
|
|||||||
action string,
|
action string,
|
||||||
actor string,
|
actor string,
|
||||||
confirmedRevision string,
|
confirmedRevision string,
|
||||||
|
) (*SourceActionReceipt, error) {
|
||||||
|
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatchSourceActionByProject(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID uint,
|
||||||
|
action string,
|
||||||
|
actor string,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
) (*SourceActionReceipt, error) {
|
) (*SourceActionReceipt, error) {
|
||||||
action = strings.TrimSpace(action)
|
action = strings.TrimSpace(action)
|
||||||
|
targetRevision = strings.TrimSpace(targetRevision)
|
||||||
confirmedRevision = strings.TrimSpace(confirmedRevision)
|
confirmedRevision = strings.TrimSpace(confirmedRevision)
|
||||||
if action != sourceActionCheck && action != sourceActionSync {
|
if action != sourceActionCheck && action != sourceActionSync {
|
||||||
return nil, errors.New(errPagesSourceActionInvalid)
|
return nil, errors.New(errPagesSourceActionInvalid)
|
||||||
@@ -224,14 +282,8 @@ func DispatchSourceAction(
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
if err := validateSourceActionPreflight(ctx, &source, action, targetRevision, confirmedRevision); err != nil {
|
||||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
return nil, err
|
||||||
}
|
|
||||||
if action == sourceActionCheck {
|
|
||||||
return nil, errors.New(errPagesSourceCheckUnsupported)
|
|
||||||
}
|
|
||||||
if confirmedRevision != "" {
|
|
||||||
return nil, errors.New(errPagesSourceActionInvalid)
|
|
||||||
}
|
}
|
||||||
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,14 +292,58 @@ func DispatchSourceAction(
|
|||||||
if busy {
|
if busy {
|
||||||
return nil, errors.New(errPagesSourceActionBusy)
|
return nil, errors.New(errPagesSourceActionBusy)
|
||||||
}
|
}
|
||||||
|
return dispatchSourceActionSnapshot(ctx, source, action, actor, targetRevision, confirmedRevision, "manual")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSourceActionPreflight(
|
||||||
|
ctx context.Context,
|
||||||
|
source *model.PagesProjectSource,
|
||||||
|
action string,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
|
) error {
|
||||||
|
if source == nil {
|
||||||
|
return errors.New(errPagesSourceNotFound)
|
||||||
|
}
|
||||||
|
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||||
|
return errors.New(errPagesSourceTypeUnsupported)
|
||||||
|
}
|
||||||
|
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||||
|
return errors.New(errPagesSourceCheckUnsupported)
|
||||||
|
}
|
||||||
|
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
|
||||||
|
return errors.New(errPagesSourceActionInvalid)
|
||||||
|
}
|
||||||
|
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
|
||||||
|
return errors.New(errPagesSourceActionInvalid)
|
||||||
|
}
|
||||||
|
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
|
||||||
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatchSourceActionSnapshot(
|
||||||
|
ctx context.Context,
|
||||||
|
source model.PagesProjectSource,
|
||||||
|
action string,
|
||||||
|
actor string,
|
||||||
|
targetRevision string,
|
||||||
|
confirmedRevision string,
|
||||||
|
triggeredBy string,
|
||||||
|
) (*SourceActionReceipt, error) {
|
||||||
|
if task.AsynqClient == nil {
|
||||||
|
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||||
|
}
|
||||||
handler := &SourceActionHandler{}
|
handler := &SourceActionHandler{}
|
||||||
rawPayload, err := json.Marshal(SourceActionPayload{
|
rawPayload, err := json.Marshal(SourceActionPayload{
|
||||||
SourceID: source.ID,
|
SourceID: source.ID,
|
||||||
ConfigVersion: source.ConfigVersion,
|
ConfigVersion: source.ConfigVersion,
|
||||||
Action: action,
|
Action: action,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
TargetRevision: "",
|
TargetRevision: targetRevision,
|
||||||
ConfirmedRevision: confirmedRevision,
|
ConfirmedRevision: confirmedRevision,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,9 +353,9 @@ func DispatchSourceAction(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, "manual")
|
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", projectID, source.ID, action, err)
|
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
|
||||||
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||||
}
|
}
|
||||||
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ func setupPagesSourceTest(t *testing.T) context.Context {
|
|||||||
return t.Context()
|
return t.Context()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
detail string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote",
|
||||||
|
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
|
||||||
|
want: "legacy.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github",
|
||||||
|
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
|
||||||
|
want: "v1.2.3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
view := revisionView(strings.Repeat("a", 64), test.detail)
|
||||||
|
if view.Label != test.want {
|
||||||
|
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
|
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
view, err := CreateProject(ctx, Input{
|
view, err := CreateProject(ctx, Input{
|
||||||
@@ -203,9 +230,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
|||||||
Where("source_id = ?", source.ID).
|
Where("source_id = ?", source.ID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"last_seen_revision": seenRevision,
|
"last_seen_revision": seenRevision,
|
||||||
"last_seen_detail": `{"provider":"remote_url","label":"new.zip"}`,
|
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
|
||||||
"last_applied_revision": appliedRevision,
|
"last_applied_revision": appliedRevision,
|
||||||
"last_applied_detail": `{"provider":"remote_url","label":"old.zip"}`,
|
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
|
||||||
"sync_status": pagesSourceStatusSyncing,
|
"sync_status": pagesSourceStatusSyncing,
|
||||||
"lease_token": "in-flight",
|
"lease_token": "in-flight",
|
||||||
"lease_expires_at": &future,
|
"lease_expires_at": &future,
|
||||||
|
|||||||
@@ -0,0 +1,725 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package githubrelease resolves and downloads public GitHub Release assets.
|
||||||
|
// It deliberately does not know about Pages projects, deployments or runtime
|
||||||
|
// state so other callers can reuse the same constrained HTTP contract.
|
||||||
|
package githubrelease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// APIVersion is the GitHub REST API contract used by this package.
|
||||||
|
APIVersion = "2026-03-10"
|
||||||
|
|
||||||
|
// SelectorLatest uses GitHub's repository latest-release endpoint.
|
||||||
|
SelectorLatest Selector = "latest"
|
||||||
|
// SelectorTag resolves one exact GitHub release tag.
|
||||||
|
SelectorTag Selector = "tag"
|
||||||
|
|
||||||
|
defaultAPIBaseURL = "https://api.github.com"
|
||||||
|
defaultUserAgent = "OpenFlare-GitHubRelease/1.0"
|
||||||
|
metadataAccept = "application/vnd.github+json"
|
||||||
|
assetAccept = "application/octet-stream"
|
||||||
|
maxMetadataBytes = 4 << 20
|
||||||
|
maxAssetErrorNames = 10
|
||||||
|
maxSafeTextBytes = 255
|
||||||
|
maxSafeAssetNameLen = 96
|
||||||
|
maxDigestBytes = 96
|
||||||
|
maxETagBytes = 512
|
||||||
|
safePartsCapacity = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidRequest = errors.New("GitHub Release 请求参数无效")
|
||||||
|
errMetadata = errors.New("GitHub Release 元数据响应无效")
|
||||||
|
errAssetMissing = errors.New("GitHub Release 中未找到指定的已上传 asset")
|
||||||
|
errDownload = errors.New("GitHub Release asset 下载失败")
|
||||||
|
errTooLarge = errors.New("GitHub Release asset 超过大小限制")
|
||||||
|
errEmptyAsset = errors.New("GitHub Release asset 内容为空")
|
||||||
|
errDigest = errors.New("GitHub Release asset digest 无效或校验失败")
|
||||||
|
errCleanup = errors.New("GitHub Release 临时文件清理失败")
|
||||||
|
|
||||||
|
ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
|
||||||
|
repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
hexPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidRequest identifies caller configuration errors.
|
||||||
|
ErrInvalidRequest = errInvalidRequest
|
||||||
|
// ErrMetadata identifies malformed, unavailable or failed Release metadata requests.
|
||||||
|
ErrMetadata = errMetadata
|
||||||
|
// ErrAssetNotFound identifies an otherwise valid Release without the exact uploaded asset.
|
||||||
|
ErrAssetNotFound = errAssetMissing
|
||||||
|
// ErrDownload identifies network or HTTP failures while downloading an asset.
|
||||||
|
ErrDownload = errDownload
|
||||||
|
// ErrAssetTooLarge identifies assets that exceed the caller's hard byte limit.
|
||||||
|
ErrAssetTooLarge = errTooLarge
|
||||||
|
// ErrEmptyAsset identifies an empty downloaded asset.
|
||||||
|
ErrEmptyAsset = errEmptyAsset
|
||||||
|
// ErrDigestMismatch identifies malformed or mismatched declared SHA-256 digests.
|
||||||
|
ErrDigestMismatch = errDigest
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selector identifies GitHub's own latest endpoint or one exact tag.
|
||||||
|
type Selector string
|
||||||
|
|
||||||
|
// ResolveRequest describes one public repository release asset lookup.
|
||||||
|
type ResolveRequest struct {
|
||||||
|
Repository string
|
||||||
|
Selector Selector
|
||||||
|
Tag string
|
||||||
|
AssetName string
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release contains only metadata safe and necessary for source resolution.
|
||||||
|
type Release struct {
|
||||||
|
ID string `json:"release_id"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset contains the immutable target metadata returned by a resolve call.
|
||||||
|
type Asset struct {
|
||||||
|
ID string `json:"asset_id"`
|
||||||
|
Name string `json:"asset_name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveResult is either a selected uploaded asset or a not-modified marker.
|
||||||
|
type ResolveResult struct {
|
||||||
|
NotModified bool `json:"not_modified"`
|
||||||
|
ETag string `json:"etag,omitempty"`
|
||||||
|
Release Release `json:"release,omitempty"`
|
||||||
|
Asset Asset `json:"asset,omitempty"`
|
||||||
|
RetryAt *time.Time `json:"retry_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadRequest identifies an already resolved asset. Asset IDs never come
|
||||||
|
// from an untrusted URL and the download endpoint is built locally.
|
||||||
|
type DownloadRequest struct {
|
||||||
|
Repository string
|
||||||
|
Asset Asset
|
||||||
|
MaxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadResult owns a temporary file. Call Cleanup after ingestion.
|
||||||
|
type DownloadResult struct {
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
SHA256 string
|
||||||
|
DeclaredDigest string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes the temporary file and is safe to call more than once.
|
||||||
|
func (result *DownloadResult) Cleanup() error {
|
||||||
|
if result == nil || result.Path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := result.Path
|
||||||
|
err := os.Remove(name)
|
||||||
|
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||||
|
result.Path = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errCleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is a safe provider error. It never retains a response body, request
|
||||||
|
// URL, redirect location or request headers.
|
||||||
|
type Error struct {
|
||||||
|
Kind error
|
||||||
|
StatusCode int
|
||||||
|
RequestID string
|
||||||
|
Repository string
|
||||||
|
Tag string
|
||||||
|
AssetName string
|
||||||
|
AvailableAssets []string
|
||||||
|
RetryAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (providerError *Error) Error() string {
|
||||||
|
if providerError == nil {
|
||||||
|
return "GitHub Release 请求失败"
|
||||||
|
}
|
||||||
|
message := "GitHub Release 请求失败"
|
||||||
|
if providerError.Kind != nil {
|
||||||
|
message = providerError.Kind.Error()
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, safePartsCapacity)
|
||||||
|
if providerError.StatusCode != 0 {
|
||||||
|
parts = append(parts, "status="+strconv.Itoa(providerError.StatusCode))
|
||||||
|
}
|
||||||
|
if providerError.RequestID != "" {
|
||||||
|
parts = append(parts, "request_id="+providerError.RequestID)
|
||||||
|
}
|
||||||
|
if providerError.Repository != "" {
|
||||||
|
parts = append(parts, "repo="+providerError.Repository)
|
||||||
|
}
|
||||||
|
if providerError.Tag != "" {
|
||||||
|
parts = append(parts, "tag="+providerError.Tag)
|
||||||
|
}
|
||||||
|
if providerError.AssetName != "" {
|
||||||
|
parts = append(parts, "asset="+providerError.AssetName)
|
||||||
|
}
|
||||||
|
if len(providerError.AvailableAssets) > 0 {
|
||||||
|
parts = append(parts, "available="+strings.Join(providerError.AvailableAssets, ","))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return message + " (" + strings.Join(parts, " ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (providerError *Error) Unwrap() error {
|
||||||
|
if providerError == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return providerError.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryAt extracts the server-directed retry deadline from an error.
|
||||||
|
func RetryAt(err error) (time.Time, bool) {
|
||||||
|
var providerError *Error
|
||||||
|
if !errors.As(err, &providerError) || providerError.RetryAt == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return *providerError.RetryAt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryTime is retained as a compatibility alias for early callers.
|
||||||
|
//
|
||||||
|
// Deprecated: use RetryAt.
|
||||||
|
func RetryTime(err error) (time.Time, bool) {
|
||||||
|
return RetryAt(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound reports both a missing Release endpoint and a Release that lacks
|
||||||
|
// the exact uploaded asset requested by the caller.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
if errors.Is(err, ErrAssetNotFound) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var providerError *Error
|
||||||
|
return errors.As(err, &providerError) && providerError.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDigestError reports malformed or mismatched declared asset digests.
|
||||||
|
func IsDigestError(err error) bool {
|
||||||
|
return errors.Is(err, ErrDigestMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryable classifies provider failures without relying on localized error
|
||||||
|
// strings. Configuration, not-found, size, empty-content and digest failures
|
||||||
|
// are permanent. Network failures, 408/425/429 and 5xx responses are retryable.
|
||||||
|
func IsRetryable(err error) bool {
|
||||||
|
if err == nil || errors.Is(err, ErrInvalidRequest) || IsNotFound(err) ||
|
||||||
|
errors.Is(err, ErrAssetTooLarge) || errors.Is(err, ErrEmptyAsset) || IsDigestError(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var providerError *Error
|
||||||
|
if !errors.As(err, &providerError) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if providerError.StatusCode == 0 {
|
||||||
|
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
|
||||||
|
}
|
||||||
|
if providerError.StatusCode < http.StatusBadRequest {
|
||||||
|
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
|
||||||
|
}
|
||||||
|
if providerError.RetryAt != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return providerError.StatusCode == http.StatusRequestTimeout ||
|
||||||
|
providerError.StatusCode == http.StatusTooEarly ||
|
||||||
|
providerError.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
providerError.StatusCode >= http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client accesses public GitHub Releases using a fixed, constrained transport.
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
createTemp func(string, string) (*os.File, error)
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient constructs a production client for api.github.com. Public
|
||||||
|
// repositories do not require or send a token.
|
||||||
|
func NewClient() *Client {
|
||||||
|
return newClient(defaultClientOptions())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve calls GitHub's latest or exact-tag endpoint and selects one exact,
|
||||||
|
// case-sensitive uploaded asset. It never falls back to source archives.
|
||||||
|
func (client *Client) Resolve(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
|
||||||
|
repository, tag, endpoint, err := normalizeResolveRequest(client.baseURL, request)
|
||||||
|
if err != nil {
|
||||||
|
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
|
||||||
|
}
|
||||||
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
|
||||||
|
}
|
||||||
|
applyMetadataHeaders(httpRequest, request.ETag)
|
||||||
|
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
|
||||||
|
if err != nil {
|
||||||
|
return ResolveResult{}, safeError(errMetadata, 0, "", repository, tag, request.AssetName, nil, nil)
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
|
||||||
|
retryAt := responseRetryAt(response, client.now())
|
||||||
|
etag := safeETag(response.Header.Get("ETag"))
|
||||||
|
if response.StatusCode == http.StatusNotModified {
|
||||||
|
if etag == "" {
|
||||||
|
etag = safeETag(request.ETag)
|
||||||
|
}
|
||||||
|
return ResolveResult{NotModified: true, ETag: etag, RetryAt: retryAt}, nil
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(response.Body, maxMetadataBytes+1))
|
||||||
|
if readErr != nil || len(body) > maxMetadataBytes || !utf8.Valid(body) {
|
||||||
|
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||||
|
}
|
||||||
|
var payload releasePayload
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||||
|
}
|
||||||
|
if err := ensureJSONEOF(decoder); err != nil {
|
||||||
|
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||||
|
}
|
||||||
|
release, assets, err := convertRelease(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
|
||||||
|
}
|
||||||
|
for _, asset := range assets {
|
||||||
|
if asset.State == "uploaded" && asset.Name == request.AssetName {
|
||||||
|
return ResolveResult{
|
||||||
|
ETag: etag,
|
||||||
|
Release: release,
|
||||||
|
Asset: asset,
|
||||||
|
RetryAt: retryAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
available := safeAssetNames(assets)
|
||||||
|
return ResolveResult{}, safeError(
|
||||||
|
errAssetMissing,
|
||||||
|
response.StatusCode,
|
||||||
|
response.Header.Get("X-GitHub-Request-Id"),
|
||||||
|
repository,
|
||||||
|
release.Tag,
|
||||||
|
request.AssetName,
|
||||||
|
available,
|
||||||
|
retryAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams an asset into a package-owned temporary file while
|
||||||
|
// enforcing a hard byte limit and verifying GitHub's declared sha256 digest.
|
||||||
|
func (client *Client) Download(ctx context.Context, request DownloadRequest) (*DownloadResult, error) {
|
||||||
|
repository, err := normalizeRepository(request.Repository)
|
||||||
|
if err != nil || request.MaxBytes <= 0 || !validPositiveID(request.Asset.ID) ||
|
||||||
|
!validAssetName(request.Asset.Name) || request.Asset.Size < 0 {
|
||||||
|
return nil, safeError(errInvalidRequest, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
|
||||||
|
}
|
||||||
|
if request.Asset.Size > request.MaxBytes {
|
||||||
|
return nil, safeError(errTooLarge, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(client.baseURL, "/") + "/repos/" + repository + "/releases/assets/" + request.Asset.ID
|
||||||
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, safeError(errInvalidRequest, 0, "", repository, "", request.Asset.Name, nil, nil)
|
||||||
|
}
|
||||||
|
applyAssetHeaders(httpRequest)
|
||||||
|
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
|
||||||
|
if err != nil {
|
||||||
|
return nil, safeError(errDownload, 0, "", repository, "", request.Asset.Name, nil, nil)
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
retryAt := responseRetryAt(response, client.now())
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, safeHTTPError(errDownload, response, repository, "", request.Asset.Name, retryAt)
|
||||||
|
}
|
||||||
|
if response.ContentLength > request.MaxBytes {
|
||||||
|
return nil, safeHTTPError(errTooLarge, response, repository, "", request.Asset.Name, retryAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.streamAsset(response.Body, request.MaxBytes, request.Asset.Digest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, safeError(err, response.StatusCode, response.Header.Get("X-GitHub-Request-Id"), repository, "", request.Asset.Name, nil, retryAt)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) streamAsset(body io.Reader, maxBytes int64, declaredDigest string) (result *DownloadResult, resultErr error) {
|
||||||
|
tempFile, err := client.createTemp("", "openflare-github-release-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errDownload
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
defer func() {
|
||||||
|
closeErr := tempFile.Close()
|
||||||
|
if resultErr == nil && closeErr != nil {
|
||||||
|
resultErr = errDownload
|
||||||
|
}
|
||||||
|
if resultErr != nil {
|
||||||
|
if removeErr := os.Remove(tempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||||
|
resultErr = errCleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
readLimit := maxBytes
|
||||||
|
if readLimit < math.MaxInt64 {
|
||||||
|
readLimit++
|
||||||
|
}
|
||||||
|
size, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(body, readLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errDownload
|
||||||
|
}
|
||||||
|
if size > maxBytes {
|
||||||
|
return nil, errTooLarge
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
return nil, errEmptyAsset
|
||||||
|
}
|
||||||
|
checksum := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
if err := verifyDeclaredDigest(declaredDigest, checksum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DownloadResult{
|
||||||
|
Path: tempPath,
|
||||||
|
Size: size,
|
||||||
|
SHA256: checksum,
|
||||||
|
DeclaredDigest: strings.ToLower(strings.TrimSpace(declaredDigest)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type releasePayload struct {
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Assets []assetPayload `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetPayload struct {
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRelease(payload releasePayload) (Release, []Asset, error) {
|
||||||
|
releaseID, err := positiveJSONID(payload.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, nil, err
|
||||||
|
}
|
||||||
|
if !validReleaseDisplayTag(payload.Tag) {
|
||||||
|
return Release{}, nil, errMetadata
|
||||||
|
}
|
||||||
|
publishedAt, err := parseOptionalTime(payload.PublishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, nil, err
|
||||||
|
}
|
||||||
|
release := Release{
|
||||||
|
ID: releaseID,
|
||||||
|
Tag: payload.Tag,
|
||||||
|
Name: safeText(payload.Name, maxSafeTextBytes),
|
||||||
|
Draft: payload.Draft,
|
||||||
|
Prerelease: payload.Prerelease,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
}
|
||||||
|
assets := make([]Asset, 0, len(payload.Assets))
|
||||||
|
for _, rawAsset := range payload.Assets {
|
||||||
|
assetID, assetErr := positiveJSONID(rawAsset.ID)
|
||||||
|
if assetErr != nil || rawAsset.Size < 0 {
|
||||||
|
return Release{}, nil, errMetadata
|
||||||
|
}
|
||||||
|
updatedAt, assetErr := parseOptionalTime(rawAsset.UpdatedAt)
|
||||||
|
if assetErr != nil {
|
||||||
|
return Release{}, nil, errMetadata
|
||||||
|
}
|
||||||
|
assets = append(assets, Asset{
|
||||||
|
ID: assetID,
|
||||||
|
Name: rawAsset.Name,
|
||||||
|
State: rawAsset.State,
|
||||||
|
Size: rawAsset.Size,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
Digest: safeText(rawAsset.Digest, maxDigestBytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return release, assets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeResolveRequest(baseURL string, request ResolveRequest) (string, string, string, error) {
|
||||||
|
repository, err := normalizeRepository(request.Repository)
|
||||||
|
if err != nil || !validAssetName(request.AssetName) {
|
||||||
|
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||||
|
}
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
switch request.Selector {
|
||||||
|
case SelectorLatest:
|
||||||
|
if strings.TrimSpace(request.Tag) != "" {
|
||||||
|
return repository, "", "", errInvalidRequest
|
||||||
|
}
|
||||||
|
return repository, "latest", baseURL + "/repos/" + repository + "/releases/latest", nil
|
||||||
|
case SelectorTag:
|
||||||
|
if !validTag(request.Tag) {
|
||||||
|
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||||
|
}
|
||||||
|
return repository, request.Tag, baseURL + "/repos/" + repository + "/releases/tags/" + url.PathEscape(request.Tag), nil
|
||||||
|
default:
|
||||||
|
return repository, validErrorTag(request.Tag), "", errInvalidRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRepository(repository string) (string, error) {
|
||||||
|
repository = strings.TrimSpace(repository)
|
||||||
|
parts := strings.Split(repository, "/")
|
||||||
|
if len(parts) != 2 || !ownerPattern.MatchString(parts[0]) || !repoPattern.MatchString(parts[1]) ||
|
||||||
|
len(parts[1]) > 100 || parts[1] == "." || parts[1] == ".." {
|
||||||
|
return "", errInvalidRequest
|
||||||
|
}
|
||||||
|
return parts[0] + "/" + parts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAssetName(assetName string) bool {
|
||||||
|
return validLogText(assetName, maxSafeTextBytes, false) && path.Base(assetName) == assetName &&
|
||||||
|
assetName != "." && assetName != ".." && !strings.ContainsAny(assetName, `/\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTag(tag string) bool {
|
||||||
|
if !validLogText(tag, maxSafeTextBytes, false) || strings.ContainsAny(tag, " ~^:?*[\\") ||
|
||||||
|
strings.Contains(tag, "..") || strings.Contains(tag, "@{") || strings.Contains(tag, "//") ||
|
||||||
|
strings.HasPrefix(tag, "/") || strings.HasSuffix(tag, "/") || strings.HasSuffix(tag, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, component := range strings.Split(tag, "/") {
|
||||||
|
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validReleaseDisplayTag(tag string) bool {
|
||||||
|
return validLogText(tag, maxSafeTextBytes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLogText(value string, maxBytes int, allowEmpty bool) bool {
|
||||||
|
if (!allowEmpty && value == "") || len(value) > maxBytes || !utf8.ValidString(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if isLogControl(character) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLogControl(character rune) bool {
|
||||||
|
if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch character {
|
||||||
|
case '\u061c', '\u200e', '\u200f',
|
||||||
|
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
|
||||||
|
'\u2066', '\u2067', '\u2068', '\u2069':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validErrorTag(tag string) string {
|
||||||
|
if !validTag(tag) || containsSecretDelimiter(tag) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func validErrorAssetName(assetName string) string {
|
||||||
|
if !validAssetName(assetName) || containsSecretDelimiter(assetName) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return assetName
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSecretDelimiter(value string) bool {
|
||||||
|
return strings.ContainsAny(value, "?&=#") || strings.Contains(value, "://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPositiveID(id string) bool {
|
||||||
|
parsed, err := strconv.ParseInt(id, 10, 64)
|
||||||
|
return err == nil && parsed > 0 && strconv.FormatInt(parsed, 10) == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func positiveJSONID(id json.Number) (string, error) {
|
||||||
|
parsed, err := strconv.ParseInt(id.String(), 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return "", errMetadata
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(parsed, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalTime(value string) (time.Time, error) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, errMetadata
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyDeclaredDigest(declaredDigest string, checksum string) error {
|
||||||
|
declaredDigest = strings.TrimSpace(declaredDigest)
|
||||||
|
if declaredDigest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
algorithm, digest, ok := strings.Cut(declaredDigest, ":")
|
||||||
|
if !ok || !strings.EqualFold(algorithm, "sha256") || !hexPattern.MatchString(digest) ||
|
||||||
|
!strings.EqualFold(digest, checksum) {
|
||||||
|
return errDigest
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeAssetNames(assets []Asset) []string {
|
||||||
|
count := len(assets)
|
||||||
|
if count > maxAssetErrorNames {
|
||||||
|
count = maxAssetErrorNames
|
||||||
|
}
|
||||||
|
names := make([]string, 0, count)
|
||||||
|
for _, asset := range assets[:count] {
|
||||||
|
name := safeText(asset.Name, maxSafeAssetNameLen)
|
||||||
|
if containsSecretDelimiter(name) {
|
||||||
|
name = "<redacted>"
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeText(value string, maxBytes int) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, character := range value {
|
||||||
|
if isLogControl(character) {
|
||||||
|
builder.WriteByte('?')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteRune(character)
|
||||||
|
if builder.Len() >= maxBytes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := builder.String()
|
||||||
|
for len(result) > maxBytes {
|
||||||
|
_, size := utf8.DecodeLastRuneInString(result)
|
||||||
|
result = result[:len(result)-size]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeETag(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) > maxETagBytes || safeText(value, maxETagBytes) != value {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeHTTPError(kind error, response *http.Response, repository string, tag string, assetName string, retryAt *time.Time) error {
|
||||||
|
return safeError(
|
||||||
|
kind,
|
||||||
|
response.StatusCode,
|
||||||
|
response.Header.Get("X-GitHub-Request-Id"),
|
||||||
|
repository,
|
||||||
|
tag,
|
||||||
|
assetName,
|
||||||
|
nil,
|
||||||
|
retryAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeError(
|
||||||
|
kind error,
|
||||||
|
statusCode int,
|
||||||
|
requestID string,
|
||||||
|
repository string,
|
||||||
|
tag string,
|
||||||
|
assetName string,
|
||||||
|
availableAssets []string,
|
||||||
|
retryAt *time.Time,
|
||||||
|
) error {
|
||||||
|
return &Error{
|
||||||
|
Kind: kind,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
RequestID: safeErrorToken(requestID, maxSafeTextBytes),
|
||||||
|
Repository: safeErrorToken(repository, maxSafeTextBytes),
|
||||||
|
Tag: safeErrorToken(tag, maxSafeTextBytes),
|
||||||
|
AssetName: safeErrorToken(assetName, maxSafeAssetNameLen),
|
||||||
|
AvailableAssets: availableAssets,
|
||||||
|
RetryAt: retryAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeErrorToken(value string, maxBytes int) string {
|
||||||
|
if !validLogText(value, maxBytes, true) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value = safeText(value, maxBytes)
|
||||||
|
if containsSecretDelimiter(value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package githubrelease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolverFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||||
|
|
||||||
|
func (resolve resolverFunc) LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) {
|
||||||
|
return resolve(ctx, network, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLatestUsesGitHubContractAndSelectsUploadedAsset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/repos/acme/site/releases/latest" {
|
||||||
|
t.Errorf("path = %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
assertHeader(t, request, "Accept", metadataAccept)
|
||||||
|
assertHeader(t, request, "User-Agent", defaultUserAgent)
|
||||||
|
assertHeader(t, request, "X-GitHub-Api-Version", APIVersion)
|
||||||
|
assertHeader(t, request, "If-None-Match", `W/"old"`)
|
||||||
|
writer.Header().Set("ETag", `W/"new"`)
|
||||||
|
writer.Header().Set("X-RateLimit-Remaining", "0")
|
||||||
|
writer.Header().Set("X-RateLimit-Reset", "1800000000")
|
||||||
|
_, _ = writer.Write([]byte(`{
|
||||||
|
"id": 9007199254740991,
|
||||||
|
"tag_name": "v1.2.3",
|
||||||
|
"name": "Stable",
|
||||||
|
"published_at": "2026-07-18T12:00:00Z",
|
||||||
|
"assets": [
|
||||||
|
{"id": 11, "name": "dist.zip", "state": "new", "size": 1},
|
||||||
|
{"id": 9007199254740990, "name": "dist.zip", "state": "uploaded", "size": 42,
|
||||||
|
"updated_at": "2026-07-18T12:10:00Z", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site",
|
||||||
|
Selector: SelectorLatest,
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
ETag: `W/"old"`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Release.ID != "9007199254740991" || result.Asset.ID != "9007199254740990" {
|
||||||
|
t.Fatalf("IDs lost precision: release=%q asset=%q", result.Release.ID, result.Asset.ID)
|
||||||
|
}
|
||||||
|
if result.ETag != `W/"new"` || result.Asset.Name != "dist.zip" || result.Asset.State != "uploaded" {
|
||||||
|
t.Fatalf("Resolve() = %+v", result)
|
||||||
|
}
|
||||||
|
if result.RetryAt == nil || result.RetryAt.Unix() != 1800000000 {
|
||||||
|
t.Fatalf("RetryAt = %v", result.RetryAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTagEscapesPathAndHandlesNotModified(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.RequestURI != "/repos/acme/site/releases/tags/release%2Fcandidate" {
|
||||||
|
t.Errorf("RequestURI = %q", request.RequestURI)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusNotModified)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site",
|
||||||
|
Selector: SelectorTag,
|
||||||
|
Tag: "release/candidate",
|
||||||
|
AssetName: "dist.zip",
|
||||||
|
ETag: `"cached"`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if !result.NotModified || result.ETag != `"cached"` {
|
||||||
|
t.Fatalf("Resolve() = %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAssetMissingTruncatesSafeNamesAndNeverIncludesBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assets := make([]string, 0, 12)
|
||||||
|
for index := 0; index < 12; index++ {
|
||||||
|
assets = append(assets, fmt.Sprintf(`{"id":%d,"name":"asset-%02d.zip","state":"uploaded","size":1}`, index+1, index))
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","message":"body-token","assets":[` + strings.Join(assets, ",") + `]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errAssetMissing) {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
if !strings.Contains(message, "asset-00.zip") || !strings.Contains(message, "asset-09.zip") {
|
||||||
|
t.Fatalf("error misses safe truncated names: %s", message)
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "asset-10.zip") || strings.Contains(message, "asset-11.zip") || strings.Contains(message, "body-token") {
|
||||||
|
t.Fatalf("error leaked/truncation failed: %s", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveHTTPErrorParsesRateLimitWithoutBodyLeak(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, time.July, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Retry-After", "90")
|
||||||
|
writer.Header().Set("X-GitHub-Request-Id", "request-123")
|
||||||
|
writer.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = writer.Write([]byte(`{"message":"signed_url=https://secret.example/a?token=hidden"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, func(options *clientOptions) { options.now = func() time.Time { return now } })
|
||||||
|
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "status=429") || !strings.Contains(err.Error(), "request_id=request-123") {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret.example") || strings.Contains(err.Error(), "hidden") {
|
||||||
|
t.Fatalf("error leaked body: %s", err)
|
||||||
|
}
|
||||||
|
retryAt, ok := RetryTime(err)
|
||||||
|
if !ok || !retryAt.Equal(now.Add(90*time.Second)) {
|
||||||
|
t.Fatalf("RetryTime() = %v, %v", retryAt, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadStreamsVerifiesDigestAndCleansUp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload := []byte("package bytes")
|
||||||
|
digest := sha256.Sum256(payload)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/repos/acme/site/releases/assets/42" {
|
||||||
|
t.Errorf("path = %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
assertHeader(t, request, "Accept", assetAccept)
|
||||||
|
assertHeader(t, request, "Accept-Encoding", "identity")
|
||||||
|
_, _ = writer.Write(payload)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
result, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site",
|
||||||
|
Asset: Asset{
|
||||||
|
ID: "42", Name: "dist.zip", Digest: "sha256:" + hex.EncodeToString(digest[:]),
|
||||||
|
},
|
||||||
|
MaxBytes: 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Size != int64(len(payload)) || result.SHA256 != hex.EncodeToString(digest[:]) {
|
||||||
|
t.Fatalf("Download() = %+v", result)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(result.Path); err != nil {
|
||||||
|
t.Fatalf("temp file stat: %v", err)
|
||||||
|
}
|
||||||
|
if err := result.Cleanup(); err != nil {
|
||||||
|
t.Fatalf("Cleanup() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := result.Cleanup(); err != nil {
|
||||||
|
t.Fatalf("second Cleanup() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadFollows302AndStripsCrossHostSensitiveHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var targetHost string
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
for _, header := range []string{"Authorization", "Cookie", "Proxy-Authorization", "Referer", "If-None-Match", "If-Modified-Since", "X-GitHub-Api-Version"} {
|
||||||
|
if value := request.Header.Get(header); value != "" {
|
||||||
|
t.Errorf("redirect leaked %s=%q", header, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte("redirected package"))
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
targetURL, _ := url.Parse(target.URL)
|
||||||
|
targetHost = "asset.example.test:" + targetURL.Port()
|
||||||
|
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Location", "http://"+targetHost+"/signed/package.zip?token=must-not-leak")
|
||||||
|
writer.WriteHeader(http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
apiURL, _ := url.Parse(api.URL)
|
||||||
|
baseURL := "http://api.example.test:" + apiURL.Port()
|
||||||
|
client := newMappedTestClient(t, baseURL, nil)
|
||||||
|
result, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "42", Name: "dist.zip"}, MaxBytes: 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download() redirect error = %v", err)
|
||||||
|
}
|
||||||
|
if cleanupErr := result.Cleanup(); cleanupErr != nil {
|
||||||
|
t.Fatalf("Cleanup() error = %v", cleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/repos/acme/site/releases/assets/42", nil)
|
||||||
|
applyAssetHeaders(request)
|
||||||
|
request.Header.Set("Authorization", "Bearer secret")
|
||||||
|
request.Header.Set("Cookie", "session=secret")
|
||||||
|
request.Header.Set("Proxy-Authorization", "proxy-secret")
|
||||||
|
request.Header.Set("Referer", "https://secret.example/path?token=x")
|
||||||
|
request.Header.Set("If-None-Match", `"secret-etag"`)
|
||||||
|
request.Header.Set("If-Modified-Since", time.Now().Format(http.TimeFormat))
|
||||||
|
response, err := client.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do() error = %v", err)
|
||||||
|
}
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectSSRFAndDNSRebindingAreRejectedWithoutURLLeak(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("literal private redirect", func(t *testing.T) {
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Location", "http://127.0.0.1/private?token=secret-query")
|
||||||
|
writer.WriteHeader(http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
client := newTestClient(t, api.URL, nil)
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||||
|
})
|
||||||
|
if err == nil || strings.Contains(err.Error(), "secret-query") || strings.Contains(err.Error(), "127.0.0.1") {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DNS rebind between redirect and dial", func(t *testing.T) {
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Location", "http://rebind.example.test/package.zip")
|
||||||
|
writer.WriteHeader(http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
var lock sync.Mutex
|
||||||
|
calls := map[string]int{}
|
||||||
|
resolve := resolverFunc(func(_ context.Context, _ string, host string) ([]netip.Addr, error) {
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
calls[host]++
|
||||||
|
if host == "rebind.example.test" && calls[host] > 1 {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||||
|
}
|
||||||
|
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||||
|
})
|
||||||
|
client := newTestClient(t, api.URL, func(options *clientOptions) { options.resolver = resolve })
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Download() error = nil")
|
||||||
|
}
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
if calls["rebind.example.test"] != 2 {
|
||||||
|
t.Fatalf("rebind lookup calls = %d", calls["rebind.example.test"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadFailureRemovesTemporaryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload := []byte("package bytes")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write(payload)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
client := newTestClient(t, server.URL, func(options *clientOptions) {
|
||||||
|
options.createTemp = func(_ string, pattern string) (*os.File, error) {
|
||||||
|
return os.CreateTemp(tempDir, pattern)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site",
|
||||||
|
Asset: Asset{
|
||||||
|
ID: "42", Name: "dist.zip", Digest: "sha256:" + strings.Repeat("0", 64),
|
||||||
|
},
|
||||||
|
MaxBytes: 1024,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errDigest) {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
files, readErr := filepath.Glob(filepath.Join(tempDir, "*"))
|
||||||
|
if readErr != nil || len(files) != 0 {
|
||||||
|
t.Fatalf("temporary files after failure = %v, err=%v", files, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRejectsInvalidRepositoryAndAssetWithoutRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client := NewClient()
|
||||||
|
for _, request := range []ResolveRequest{
|
||||||
|
{Repository: "https://github.com/acme/site", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||||
|
{Repository: "acme/site/extra", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: `dir\dist.zip`},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: string([]byte{'d', 'i', 's', 't', 0xff})},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\nsecret.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u2028secret.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist\u202esecret.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorTag, AssetName: "dist.zip"},
|
||||||
|
} {
|
||||||
|
_, err := client.Resolve(context.Background(), request)
|
||||||
|
if !errors.Is(err, errInvalidRequest) {
|
||||||
|
t.Errorf("Resolve(%+v) error = %v", request, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAndDownloadAssetNameWithDelimiters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assetName := "dist?channel=stable#1&x.zip"
|
||||||
|
payload := []byte("package with delimiter name")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/repos/acme/site/releases/latest":
|
||||||
|
if request.Header.Get("If-None-Match") == "missing" {
|
||||||
|
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":42,"name":%q,"state":"uploaded","size":%d}]}`,
|
||||||
|
assetName, len(payload))
|
||||||
|
case "/repos/acme/site/releases/assets/42":
|
||||||
|
_, _ = writer.Write(payload)
|
||||||
|
default:
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
|
||||||
|
resolved, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Asset.Name != assetName || resolved.Release.Tag != "release/v1" {
|
||||||
|
t.Fatalf("Resolve() = %+v", resolved)
|
||||||
|
}
|
||||||
|
download, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: resolved.Asset, MaxBytes: 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
if cleanupErr := download.Cleanup(); cleanupErr != nil {
|
||||||
|
t.Fatalf("Cleanup() error = %v", cleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: assetName, ETag: "missing",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrAssetNotFound) {
|
||||||
|
t.Fatalf("missing Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), assetName) || strings.Contains(err.Error(), "channel=stable") {
|
||||||
|
t.Fatalf("missing error leaked delimiter-bearing name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixedTagGitRefRulesAndEscaping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
valid := []string{"@", "release/v1#stable&channel=prod", "foo.LOCK", "中文/发布=稳定"}
|
||||||
|
for _, tag := range valid {
|
||||||
|
if !validTag(tag) {
|
||||||
|
t.Errorf("validTag(%q) = false", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid := []string{
|
||||||
|
"", "release v1", "release~v1", "release^v1", "release:v1", "release?v1", "release*v1",
|
||||||
|
"release[v1", `release\v1`, "release..v1", "release@{v1", "release//v1", "/release", "release/",
|
||||||
|
"release.", ".release", "release/.candidate", "release.lock", "release/v1.lock", "release\nsecret",
|
||||||
|
"release\u2028secret", "release\u202esecret", string([]byte{'v', '1', 0xff}), strings.Repeat("a", 256),
|
||||||
|
}
|
||||||
|
for _, tag := range invalid {
|
||||||
|
if validTag(tag) {
|
||||||
|
t.Errorf("validTag(%q) = true", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := "release/v1#stable&channel=prod"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
wantURI := "/repos/acme/site/releases/tags/" + url.PathEscape(tag)
|
||||||
|
if request.RequestURI != wantURI || request.URL.RawQuery != "" || request.URL.Fragment != "" {
|
||||||
|
t.Errorf("tag request = %q query=%q fragment=%q, want %q", request.RequestURI, request.URL.RawQuery, request.URL.Fragment, wantURI)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":%q,"assets":[{"id":2,"name":"dist.zip","state":"uploaded","size":1}]}`, tag)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
result, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorTag, Tag: tag, AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Release.Tag != tag {
|
||||||
|
t.Fatalf("Release.Tag = %q", result.Release.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDoesNotMatchSanitizedRemoteAssetName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remote string
|
||||||
|
requested string
|
||||||
|
}{
|
||||||
|
{name: "unicode line separator", remote: "dist\u2028.zip", requested: "dist?.zip"},
|
||||||
|
{name: "overlong", remote: strings.Repeat("a", 256), requested: strings.Repeat("a", 255)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"id":1,"tag_name":"release/v1","assets":[{"id":2,"name":%q,"state":"uploaded","size":1}]}`, test.remote)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: test.requested,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrAssetNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseDisplayTagValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, tag := range []string{"release/v1", "release v1", "release/v1#stable&channel=prod"} {
|
||||||
|
if !validReleaseDisplayTag(tag) {
|
||||||
|
t.Errorf("validReleaseDisplayTag(%q) = false", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range []string{"", strings.Repeat("a", 256), "release\nsecret", "release\u2028secret", "release\u202esecret"} {
|
||||||
|
if validReleaseDisplayTag(tag) {
|
||||||
|
t.Errorf("validReleaseDisplayTag(%q) = true", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRejectsInvalidUTF8Metadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write(append([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":2,"name":"dist`),
|
||||||
|
append([]byte{0xff}, []byte(`.zip","state":"uploaded","size":1}]}`)...)...))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist�.zip",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrMetadata) {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadRejectsImpossibleMetadataBeforeNetwork(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var requests atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
kind error
|
||||||
|
limit int64
|
||||||
|
}{
|
||||||
|
{name: "negative", size: -1, kind: ErrInvalidRequest, limit: 100},
|
||||||
|
{name: "declared too large", size: 101, kind: ErrAssetTooLarge, limit: 100},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site",
|
||||||
|
Asset: Asset{ID: "1", Name: "dist?token=hidden#asset.zip", Size: test.size},
|
||||||
|
MaxBytes: test.limit,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, test.kind) {
|
||||||
|
t.Errorf("%s Download() error = %v", test.name, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "token=hidden") {
|
||||||
|
t.Errorf("%s error leaked asset name: %v", test.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := requests.Load(); got != 0 {
|
||||||
|
t.Fatalf("HTTP requests = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogControlCharactersNeverEnterSafeErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
controls := []string{"\u2028", "\u2029", "\u061c", "\u200e", "\u200f", "\u202e", "\u2066", "\u2069"}
|
||||||
|
for _, control := range controls {
|
||||||
|
secret := "before" + control + "after"
|
||||||
|
err := safeError(errInvalidRequest, 0, secret, secret, secret, secret, nil, nil)
|
||||||
|
message := err.Error()
|
||||||
|
if strings.Contains(message, secret) || strings.Contains(message, control) || strings.Contains(message, "before") {
|
||||||
|
t.Errorf("safe error retained control %U: %q", []rune(control)[0], message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRejectsMetadataOverHardLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write([]byte(`{"id":1,"tag_name":"v1","assets":[]}` + strings.Repeat(" ", maxMetadataBytes)))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Resolve(context.Background(), ResolveRequest{
|
||||||
|
Repository: "acme/site", Selector: SelectorLatest, AssetName: "dist.zip",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrMetadata) {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductionTransportRejectsSelfSignedTLS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = writer.Write([]byte("package"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
parsed, _ := url.Parse(server.URL)
|
||||||
|
dialer := &net.Dialer{Timeout: time.Second}
|
||||||
|
client := newClient(clientOptions{
|
||||||
|
baseURL: "https://api.example.test:" + parsed.Port(),
|
||||||
|
resolver: resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||||
|
}),
|
||||||
|
dialContext: func(ctx context.Context, network string, _ string) (net.Conn, error) {
|
||||||
|
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||||
|
createTemp: os.CreateTemp,
|
||||||
|
now: time.Now,
|
||||||
|
clientTimeout: 5 * time.Second,
|
||||||
|
})
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrDownload) {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "api.example.test") || strings.Contains(err.Error(), server.URL) {
|
||||||
|
t.Fatalf("TLS error leaked URL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStableErrorClassification(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Now()
|
||||||
|
assetMissing := safeError(errAssetMissing, http.StatusOK, "", "acme/site", "v1", "dist.zip", nil, nil)
|
||||||
|
if !IsNotFound(assetMissing) || IsRetryable(assetMissing) {
|
||||||
|
t.Fatalf("asset missing classification failed: %v", assetMissing)
|
||||||
|
}
|
||||||
|
metadata404 := safeError(errMetadata, http.StatusNotFound, "", "acme/site", "v1", "dist.zip", nil, nil)
|
||||||
|
if !IsNotFound(metadata404) || IsRetryable(metadata404) {
|
||||||
|
t.Fatalf("metadata 404 classification failed: %v", metadata404)
|
||||||
|
}
|
||||||
|
digest := safeError(errDigest, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil)
|
||||||
|
if !IsDigestError(digest) || IsRetryable(digest) {
|
||||||
|
t.Fatalf("digest classification failed: %v", digest)
|
||||||
|
}
|
||||||
|
for _, retryable := range []error{
|
||||||
|
safeError(errMetadata, 0, "", "acme/site", "", "dist.zip", nil, nil),
|
||||||
|
safeError(errMetadata, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
|
||||||
|
safeError(errDownload, http.StatusOK, "", "acme/site", "", "dist.zip", nil, nil),
|
||||||
|
safeError(errMetadata, http.StatusInternalServerError, "", "acme/site", "", "dist.zip", nil, nil),
|
||||||
|
safeError(errMetadata, http.StatusForbidden, "", "acme/site", "", "dist.zip", nil, &now),
|
||||||
|
} {
|
||||||
|
if !IsRetryable(retryable) {
|
||||||
|
t.Errorf("IsRetryable(%v) = false", retryable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadRedirectLimitIsSafe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
step, _ := strconv.Atoi(request.URL.Query().Get("step"))
|
||||||
|
writer.Header().Set("Location", fmt.Sprintf("/repos/acme/site/releases/assets/1?step=%d&token=redirect-secret", step+1))
|
||||||
|
writer.WriteHeader(http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 100,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Download() error = nil")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "redirect-secret") || strings.Contains(err.Error(), "step=") {
|
||||||
|
t.Fatalf("redirect error leaked Location: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidRequestDoesNotEchoURLQueryTagOrAsset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client := NewClient()
|
||||||
|
requests := []ResolveRequest{
|
||||||
|
{Repository: "https://github.com/acme/site?token=repo-secret", Selector: SelectorLatest, AssetName: "dist.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorTag, Tag: "?token=tag-secret", AssetName: "dist.zip"},
|
||||||
|
{Repository: "acme/site", Selector: SelectorLatest, AssetName: "../dist.zip?token=asset-secret"},
|
||||||
|
}
|
||||||
|
for _, request := range requests {
|
||||||
|
_, err := client.Resolve(context.Background(), request)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Resolve(%+v) error = nil", request)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"repo-secret", "tag-secret", "asset-secret", "https://github.com"} {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("Resolve(%+v) leaked %q: %v", request, secret, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClient(t *testing.T, rawBaseURL string, customize func(*clientOptions)) *Client {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := url.Parse(rawBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
baseURL := "http://api.example.test:" + parsed.Port()
|
||||||
|
return newMappedTestClient(t, baseURL, customize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMappedTestClient(t *testing.T, baseURL string, customize func(*clientOptions)) *Client {
|
||||||
|
t.Helper()
|
||||||
|
resolve := resolverFunc(func(_ context.Context, _ string, _ string) ([]netip.Addr, error) {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil
|
||||||
|
})
|
||||||
|
dialer := &net.Dialer{Timeout: time.Second}
|
||||||
|
options := clientOptions{
|
||||||
|
baseURL: baseURL,
|
||||||
|
resolver: resolve,
|
||||||
|
allowHTTP: true,
|
||||||
|
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||||
|
dialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||||
|
_, port, splitErr := net.SplitHostPort(address)
|
||||||
|
if splitErr != nil {
|
||||||
|
return nil, splitErr
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
|
||||||
|
},
|
||||||
|
createTemp: os.CreateTemp,
|
||||||
|
now: time.Now,
|
||||||
|
clientTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
if customize != nil {
|
||||||
|
customize(&options)
|
||||||
|
}
|
||||||
|
return newClient(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHeader(t *testing.T, request *http.Request, name string, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
if actual := request.Header.Get(name); actual != expected {
|
||||||
|
t.Errorf("%s = %q, want %q", name, actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseRetryAtHTTPDate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
want := time.Date(2026, time.July, 19, 12, 30, 0, 0, time.UTC)
|
||||||
|
response := &http.Response{Header: make(http.Header)}
|
||||||
|
response.Header.Set("Retry-After", want.Format(http.TimeFormat))
|
||||||
|
if got := responseRetryAt(response, time.Time{}); got == nil || !got.Equal(want) {
|
||||||
|
t.Fatalf("responseRetryAt() = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseRetryAtRejectsDurationOverflow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
response := &http.Response{Header: make(http.Header)}
|
||||||
|
response.Header.Set("Retry-After", strconv.FormatInt(maxRetryAfterSeconds+1, 10))
|
||||||
|
if got := responseRetryAt(response, time.Now()); got != nil {
|
||||||
|
t.Fatalf("responseRetryAt(overflow) = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeETagDropsOversizedOrControlValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := safeETag(strings.Repeat("x", 513)); got != "" {
|
||||||
|
t.Fatalf("safeETag(overlong) = %q", got)
|
||||||
|
}
|
||||||
|
if got := safeETag("ok\nsecret"); got != "" {
|
||||||
|
t.Fatalf("safeETag(control) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadSizeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Length", strconv.Itoa(20))
|
||||||
|
_, _ = writer.Write([]byte(strings.Repeat("x", 20)))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server.URL, nil)
|
||||||
|
_, err := client.Download(context.Background(), DownloadRequest{
|
||||||
|
Repository: "acme/site", Asset: Asset{ID: "1", Name: "dist.zip"}, MaxBytes: 10,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errTooLarge) {
|
||||||
|
t.Fatalf("Download() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package githubrelease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/httppool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
clientTimeout = 10 * time.Minute
|
||||||
|
dialTimeout = 30 * time.Second
|
||||||
|
dialKeepAlive = 30 * time.Second
|
||||||
|
responseHeaderTimeout = 30 * time.Second
|
||||||
|
maxRedirects = 5
|
||||||
|
maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBlockedTarget = errors.New("GitHub Release 请求目标不是公网地址")
|
||||||
|
errResolveTarget = errors.New("GitHub Release 请求目标解析失败")
|
||||||
|
errRedirectLimit = errors.New("GitHub Release asset 重定向次数过多")
|
||||||
|
|
||||||
|
publicIPv6Prefix = netip.MustParsePrefix("2000::/3")
|
||||||
|
nonPublicPrefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("0.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
netip.MustParsePrefix("127.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("169.254.0.0/16"),
|
||||||
|
netip.MustParsePrefix("172.16.0.0/12"),
|
||||||
|
netip.MustParsePrefix("192.0.0.0/24"),
|
||||||
|
netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"),
|
||||||
|
netip.MustParsePrefix("198.51.100.0/24"),
|
||||||
|
netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
netip.MustParsePrefix("224.0.0.0/4"),
|
||||||
|
netip.MustParsePrefix("240.0.0.0/4"),
|
||||||
|
netip.MustParsePrefix("::/128"),
|
||||||
|
netip.MustParsePrefix("::1/128"),
|
||||||
|
netip.MustParsePrefix("::ffff:0:0/96"),
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"),
|
||||||
|
netip.MustParsePrefix("100::/64"),
|
||||||
|
netip.MustParsePrefix("2001:db8::/32"),
|
||||||
|
netip.MustParsePrefix("fc00::/7"),
|
||||||
|
netip.MustParsePrefix("fe80::/10"),
|
||||||
|
netip.MustParsePrefix("ff00::/8"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolver interface {
|
||||||
|
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientOptions struct {
|
||||||
|
baseURL string
|
||||||
|
resolver resolver
|
||||||
|
dialContext func(context.Context, string, string) (net.Conn, error)
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
allowHTTP bool
|
||||||
|
createTemp func(string, string) (*os.File, error)
|
||||||
|
now func() time.Time
|
||||||
|
clientTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClientOptions() clientOptions {
|
||||||
|
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
||||||
|
return clientOptions{
|
||||||
|
baseURL: defaultAPIBaseURL,
|
||||||
|
resolver: net.DefaultResolver,
|
||||||
|
dialContext: dialer.DialContext,
|
||||||
|
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||||
|
createTemp: os.CreateTemp,
|
||||||
|
now: time.Now,
|
||||||
|
clientTimeout: clientTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(options clientOptions) *Client {
|
||||||
|
if options.baseURL == "" {
|
||||||
|
options.baseURL = defaultAPIBaseURL
|
||||||
|
}
|
||||||
|
if options.resolver == nil {
|
||||||
|
options.resolver = net.DefaultResolver
|
||||||
|
}
|
||||||
|
if options.dialContext == nil {
|
||||||
|
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
||||||
|
options.dialContext = dialer.DialContext
|
||||||
|
}
|
||||||
|
if options.tlsConfig == nil {
|
||||||
|
options.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
if options.createTemp == nil {
|
||||||
|
options.createTemp = os.CreateTemp
|
||||||
|
}
|
||||||
|
if options.now == nil {
|
||||||
|
options.now = time.Now
|
||||||
|
}
|
||||||
|
if options.clientTimeout <= 0 {
|
||||||
|
options.clientTimeout = clientTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
secureDial := publicDialer(options.resolver, options.dialContext)
|
||||||
|
transport := httppool.NewTransport(httppool.TransportOptions{
|
||||||
|
Proxy: nil,
|
||||||
|
DialContext: secureDial,
|
||||||
|
TLSClientConfig: options.tlsConfig,
|
||||||
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||||
|
TraceFilter: func(request *http.Request) bool {
|
||||||
|
return request.URL == nil || request.URL.RawQuery == ""
|
||||||
|
},
|
||||||
|
})
|
||||||
|
httpClient := &http.Client{Timeout: options.clientTimeout, Transport: transport}
|
||||||
|
httpClient.CheckRedirect = func(next *http.Request, previous []*http.Request) error {
|
||||||
|
if len(previous) > maxRedirects {
|
||||||
|
return errRedirectLimit
|
||||||
|
}
|
||||||
|
if err := validateTarget(next.Context(), next.URL, options.resolver, options.allowHTTP); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(previous) > 0 && !sameHost(previous[len(previous)-1].URL, next.URL) {
|
||||||
|
stripCrossHostHeaders(next)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
httpClient: httpClient,
|
||||||
|
baseURL: strings.TrimRight(options.baseURL, "/"),
|
||||||
|
createTemp: options.createTemp,
|
||||||
|
now: options.now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMetadataHeaders(request *http.Request, etag string) {
|
||||||
|
request.Header.Set("Accept", metadataAccept)
|
||||||
|
request.Header.Set("User-Agent", defaultUserAgent)
|
||||||
|
request.Header.Set("X-GitHub-Api-Version", APIVersion)
|
||||||
|
if etag = safeETag(etag); etag != "" {
|
||||||
|
request.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAssetHeaders(request *http.Request) {
|
||||||
|
request.Header.Set("Accept", assetAccept)
|
||||||
|
request.Header.Set("Accept-Encoding", "identity")
|
||||||
|
request.Header.Set("User-Agent", defaultUserAgent)
|
||||||
|
request.Header.Set("X-GitHub-Api-Version", APIVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripCrossHostHeaders(request *http.Request) {
|
||||||
|
for _, header := range []string{
|
||||||
|
"Authorization",
|
||||||
|
"Cookie",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"Referer",
|
||||||
|
"If-None-Match",
|
||||||
|
"If-Modified-Since",
|
||||||
|
"X-GitHub-Api-Version",
|
||||||
|
} {
|
||||||
|
request.Header.Del(header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameHost(left *url.URL, right *url.URL) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(left.Hostname(), right.Hostname()) && effectivePort(left) == effectivePort(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectivePort(target *url.URL) string {
|
||||||
|
if port := target.Port(); port != "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
if strings.EqualFold(target.Scheme, "https") {
|
||||||
|
return "443"
|
||||||
|
}
|
||||||
|
return "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTarget(ctx context.Context, target *url.URL, targetResolver resolver, allowHTTP bool) error {
|
||||||
|
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" || target.Hostname() == "" {
|
||||||
|
return errBlockedTarget
|
||||||
|
}
|
||||||
|
isHTTPS := strings.EqualFold(target.Scheme, "https")
|
||||||
|
isAllowedHTTP := allowHTTP && strings.EqualFold(target.Scheme, "http")
|
||||||
|
if !isHTTPS && !isAllowedHTTP {
|
||||||
|
return errBlockedTarget
|
||||||
|
}
|
||||||
|
_, err := resolvePublicIPs(ctx, targetResolver, target.Hostname())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicDialer(
|
||||||
|
targetResolver resolver,
|
||||||
|
directDial func(context.Context, string, string) (net.Conn, error),
|
||||||
|
) func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errResolveTarget
|
||||||
|
}
|
||||||
|
addresses, err := resolvePublicIPs(ctx, targetResolver, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, resolved := range addresses {
|
||||||
|
if !ipMatchesNetwork(resolved, network) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connection, dialErr := directDial(ctx, network, net.JoinHostPort(resolved.String(), port))
|
||||||
|
if dialErr == nil {
|
||||||
|
return connection, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePublicIPs(ctx context.Context, targetResolver resolver, host string) ([]netip.Addr, error) {
|
||||||
|
if strings.Contains(host, "%") {
|
||||||
|
return nil, errBlockedTarget
|
||||||
|
}
|
||||||
|
if literal, err := netip.ParseAddr(host); err == nil {
|
||||||
|
if !isPublicIP(literal) {
|
||||||
|
return nil, errBlockedTarget
|
||||||
|
}
|
||||||
|
return []netip.Addr{literal}, nil
|
||||||
|
}
|
||||||
|
if targetResolver == nil {
|
||||||
|
return nil, errResolveTarget
|
||||||
|
}
|
||||||
|
addresses, err := targetResolver.LookupNetIP(ctx, "ip", host)
|
||||||
|
if err != nil || len(addresses) == 0 {
|
||||||
|
return nil, errResolveTarget
|
||||||
|
}
|
||||||
|
for _, address := range addresses {
|
||||||
|
if !isPublicIP(address) {
|
||||||
|
return nil, errBlockedTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addresses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicIP(address netip.Addr) bool {
|
||||||
|
if !address.IsValid() || address.Zone() != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
address = address.Unmap()
|
||||||
|
if !address.IsGlobalUnicast() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if address.Is6() && !publicIPv6Prefix.Contains(address) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range nonPublicPrefixes {
|
||||||
|
if prefix.Contains(address) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipMatchesNetwork(address netip.Addr, network string) bool {
|
||||||
|
switch network {
|
||||||
|
case "tcp4":
|
||||||
|
return address.Unmap().Is4()
|
||||||
|
case "tcp6":
|
||||||
|
return address.Unmap().Is6()
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseRetryAt(response *http.Response, now time.Time) *time.Time {
|
||||||
|
if response == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if retryAfter := strings.TrimSpace(response.Header.Get("Retry-After")); retryAfter != "" {
|
||||||
|
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 && seconds <= maxRetryAfterSeconds {
|
||||||
|
retryAt := now.Add(time.Duration(seconds) * time.Second)
|
||||||
|
return &retryAt
|
||||||
|
}
|
||||||
|
if retryAt, err := http.ParseTime(retryAfter); err == nil {
|
||||||
|
retryAt = retryAt.UTC()
|
||||||
|
return &retryAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(response.Header.Get("X-RateLimit-Remaining")) != "0" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reset, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("X-RateLimit-Reset")), 10, 64)
|
||||||
|
if err != nil || reset <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
retryAt := time.Unix(reset, 0).UTC()
|
||||||
|
return &retryAt
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user