feat: add DELETE endpoint for DDNS configuration and implement deletion logic

This commit is contained in:
0xJacky
2026-03-15 02:44:48 +00:00
parent a2e1e8e31c
commit bc8df5beda
16 changed files with 897 additions and 248 deletions
+18
View File
@@ -241,6 +241,24 @@ func UpdateDDNSConfig(c *gin.Context) {
c.JSON(http.StatusOK, toDDNSResponse(cfg))
}
// DeleteDDNSConfig removes DDNS settings for a domain and stops its schedule.
func DeleteDDNSConfig(c *gin.Context) {
domainID := cast.ToUint64(c.Param("id"))
svc := dnsService.NewService()
if err := svc.DeleteDDNSConfig(c.Request.Context(), domainID); err != nil {
cosy.ErrHandler(c, err)
return
}
if err := cron.RemoveDDNSJob(domainID); err != nil {
cosy.ErrHandler(c, err)
return
}
c.Status(http.StatusNoContent)
}
func buildPagination(page, perPage int, total int64) model.Pagination {
page = lo.If(page < 1, 1).Else(page)
perPage = lo.If(perPage <= 0, 50).Else(perPage)
+1
View File
@@ -24,6 +24,7 @@ func InitRouter(r *gin.RouterGroup) {
group.GET("/domains/:id/ddns", GetDDNSConfig)
group.PUT("/domains/:id/ddns", UpdateDDNSConfig)
group.DELETE("/domains/:id/ddns", DeleteDDNSConfig)
group.GET("/ddns", ListDDNSConfig)
}
+15 -13
View File
@@ -9,6 +9,17 @@ import (
"github.com/uozi-tech/cosy"
)
func buildNamespaceTestConfigResponse(namespaceID uint64, result nginx.TestConfigResult) gin.H {
return gin.H{
"message": result.Message,
"level": result.Level,
"namespace_id": namespaceID,
"test_scope": result.TestScope,
"sandbox_status": result.SandboxStatus,
"error_category": result.ErrorCategory,
}
}
// Reload reloads the nginx
func Reload(c *gin.Context) {
nginx.Control(nginx.Reload).Resp(c)
@@ -17,10 +28,8 @@ func Reload(c *gin.Context) {
// TestConfig tests the nginx config
func TestConfig(c *gin.Context) {
lastResult := nginx.Control(nginx.TestConfig)
c.JSON(http.StatusOK, gin.H{
"message": lastResult.GetOutput(),
"level": lastResult.GetLevel(),
})
result := nginx.NewTestConfigResult(lastResult.GetStdOut(), lastResult.GetStdErr(), nginx.TestScopeGlobal, "")
c.JSON(http.StatusOK, result)
}
// TestConfigWithNamespace tests nginx config in isolated sandbox for a specific namespace
@@ -73,15 +82,8 @@ func TestConfigWithNamespace(c *gin.Context) {
}
// Use sandbox test with namespace-specific paths
result := nginx.Control(func() (string, error) {
return nginx.SandboxTestConfigWithPaths(namespaceInfo, sitePaths, streamPaths)
})
c.JSON(http.StatusOK, gin.H{
"message": result.GetOutput(),
"level": result.GetLevel(),
"namespace_id": req.NamespaceID,
})
result := nginx.SandboxTestConfigWithPaths(namespaceInfo, sitePaths, streamPaths)
c.JSON(http.StatusOK, buildNamespaceTestConfigResponse(req.NamespaceID, result))
}
// Restart restarts the nginx
+24
View File
@@ -0,0 +1,24 @@
package nginx
import (
"testing"
internalnginx "github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/stretchr/testify/assert"
)
func TestBuildNamespaceTestConfigResponseIncludesSandboxFields(t *testing.T) {
response := buildNamespaceTestConfigResponse(9, internalnginx.TestConfigResult{
Message: "sandbox failed",
Level: internalnginx.Error,
TestScope: internalnginx.TestScopeNamespaceSandbox,
SandboxStatus: internalnginx.SandboxStatusFailed,
ErrorCategory: internalnginx.ErrorCategoryMissingInclude,
})
assert.Equal(t, uint64(9), response["namespace_id"])
assert.Equal(t, "sandbox failed", response["message"])
assert.Equal(t, internalnginx.TestScopeNamespaceSandbox, response["test_scope"])
assert.Equal(t, internalnginx.SandboxStatusFailed, response["sandbox_status"])
assert.Equal(t, internalnginx.ErrorCategoryMissingInclude, response["error_category"])
}
+3
View File
@@ -108,6 +108,9 @@ export const dnsApi = {
listDDNS() {
return http.get<{ data: DDNSDomainItem[] }>(`/dns/ddns`)
},
deleteDDNSConfig(domainId: number) {
return http.delete(`${baseDomainUrl}/${domainId}/ddns`)
},
}
export type { DnsCredential }
+11 -2
View File
@@ -111,6 +111,15 @@ export interface NgxModule {
loaded: boolean
}
export interface NgxTestResult {
message: string
level: number
namespace_id?: number
test_scope?: 'global' | 'namespace_sandbox'
sandbox_status?: 'ok' | 'skipped' | 'failed'
error_category?: 'missing_include' | 'sandbox_build_error' | 'syntax_error' | 'nginx_runtime_error'
}
const ngx = {
build_config(ngxConfig: NgxConfig) {
return http.post('/ngx/build_config', ngxConfig)
@@ -144,11 +153,11 @@ const ngx = {
return http.post('/nginx/restart')
},
test() {
test(): Promise<NgxTestResult> {
return http.post('/nginx/test')
},
test_namespace(namespace_id?: number): Promise<{ message: string, level: number, namespace_id?: number }> {
test_namespace(namespace_id?: number): Promise<NgxTestResult> {
return http.post('/nginx/test_namespace', { namespace_id })
},
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { NgxTestResult } from '@/api/ngx'
import type { CosyError } from '@/lib/http/types'
import ngx from '@/api/ngx'
import { logLevel } from '@/constants/config'
@@ -9,41 +10,92 @@ const props = defineProps<{
namespaceId?: number | string
}>()
interface TestResult extends CosyError {
message: string
level: number
namespace_id?: number
interface TestResult extends NgxTestResult {
code?: string
scope?: string
params?: string[]
}
const data = ref<TestResult>()
const translatedError = ref<string>('')
const testLoading = ref(false)
const statusMessage = computed(() => {
switch (data.value?.sandbox_status) {
case 'skipped':
return $gettext('Sandbox validation skipped')
case 'failed':
return $gettext('Sandbox validation failed')
default:
return $gettext('Error')
}
})
const categoryMessage = computed(() => {
switch (data.value?.error_category) {
case 'missing_include':
return $gettext('A required include file is missing from the sandbox or source configuration.')
case 'sandbox_build_error':
return $gettext('Sandbox setup failed before Nginx could validate the configuration.')
case 'syntax_error':
return $gettext('Nginx reported a configuration syntax error.')
case 'nginx_runtime_error':
return $gettext('Nginx failed to validate the configuration.')
default:
return ''
}
})
const translatedDetails = computed(() => {
if (!translatedError.value || translatedError.value === data.value?.message) {
return ''
}
return translatedError.value
})
// Watch for namespace changes and auto-test
watch(() => props.namespaceId, () => {
test()
}, { immediate: true })
function test() {
async function test() {
testLoading.value = true
translatedError.value = ''
const namespaceIdNum = props.namespaceId ? Number(props.namespaceId) : 0
const testPromise = namespaceIdNum > 0
? ngx.test_namespace(namespaceIdNum)
: ngx.test()
testPromise.then(r => {
data.value = r
if (r && r.level > logLevel.Warn) {
const cosyError: CosyError = {
...r,
}
translateError(cosyError).then(translated => {
translatedError.value = translated
})
try {
const result = namespaceIdNum > 0
? await ngx.test_namespace(namespaceIdNum)
: await ngx.test()
data.value = result
const testResult = result as TestResult
if (testResult.level > logLevel.Warn && testResult.code && testResult.scope) {
translatedError.value = await translateError(testResult as CosyError)
}
}).finally(() => {
}
catch (error) {
const cosyError = error as Partial<CosyError>
const message = cosyError?.message ?? $gettext('Server error')
data.value = {
...cosyError,
message,
level: logLevel.Error,
sandbox_status: namespaceIdNum > 0 ? 'failed' : undefined,
error_category: 'nginx_runtime_error',
test_scope: namespaceIdNum > 0 ? 'namespace_sandbox' : 'global',
}
if (cosyError?.code && cosyError?.scope) {
translatedError.value = await translateError(cosyError as CosyError)
}
}
finally {
testLoading.value = false
})
}
}
defineExpose({
@@ -53,9 +105,45 @@ defineExpose({
<template>
<div class="inspect-container">
<!-- Test Results -->
<AAlert
v-if="data && data.level <= logLevel.Info"
v-if="testLoading"
:banner
:message="$gettext('Testing Nginx configuration...')"
type="info"
show-icon
/>
<AAlert
v-else-if="data?.sandbox_status === 'skipped'"
:banner
:message="$gettext('Sandbox validation skipped')"
type="info"
show-icon
>
<template #description>
{{ data?.message }}
</template>
</AAlert>
<AAlert
v-else-if="data?.sandbox_status === 'failed'"
:banner
:message="$gettext('Sandbox validation failed')"
type="error"
show-icon
>
<template #description>
<div v-if="categoryMessage">
{{ categoryMessage }}
</div>
<div v-if="translatedDetails">
{{ translatedDetails }}
</div>
<div v-if="data?.message">
{{ data?.message }}
</div>
</template>
</AAlert>
<AAlert
v-else-if="data && data.level <= logLevel.Info"
:banner
:message="namespaceId
? $gettext('Configuration file is test successful in isolated sandbox')
@@ -77,13 +165,21 @@ defineExpose({
<AAlert
v-else-if="data && data.level > logLevel.Warn"
:message="$gettext('Error')"
:message="statusMessage"
:banner
type="error"
show-icon
>
<template #description>
{{ translatedError }}
<div v-if="categoryMessage">
{{ categoryMessage }}
</div>
<div v-if="translatedDetails">
{{ translatedDetails }}
</div>
<div v-if="data?.message">
{{ data?.message }}
</div>
</template>
</AAlert>
</div>
+12
View File
@@ -8359,3 +8359,15 @@ msgstr "零分配管道"
#~ msgid "Used: %{used} / Total: %{total}"
#~ msgstr "已使用: %{used} / 总共: %{total}"
#: src/views/dns/DDNSManager.vue:195
msgid "DDNS config deleted"
msgstr "DDNS 配置已删除"
#: src/views/dns/DDNSManager.vue:220
msgid "Search domain, provider or target"
msgstr "搜索域名、提供商或目标记录"
#: src/views/dns/DDNSManager.vue:276
msgid "Are you sure to delete this DDNS config?"
msgstr "确定要删除这个 DDNS 配置吗?"
+10
View File
@@ -95,6 +95,16 @@ export const useDnsStore = defineStore('dns-store', {
this.ddnsLoading = false
}
},
async deleteDDNSConfig(domainId: number) {
this.ddnsLoading = true
try {
await dnsApi.deleteDDNSConfig(domainId)
this.ddnsConfig = undefined
}
finally {
this.ddnsLoading = false
}
},
resetRecords() {
this.records = []
this.recordsPagination = undefined
+121 -13
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DDNSDomainItem, DNSRecord, UpdateDDNSPayload } from '@/api/dns'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { DeleteOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
@@ -11,9 +11,11 @@ const store = useDnsStore()
const loading = computed(() => store.ddnsListLoading)
const items = computed(() => store.ddnsList)
const searchKeyword = ref('')
const drawerOpen = ref(false)
const saving = ref(false)
const deletingDomainId = ref<number | null>(null)
const currentDomain = ref<DDNSDomainItem | null>(null)
const ddnsForm = ref<UpdateDDNSPayload>({
enabled: false,
@@ -24,6 +26,14 @@ const ddnsForm = ref<UpdateDDNSPayload>({
const records = ref<DNSRecord[]>([])
const recordsLoading = ref(false)
const filteredItems = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword)
return items.value
return items.value.filter(item => matchKeyword(item, keyword))
})
const recordOptions = computed(() => {
const opts = new Map<string, { value: string, label: string }>()
records.value
@@ -55,6 +65,34 @@ function filterRecordOption(input: string, option?: { label: string, value: stri
return option.label.toLowerCase().includes(keyword)
}
function normalizeText(value?: string | null) {
return value?.toLowerCase().trim() ?? ''
}
function hasDDNSConfig(item: DDNSDomainItem) {
const config = item.config
return config.enabled
|| Boolean(config.targets?.length)
|| Boolean(config.last_run_at)
|| Boolean(config.last_error)
|| Boolean(config.last_ipv4)
|| Boolean(config.last_ipv6)
}
function matchKeyword(item: DDNSDomainItem, keyword: string) {
const targetText = item.config.targets
?.map(target => `${target.name} ${target.type}`)
.join(' ')
return [
item.domain,
item.credential_name,
item.credential_provider,
targetText,
item.config.enabled ? 'enabled' : 'disabled',
].some(value => normalizeText(value).includes(keyword))
}
const columns = [
{
title: $gettext('Domain'),
@@ -105,6 +143,7 @@ function formatTime(value?: string) {
async function openDrawer(record: DDNSDomainItem) {
currentDomain.value = record
records.value = []
ddnsForm.value = {
enabled: record.config.enabled,
interval_seconds: record.config.interval_seconds,
@@ -125,6 +164,12 @@ async function loadRecords(domainId: number) {
}
}
function closeDrawer() {
drawerOpen.value = false
currentDomain.value = null
records.value = []
}
async function saveDDNS() {
if (!currentDomain.value)
return
@@ -133,13 +178,27 @@ async function saveDDNS() {
await store.updateDDNSConfig(currentDomain.value.id, ddnsForm.value)
await store.refreshDDNSItem(currentDomain.value.id)
message.success($gettext('DDNS saved'))
drawerOpen.value = false
closeDrawer()
}
finally {
saving.value = false
}
}
async function deleteDDNS(record: DDNSDomainItem) {
deletingDomainId.value = record.id
try {
await store.deleteDDNSConfig(record.id)
await store.refreshDDNSItem(record.id)
if (currentDomain.value?.id === record.id)
closeDrawer()
message.success($gettext('DDNS config deleted'))
}
finally {
deletingDomainId.value = null
}
}
onMounted(() => {
init()
})
@@ -147,27 +206,40 @@ onMounted(() => {
<template>
<div class="ddns-page">
<ACard>
<ACard class="ddns-card">
<template #title>
<ASpace align="center">
{{ $gettext('DDNS Overview') }}
</ASpace>
</template>
<template #extra>
<AButton type="link" size="small" :loading="loading" @click="init">
<template #icon>
<ReloadOutlined />
</template>
{{ $gettext('Refresh') }}
</AButton>
<div class="toolbar">
<AInput
v-model:value="searchKeyword"
allow-clear
:placeholder="$gettext('Search domain, provider or target')"
class="toolbar-search"
>
<template #prefix>
<SearchOutlined />
</template>
</AInput>
<AButton size="small" :loading="loading" @click="init">
<template #icon>
<ReloadOutlined />
</template>
{{ $gettext('Refresh') }}
</AButton>
</div>
</template>
<ATable
:loading="loading"
:data-source="items"
:data-source="filteredItems"
:columns="columns"
row-key="id"
:pagination="false"
:scroll="{ x: 960 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
@@ -196,10 +268,28 @@ onMounted(() => {
</div>
</template>
<template v-else-if="column.key === 'actions'">
<ASpace>
<ASpace size="small" wrap>
<AButton size="small" type="link" @click="openDrawer(record as DDNSDomainItem)">
{{ $gettext('Configure') }}
</AButton>
<APopconfirm
:title="$gettext('Are you sure to delete this DDNS config?')"
:disabled="!hasDDNSConfig(record as DDNSDomainItem)"
@confirm="deleteDDNS(record as DDNSDomainItem)"
>
<AButton
size="small"
type="link"
danger
:disabled="!hasDDNSConfig(record as DDNSDomainItem)"
:loading="deletingDomainId === (record as DDNSDomainItem).id"
>
<template #icon>
<DeleteOutlined />
</template>
{{ $gettext('Delete') }}
</AButton>
</APopconfirm>
</ASpace>
</template>
</template>
@@ -210,7 +300,7 @@ onMounted(() => {
:open="drawerOpen"
:title="currentDomain ? `${$gettext('Configure DDNS')} - ${currentDomain.domain}` : ''"
width="520"
@close="drawerOpen = false"
@close="closeDrawer"
>
<ASkeleton v-if="recordsLoading" active />
<template v-else>
@@ -240,7 +330,7 @@ onMounted(() => {
</AFormItem>
</AForm>
<div class="flex gap-2 mt-4">
<AButton @click="drawerOpen = false">
<AButton @click="closeDrawer">
{{ $gettext('Cancel') }}
</AButton>
<AButton type="primary" :loading="saving" @click="saveDDNS">
@@ -256,4 +346,22 @@ onMounted(() => {
.ddns-page {
padding-bottom: 16px;
}
.ddns-card :deep(.ant-card-head) {
padding-inline: 20px;
}
.ddns-card :deep(.ant-card-body) {
padding: 20px;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-search {
width: min(320px, 55vw);
}
</style>
+12
View File
@@ -209,6 +209,18 @@ func (s *Service) UpdateDDNSConfig(ctx context.Context, domainID uint64, input D
return cfg, nil
}
// DeleteDDNSConfig removes DDNS configuration for the given domain.
func (s *Service) DeleteDDNSConfig(ctx context.Context, domainID uint64) error {
if _, err := loadDomain(ctx, domainID); err != nil {
return err
}
return model.UseDB().WithContext(ctx).
Model(&model.DnsDomain{}).
Where("id = ?", domainID).
Update("ddns_config", nil).Error
}
// ListEnabledDDNSSchedules returns schedules for enabled DDNS domains.
func ListEnabledDDNSSchedules(ctx context.Context) ([]DDNSSchedule, error) {
domains, err := query.DnsDomain.WithContext(ctx).Find()
+8
View File
@@ -56,10 +56,18 @@ func (t *ControlResult) GetOutput() string {
return strings.Join([]string{t.stdOut, t.stdErr.Error()}, " ")
}
func (t *ControlResult) GetStdOut() string {
return t.stdOut
}
func (t *ControlResult) GetError() error {
return cosy.WrapErrorWithParams(ErrNginx, t.GetOutput())
}
func (t *ControlResult) GetStdErr() error {
return t.stdErr
}
func (t *ControlResult) GetLevel() int {
return GetLogLevel(t.stdOut)
}
+232 -70
View File
@@ -20,30 +20,42 @@ type NamespaceInfo struct {
DeployMode string
}
// SandboxTestConfigWithPaths tests nginx config in an isolated sandbox with provided paths
func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths []string) (stdOut string, stdErr error) {
// SandboxTestConfigWithPaths tests nginx config in an isolated sandbox with provided paths.
func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths []string) TestConfigResult {
// If custom test command is set, use it (no sandbox support)
if settings.NginxSettings.TestConfigCmd != "" {
mutex.Lock()
defer mutex.Unlock()
return execShell(settings.NginxSettings.TestConfigCmd)
stdOut, stdErr := execShell(settings.NginxSettings.TestConfigCmd)
result := NewTestConfigResult(stdOut, stdErr, TestScopeNamespaceSandbox, SandboxStatusSkipped)
result.Message = strings.TrimSpace(strings.Join([]string{
"Sandbox validation skipped because a custom test command is configured.",
result.Message,
}, "\n"))
return result
}
// Skip local test for remote-only namespaces
if namespace != nil && namespace.DeployMode == "remote" {
return "Config validation skipped for remote-only namespace", nil
return TestConfigResult{
Message: "Config validation skipped for remote-only namespace",
Level: Notice,
TestScope: TestScopeNamespaceSandbox,
SandboxStatus: SandboxStatusSkipped,
}
}
// If namespace is nil, directly test in real directory (no sandbox)
if namespace == nil {
return TestConfig()
stdOut, stdErr := TestConfig()
return NewTestConfigResult(stdOut, stdErr, TestScopeGlobal, "")
}
// Create sandbox and test
sandbox, err := createSandbox(namespace, sitePaths, streamPaths)
if err != nil {
logger.Errorf("Failed to create sandbox: %v", err)
return TestConfig() // Fallback to normal test
return NewSandboxBuildFailureResult(err)
}
defer sandbox.Cleanup()
@@ -56,7 +68,8 @@ func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths
sbin = "nginx"
}
return execCommand(sbin, "-t", "-c", sandbox.ConfigPath)
stdOut, stdErr := execCommand(sbin, "-t", "-c", sandbox.ConfigPath)
return NewTestConfigResult(stdOut, stdErr, TestScopeNamespaceSandbox, SandboxStatusOK)
}
// Sandbox represents an isolated nginx test environment
@@ -78,6 +91,7 @@ func createSandbox(namespace *NamespaceInfo, sitePaths, streamPaths []string) (*
Dir: tempDir,
Namespace: namespace,
}
builder := newSandboxBuilder(tempDir)
// Copy full nginx conf directory to sandbox, excluding sites-* and streams-*
if err := copyConfigBaseExceptSitesStreams(tempDir); err != nil {
@@ -96,14 +110,14 @@ func createSandbox(namespace *NamespaceInfo, sitePaths, streamPaths []string) (*
}
// Collect and copy only enabled sites/streams for the given namespace
siteFiles, streamFiles, err := collectAndCopyNamespaceEnabled(namespace, sitePaths, streamPaths, tempDir)
siteFiles, streamFiles, err := collectAndCopyNamespaceEnabled(namespace, sitePaths, streamPaths, builder)
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to collect/copy namespace configs: %w", err)
}
// Generate sandbox nginx.conf
configContent, err := generateSandboxConfig(namespace, siteFiles, streamFiles, tempDir)
configContent, err := generateSandboxConfig(namespace, siteFiles, streamFiles, builder)
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to generate sandbox config: %w", err)
@@ -131,8 +145,8 @@ func (s *Sandbox) Cleanup() {
}
}
// generateSandboxConfig generates a minimal nginx.conf that only includes configs from specified paths
func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []string, sandboxDir string) (string, error) {
// generateSandboxConfig generates a minimal nginx.conf that only includes configs from specified paths.
func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []string, builder *sandboxBuilder) (string, error) {
// Read the main nginx.conf to get basic structure
mainConfPath := GetConfEntryPath()
mainConf, err := os.ReadFile(mainConfPath)
@@ -145,22 +159,25 @@ func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []st
// Generate include patterns based on provided paths
siteIncludeLines := make([]string, 0, len(siteFiles))
for _, f := range siteFiles {
siteIncludeLines = append(siteIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "sites-enabled", f)))
siteIncludeLines = append(siteIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(builder.sandboxDir, "sites-enabled", f)))
}
streamIncludeLines := make([]string, 0, len(streamFiles))
for _, f := range streamFiles {
streamIncludeLines = append(streamIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "streams-enabled", f)))
streamIncludeLines = append(streamIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(builder.sandboxDir, "streams-enabled", f)))
}
// Replace include directives with sandbox-specific ones
sandboxConf := replaceIncludeDirectives(mainConfStr, sandboxDir, siteIncludeLines, streamIncludeLines)
sandboxConf, err := replaceIncludeDirectives(mainConfStr, mainConfPath, builder, siteIncludeLines, streamIncludeLines)
if err != nil {
return "", err
}
return sandboxConf, nil
}
// replaceIncludeDirectives replaces only sites-enabled and streams-enabled includes
// replaceIncludeDirectives replaces only sites-enabled and streams-enabled includes.
// Rewrites other includes to point to copied files under sandboxDir, preserving isolation.
func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLines, streamIncludeLines []string) string {
func replaceIncludeDirectives(mainConf string, sourcePath string, builder *sandboxBuilder, siteIncludeLines, streamIncludeLines []string) (string, error) {
lines := strings.Split(mainConf, "\n")
var result []string
httpDepth := 0
@@ -218,7 +235,10 @@ func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLin
}
// Rewrite other includes to sandbox paths
normalized := rewriteIncludeLineToSandbox(line, sandboxDir)
normalized, err := builder.rewriteIncludeLine(line, filepath.Dir(sourcePath))
if err != nil {
return "", err
}
if normalized != "" {
result = append(result, normalized)
}
@@ -253,44 +273,13 @@ func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLin
result = append(result, line)
}
return strings.Join(result, "\n")
}
// rewriteIncludeLineToSandbox rewrites include lines to point to files/directories inside sandboxDir.
// If an include path is relative, it will be rewritten relative to the nginx conf dir inside sandbox.
func rewriteIncludeLineToSandbox(line string, sandboxDir string) string {
includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
matches := includeRegex.FindStringSubmatch(line)
if len(matches) < 2 {
return line
}
path := strings.TrimSpace(matches[1])
confBase := GetConfPath()
var rewritten string
if filepath.IsAbs(path) {
// If absolute under confBase, map to sandbox
if helper.IsUnderDirectory(path, confBase) {
rel, err := filepath.Rel(confBase, path)
if err == nil {
rewritten = filepath.Join(sandboxDir, rel)
}
}
} else {
// Relative includes should point inside sandbox conf root
rewritten = filepath.Join(sandboxDir, path)
}
if rewritten == "" {
rewritten = path
}
trimmed := includeRegex.ReplaceAllString(line, "include "+rewritten+";")
return trimmed
return strings.Join(result, "\n"), nil
}
// collectAndCopyNamespaceEnabled collects and copies enabled site/stream configs based on provided paths.
// It rewrites relative includes to absolute, and writes them into sandboxDir/{sites-enabled,streams-enabled}.
// Returns the written file names.
func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []string, sandboxDir string) (siteFiles, streamFiles []string, err error) {
func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []string, builder *sandboxBuilder) (siteFiles, streamFiles []string, err error) {
// Helper to process and write a single config by kind and name
readSourceAndWrite := func(kind, name string) (writtenName string, wErr error) {
var enabledCandidates []string
@@ -340,10 +329,10 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
}
// Rewrite include lines to sandbox paths (resolve relative to source dir first)
absRewriter := regexp.MustCompile(`(?m)^[ \t]*include\s+([^;#]+);`)
rewritten := absRewriter.ReplaceAllStringFunc(string(content), func(m string) string {
return normalizeIncludeLineRelativeTo(m, filepath.Dir(srcPath), sandboxDir)
})
rewritten, rErr := builder.rewriteConfigContent(string(content), srcPath)
if rErr != nil {
return "", fmt.Errorf("rewrite sandbox %s: %w", kind, rErr)
}
// Compute destination file name respecting platform symlink naming
var destName string
@@ -354,7 +343,7 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
destName = filepath.Base(GetConfSymlinkPath(GetConfPath("streams-enabled", name)))
}
destDir := filepath.Join(sandboxDir, kind+"s-enabled")
destDir := filepath.Join(builder.sandboxDir, kind+"s-enabled")
if err := os.WriteFile(filepath.Join(destDir, destName), []byte(rewritten), 0644); err != nil {
return "", fmt.Errorf("write sandbox %s: %w", kind, err)
}
@@ -384,29 +373,202 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
return siteFiles, streamFiles, nil
}
// normalizeIncludeLineRelativeTo rewrites a single include line:
// - resolves relative paths against baseDir
// - if the resolved path is under confBase, map to sandboxDir mirror; else keep as is
func normalizeIncludeLineRelativeTo(line, baseDir, sandboxDir string) string {
type sandboxBuilder struct {
sandboxDir string
confBase string
mirrored map[string]bool
}
func newSandboxBuilder(sandboxDir string) *sandboxBuilder {
return &sandboxBuilder{
sandboxDir: sandboxDir,
confBase: GetConfPath(),
mirrored: map[string]bool{},
}
}
func (b *sandboxBuilder) rewriteConfigContent(content string, sourcePath string) (string, error) {
includeRegex := regexp.MustCompile(`(?m)^[ \t]*include\s+([^;#]+);`)
var rewriteErr error
rewritten := includeRegex.ReplaceAllStringFunc(content, func(match string) string {
if rewriteErr != nil {
return match
}
line, err := b.rewriteIncludeLine(match, filepath.Dir(sourcePath))
if err != nil {
rewriteErr = err
return match
}
return line
})
if rewriteErr != nil {
return "", rewriteErr
}
return rewritten, nil
}
func (b *sandboxBuilder) rewriteIncludeLine(line string, baseDir string) (string, error) {
includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
matches := includeRegex.FindStringSubmatch(line)
if len(matches) < 2 {
return line
return line, nil
}
path := strings.TrimSpace(matches[1])
// If relative, make absolute to source file dir
resolved := path
if !filepath.IsAbs(resolved) {
resolved = filepath.Clean(filepath.Join(baseDir, resolved))
includePath := strings.TrimSpace(matches[1])
resolvedPath, matchedFiles, err := b.resolveIncludePath(includePath, baseDir)
if err != nil {
return "", err
}
confBase := GetConfPath()
if helper.IsUnderDirectory(resolved, confBase) {
if rel, err := filepath.Rel(confBase, resolved); err == nil {
resolved = filepath.Join(sandboxDir, rel)
if helper.IsUnderDirectory(resolvedPath, b.confBase) {
for _, matchedFile := range matchedFiles {
if err := b.mirrorDependency(matchedFile); err != nil {
return "", err
}
}
rel, err := filepath.Rel(b.confBase, resolvedPath)
if err == nil {
resolvedPath = filepath.Join(b.sandboxDir, rel)
}
}
return includeRegex.ReplaceAllString(line, "include "+resolved+";")
return includeRegex.ReplaceAllString(line, "include "+resolvedPath+";"), nil
}
func (b *sandboxBuilder) resolveIncludePath(includePath string, baseDir string) (string, []string, error) {
if filepath.IsAbs(includePath) {
matches, err := matchIncludePattern(includePath)
if err != nil {
return "", nil, err
}
if len(matches) == 0 && helper.IsUnderDirectory(includePath, b.confBase) {
return "", nil, newSandboxIncludeError(baseDir, includePath)
}
return includePath, matches, nil
}
candidates := uniqueSandboxCandidates(
filepath.Clean(filepath.Join(baseDir, includePath)),
filepath.Clean(filepath.Join(b.confBase, includePath)),
)
for _, candidate := range candidates {
matches, err := matchIncludePattern(candidate)
if err != nil {
return "", nil, err
}
if len(matches) > 0 {
return candidate, matches, nil
}
}
if len(candidates) == 0 {
return includePath, nil, nil
}
return "", nil, newSandboxIncludeError(baseDir, includePath)
}
func (b *sandboxBuilder) mirrorDependency(sourcePath string) error {
sourcePath = filepath.Clean(sourcePath)
if !helper.IsUnderDirectory(sourcePath, b.confBase) {
return nil
}
if b.mirrored[sourcePath] {
return nil
}
b.mirrored[sourcePath] = true
data, err := os.ReadFile(sourcePath)
if err != nil {
return &SandboxBuildError{
Category: ErrorCategorySandboxBuildError,
Message: fmt.Sprintf("failed to read sandbox dependency %s: %v", sourcePath, err),
}
}
rewritten, err := b.rewriteConfigContent(string(data), sourcePath)
if err != nil {
return err
}
rel, err := filepath.Rel(b.confBase, sourcePath)
if err != nil {
return &SandboxBuildError{
Category: ErrorCategorySandboxBuildError,
Message: fmt.Sprintf("failed to resolve sandbox dependency path %s: %v", sourcePath, err),
}
}
destPath := filepath.Join(b.sandboxDir, rel)
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return &SandboxBuildError{
Category: ErrorCategorySandboxBuildError,
Message: fmt.Sprintf("failed to create sandbox dependency dir for %s: %v", sourcePath, err),
}
}
if err := os.WriteFile(destPath, []byte(rewritten), 0644); err != nil {
return &SandboxBuildError{
Category: ErrorCategorySandboxBuildError,
Message: fmt.Sprintf("failed to write sandbox dependency %s: %v", sourcePath, err),
}
}
return nil
}
func matchIncludePattern(pattern string) ([]string, error) {
if strings.ContainsAny(pattern, "*?[") {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, &SandboxBuildError{
Category: ErrorCategorySandboxBuildError,
Message: fmt.Sprintf("invalid include pattern %s: %v", pattern, err),
}
}
var existing []string
for _, match := range matches {
info, statErr := os.Stat(match)
if statErr == nil && !info.IsDir() {
existing = append(existing, match)
}
}
return existing, nil
}
info, err := os.Stat(pattern)
if err != nil || info.IsDir() {
return nil, nil
}
return []string{pattern}, nil
}
func uniqueSandboxCandidates(paths ...string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(paths))
for _, path := range paths {
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
result = append(result, path)
}
return result
}
// copyConfigBaseExceptSitesStreams copies the entire nginx conf directory into sandboxDir,
+139 -127
View File
@@ -1,82 +1,110 @@
package nginx
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/0xJacky/Nginx-UI/settings"
)
func TestNormalizeIncludeLineRelativeTo(t *testing.T) {
baseDir := "/etc/nginx/sites-available"
if runtime.GOOS == "windows" {
// keep test portable; filepath.Join will use OS-specific separator
baseDir = `C:\nginx\conf\sites-available`
}
sandboxDir := "/tmp/sbx"
func withSandboxPaths(t *testing.T, files map[string]string, fn func(confDir string, sandboxDir string)) {
t.Helper()
tests := []struct {
name string
in string
wantPrefix string
}{
{
name: "relative simple file",
in: " include mime.types;",
wantPrefix: " include ",
},
{
name: "relative path with subdir",
in: "include ../common/snippets/*.conf;",
wantPrefix: "include ",
},
originalConfigDir := settings.NginxSettings.ConfigDir
originalConfigPath := settings.NginxSettings.ConfigPath
t.Cleanup(func() {
settings.NginxSettings.ConfigDir = originalConfigDir
settings.NginxSettings.ConfigPath = originalConfigPath
})
confDir := t.TempDir()
settings.NginxSettings.ConfigDir = confDir
settings.NginxSettings.ConfigPath = filepath.Join(confDir, "nginx.conf")
if _, ok := files["nginx.conf"]; !ok {
files["nginx.conf"] = "events {}\nhttp {\n include sites-enabled/*;\n}\n"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := normalizeIncludeLineRelativeTo(tt.in, baseDir, sandboxDir)
if out == "" {
t.Fatalf("expected non-empty include, got empty")
}
if !strings.HasPrefix(out, tt.wantPrefix) {
t.Fatalf("unexpected prefix: %q, got %q", tt.wantPrefix, out)
}
// if relative input (first two cases), ensure absolute joined path appears
if tt.name == "relative simple file" || tt.name == "relative path with subdir" {
parts := strings.Split(out, "include ")
if len(parts) < 2 {
t.Fatalf("malformed include line: %q", out)
}
pathWithSemi := parts[1]
path := strings.TrimSuffix(pathWithSemi, ";")
if !filepath.IsAbs(path) {
t.Fatalf("expected absolute path, got %q", path)
}
}
})
for relPath, content := range files {
path := filepath.Join(confDir, relPath)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
fn(confDir, t.TempDir())
}
func TestReplaceIncludeDirectives(t *testing.T) {
mainConf := `
func TestSandboxBuilderRewriteIncludeLineFallsBackToConfBase(t *testing.T) {
withSandboxPaths(t, map[string]string{
"fastcgi.conf": "fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n",
}, func(confDir string, sandboxDir string) {
builder := newSandboxBuilder(sandboxDir)
line, err := builder.rewriteIncludeLine(" include fastcgi.conf;", filepath.Join(confDir, "sites-available"))
if err != nil {
t.Fatalf("rewriteIncludeLine() error = %v", err)
}
expected := " include " + filepath.Join(sandboxDir, "fastcgi.conf") + ";"
if line != expected {
t.Fatalf("rewriteIncludeLine() = %q, want %q", line, expected)
}
if _, err := os.Stat(filepath.Join(sandboxDir, "fastcgi.conf")); err != nil {
t.Fatalf("expected mirrored fastcgi.conf: %v", err)
}
})
}
func TestSandboxBuilderMirrorsNestedIncludeDependencies(t *testing.T) {
withSandboxPaths(t, map[string]string{
"fastcgi.conf": "fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n",
"sites-available/fragments/php.conf": "include fastcgi.conf;\n",
}, func(confDir string, sandboxDir string) {
builder := newSandboxBuilder(sandboxDir)
sourcePath := filepath.Join(confDir, "sites-available", "example.conf")
content := "include fragments/php.conf;\n"
rewritten, err := builder.rewriteConfigContent(content, sourcePath)
if err != nil {
t.Fatalf("rewriteConfigContent() error = %v", err)
}
expectedInclude := filepath.Join(sandboxDir, "sites-available", "fragments", "php.conf")
if !strings.Contains(rewritten, expectedInclude) {
t.Fatalf("rewriteConfigContent() = %q, want include %q", rewritten, expectedInclude)
}
nestedPath := filepath.Join(sandboxDir, "sites-available", "fragments", "php.conf")
nestedContent, err := os.ReadFile(nestedPath)
if err != nil {
t.Fatalf("read mirrored nested dependency: %v", err)
}
expectedNestedInclude := filepath.Join(sandboxDir, "fastcgi.conf")
if !strings.Contains(string(nestedContent), expectedNestedInclude) {
t.Fatalf("nested dependency = %q, want include %q", string(nestedContent), expectedNestedInclude)
}
})
}
func TestReplaceIncludeDirectivesInjectsSandboxIncludes(t *testing.T) {
withSandboxPaths(t, map[string]string{
"mime.types": "types { text/html html; }\n",
}, func(confDir string, sandboxDir string) {
mainConf := `
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
events {}
http {
server {
location / {
return 200;
}
}
include mime.types;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
@@ -84,77 +112,61 @@ stream {
include /etc/nginx/streams-enabled/*;
}
`
siteLines := []string{" include /tmp/sbx/sites-enabled/a.conf;"}
streamLines := []string{" include /tmp/sbx/streams-enabled/s1.conf;"}
out := replaceIncludeDirectives(mainConf, "/tmp/sbx", siteLines, streamLines)
// ensure site includes inserted before closing http brace (inside block)
lines := strings.Split(out, "\n")
httpStart := -1
httpClose := -1
inHttp := false
depth := 0
for i, l := range lines {
if strings.Contains(l, "http {") && httpStart == -1 {
httpStart = i
inHttp = true
depth = 1
continue
builder := newSandboxBuilder(sandboxDir)
out, err := replaceIncludeDirectives(
mainConf,
filepath.Join(confDir, "nginx.conf"),
builder,
[]string{" include " + filepath.Join(sandboxDir, "sites-enabled", "a.conf") + ";"},
[]string{" include " + filepath.Join(sandboxDir, "streams-enabled", "s1.conf") + ";"},
)
if err != nil {
t.Fatalf("replaceIncludeDirectives() error = %v", err)
}
if inHttp {
depth += strings.Count(l, "{")
depth -= strings.Count(l, "}")
if depth == 0 {
httpClose = i
inHttp = false
break
}
}
}
if httpStart == -1 || httpClose == -1 {
t.Fatal("failed to locate http block bounds")
}
incIdx := -1
for i := httpStart; i <= httpClose; i++ {
if strings.Contains(lines[i], "/tmp/sbx/sites-enabled/a.conf;") {
incIdx = i
break
}
}
if incIdx == -1 || incIdx >= httpClose {
t.Fatalf("sandbox site include should be inside http block before closing brace, got index=%d close=%d", incIdx, httpClose)
}
if strings.Contains(out, "/etc/nginx/sites-enabled/*") {
t.Fatal("sites-enabled wildcard should be replaced by sandbox files")
}
if !strings.Contains(out, "/tmp/sbx/sites-enabled/a.conf;") {
t.Fatal("sandbox site include missing")
}
if strings.Contains(out, "/etc/nginx/streams-enabled/*") {
t.Fatal("streams-enabled wildcard should be replaced by sandbox files")
}
if !strings.Contains(out, "/tmp/sbx/streams-enabled/s1.conf;") {
t.Fatal("sandbox stream include missing")
}
// mime.types should be kept (possibly normalized)
if !strings.Contains(strings.ToLower(out), "include") {
t.Fatal("expected include directives to remain")
}
if strings.Contains(out, "/etc/nginx/sites-enabled/*") {
t.Fatal("sites-enabled wildcard should be replaced by sandbox files")
}
if strings.Contains(out, "/etc/nginx/streams-enabled/*") {
t.Fatal("streams-enabled wildcard should be replaced by sandbox files")
}
if !strings.Contains(out, filepath.Join(sandboxDir, "sites-enabled", "a.conf")) {
t.Fatal("sandbox site include missing")
}
if !strings.Contains(out, filepath.Join(sandboxDir, "streams-enabled", "s1.conf")) {
t.Fatal("sandbox stream include missing")
}
if !strings.Contains(out, filepath.Join(sandboxDir, "mime.types")) {
t.Fatal("main nginx include should be rewritten into sandbox")
}
})
}
func TestSandboxTestConfigWithPathsNilNamespaceReturns(t *testing.T) {
done := make(chan struct{})
func TestSandboxTestConfigWithPathsReturnsSandboxFailureWithoutFallback(t *testing.T) {
withSandboxPaths(t, map[string]string{
"sites-enabled/example.conf": "include missing-fastcgi.conf;\n",
}, func(_ string, _ string) {
result := SandboxTestConfigWithPaths(&NamespaceInfo{Name: "demo"}, []string{"/tmp/example.conf"}, nil)
go func() {
_, _ = SandboxTestConfigWithPaths(nil, nil, nil)
close(done)
}()
if result.SandboxStatus != SandboxStatusFailed {
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusFailed)
}
if result.ErrorCategory != ErrorCategoryMissingInclude {
t.Fatalf("ErrorCategory = %q, want %q", result.ErrorCategory, ErrorCategoryMissingInclude)
}
if !strings.Contains(result.Message, "Sandbox test setup failed") {
t.Fatalf("Message = %q, want sandbox setup failure", result.Message)
}
})
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("SandboxTestConfigWithPaths should fall back to TestConfig without blocking when namespace is nil")
func TestSandboxTestConfigWithPathsSkipsRemoteNamespaces(t *testing.T) {
result := SandboxTestConfigWithPaths(&NamespaceInfo{DeployMode: "remote"}, nil, nil)
if result.SandboxStatus != SandboxStatusSkipped {
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusSkipped)
}
if result.TestScope != TestScopeNamespaceSandbox {
t.Fatalf("TestScope = %q, want %q", result.TestScope, TestScopeNamespaceSandbox)
}
}
+119
View File
@@ -0,0 +1,119 @@
package nginx
import (
"errors"
"fmt"
"strings"
)
type TestScope string
const (
TestScopeGlobal TestScope = "global"
TestScopeNamespaceSandbox TestScope = "namespace_sandbox"
)
type SandboxStatus string
const (
SandboxStatusOK SandboxStatus = "ok"
SandboxStatusSkipped SandboxStatus = "skipped"
SandboxStatusFailed SandboxStatus = "failed"
)
type ErrorCategory string
const (
ErrorCategoryNone ErrorCategory = ""
ErrorCategoryMissingInclude ErrorCategory = "missing_include"
ErrorCategorySandboxBuildError ErrorCategory = "sandbox_build_error"
ErrorCategorySyntaxError ErrorCategory = "syntax_error"
ErrorCategoryNginxRuntimeError ErrorCategory = "nginx_runtime_error"
)
type TestConfigResult struct {
Message string `json:"message"`
Level int `json:"level"`
TestScope TestScope `json:"test_scope"`
SandboxStatus SandboxStatus `json:"sandbox_status,omitempty"`
ErrorCategory ErrorCategory `json:"error_category,omitempty"`
}
type SandboxBuildError struct {
Category ErrorCategory
Message string
}
func (e *SandboxBuildError) Error() string {
return e.Message
}
func newSandboxIncludeError(baseDir string, includePath string) error {
return &SandboxBuildError{
Category: ErrorCategoryMissingInclude,
Message: fmt.Sprintf("sandbox include not found: %s (resolved from %s)", includePath, baseDir),
}
}
func NewSandboxBuildFailureResult(err error) TestConfigResult {
category := ErrorCategorySandboxBuildError
var sandboxErr *SandboxBuildError
if errors.As(err, &sandboxErr) && sandboxErr.Category != ErrorCategoryNone {
category = sandboxErr.Category
}
return TestConfigResult{
Message: fmt.Sprintf("Sandbox test setup failed: %v", err),
Level: Error,
TestScope: TestScopeNamespaceSandbox,
SandboxStatus: SandboxStatusFailed,
ErrorCategory: category,
}
}
func NewTestConfigResult(stdOut string, stdErr error, scope TestScope, sandboxStatus SandboxStatus) TestConfigResult {
message := stdOut
if stdErr != nil {
message = strings.TrimSpace(strings.Join([]string{stdOut, stdErr.Error()}, " "))
}
level := GetLogLevel(message)
if level == Unknown && stdErr != nil {
level = Error
}
result := TestConfigResult{
Message: message,
Level: level,
TestScope: scope,
SandboxStatus: sandboxStatus,
ErrorCategory: DetectErrorCategory(message, stdErr),
}
if sandboxStatus == "" && scope == TestScopeNamespaceSandbox {
result.SandboxStatus = SandboxStatusOK
}
return result
}
func DetectErrorCategory(message string, stdErr error) ErrorCategory {
if stdErr == nil {
return ErrorCategoryNone
}
lowerMessage := strings.ToLower(message)
switch {
case strings.Contains(lowerMessage, "failed (2: no such file or directory)") ||
strings.Contains(lowerMessage, "open()") && strings.Contains(lowerMessage, "no such file or directory"):
return ErrorCategoryMissingInclude
case strings.Contains(lowerMessage, "unknown directive") ||
strings.Contains(lowerMessage, "invalid number of arguments") ||
strings.Contains(lowerMessage, "directive is not terminated by") ||
strings.Contains(lowerMessage, "unexpected end of file"):
return ErrorCategorySyntaxError
default:
return ErrorCategoryNginxRuntimeError
}
}
+53
View File
@@ -0,0 +1,53 @@
package nginx
import (
"errors"
"testing"
)
func TestDetectErrorCategory(t *testing.T) {
tests := []struct {
name string
message string
want ErrorCategory
}{
{
name: "missing include",
message: `open() "/tmp/nginx-ui-sandbox/sites-available/fastcgi.conf" failed (2: No such file or directory)`,
want: ErrorCategoryMissingInclude,
},
{
name: "syntax error",
message: `nginx: [emerg] unknown directive "servername" in /etc/nginx/nginx.conf:5`,
want: ErrorCategorySyntaxError,
},
{
name: "runtime error",
message: `nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)`,
want: ErrorCategoryNginxRuntimeError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectErrorCategory(tt.message, errors.New("exit status 1"))
if got != tt.want {
t.Fatalf("DetectErrorCategory() = %q, want %q", got, tt.want)
}
})
}
}
func TestNewSandboxBuildFailureResultPreservesCategory(t *testing.T) {
result := NewSandboxBuildFailureResult(&SandboxBuildError{
Category: ErrorCategoryMissingInclude,
Message: "sandbox include not found: fastcgi.conf",
})
if result.SandboxStatus != SandboxStatusFailed {
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusFailed)
}
if result.ErrorCategory != ErrorCategoryMissingInclude {
t.Fatalf("ErrorCategory = %q, want %q", result.ErrorCategory, ErrorCategoryMissingInclude)
}
}