feat: allow production provider bootstrap
This commit is contained in:
@@ -18,7 +18,7 @@ func TestListenAddressDefaultsToLoopbackAndSupportsExplicitBinding(t *testing.T)
|
||||
|
||||
func TestRuntimeGetenvLoadsWhitelistedSettingsAndPreservesProcessPrecedence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.local")
|
||||
if err := os.WriteFile(path, []byte("IMAGE_GENERATE_ENGINE=evolink\nEVOLINK_API_KEY=file-secret\nDATABASE_URL=postgres://must-not-load\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte("IMAGE_GENERATE_ENGINE=evolink\nEVOLINK_API_KEY=file-secret\nZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true\nDATABASE_URL=postgres://must-not-load\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := map[string]string{"ZHINIAN_SETTINGS_FILE": path, "EVOLINK_API_KEY": "process-secret", "ZHINIAN_DATA_BACKEND": "local"}
|
||||
@@ -26,7 +26,7 @@ func TestRuntimeGetenvLoadsWhitelistedSettingsAndPreservesProcessPrecedence(t *t
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if getenv("IMAGE_GENERATE_ENGINE") != "evolink" || getenv("EVOLINK_API_KEY") != "process-secret" || getenv("DATABASE_URL") != "" || getenv("ZHINIAN_DATA_BACKEND") != "local" {
|
||||
t.Fatalf("loaded engine=%q key=%q database=%q backend=%q", getenv("IMAGE_GENERATE_ENGINE"), getenv("EVOLINK_API_KEY"), getenv("DATABASE_URL"), getenv("ZHINIAN_DATA_BACKEND"))
|
||||
if getenv("IMAGE_GENERATE_ENGINE") != "evolink" || getenv("EVOLINK_API_KEY") != "process-secret" || getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS") != "true" || getenv("DATABASE_URL") != "" || getenv("ZHINIAN_DATA_BACKEND") != "local" {
|
||||
t.Fatalf("loaded engine=%q key=%q bootstrap=%q database=%q backend=%q", getenv("IMAGE_GENERATE_ENGINE"), getenv("EVOLINK_API_KEY"), getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"), getenv("DATABASE_URL"), getenv("ZHINIAN_DATA_BACKEND"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ func New(options Options) (*App, error) {
|
||||
if err := validateProductionProviderConfiguration(getenv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allowUnconfiguredProviders(getenv) {
|
||||
log.Printf("WARNING: %s is enabled; the Go API will start with unconfigured providers, and generation/quote requests will remain unavailable until credentials are added and the process is restarted", "ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS")
|
||||
}
|
||||
}
|
||||
database, err := postgres.Open(ctx, config)
|
||||
if err != nil {
|
||||
@@ -265,11 +268,15 @@ func New(options Options) (*App, error) {
|
||||
if providerRegistry == nil {
|
||||
providerRegistry = buildProviderRegistry(getenv)
|
||||
}
|
||||
var unavailableProviders map[string]string
|
||||
if config.Backend == postgres.BackendPostgres {
|
||||
unavailableProviders = providerUnavailableMessages(getenv)
|
||||
}
|
||||
jobService := jobs.NewService(jobStore, nil)
|
||||
jobBuilder := jobs.ProviderJobBuilder{
|
||||
ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv),
|
||||
ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv),
|
||||
ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), NewID: applicationJobID,
|
||||
ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), UnavailableProviders: unavailableProviders, NewID: applicationJobID,
|
||||
}
|
||||
usageService := usage.Service{
|
||||
Repository: usageRepository,
|
||||
|
||||
@@ -77,6 +77,19 @@ func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderBootstrapFlagAllowsApplicationComposition(t *testing.T) {
|
||||
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
|
||||
"NODE_ENV": "production",
|
||||
"ZHINIAN_DATA_BACKEND": "postgres",
|
||||
"DATABASE_URL": "postgres://user:password@127.0.0.1:5432/zhinian",
|
||||
"ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": "true",
|
||||
})})
|
||||
if err != nil {
|
||||
t.Fatalf("New() with provider bootstrap flag = %v", err)
|
||||
}
|
||||
app.Close()
|
||||
}
|
||||
|
||||
func TestProductionLocalBackendNeverGrantsAnonymousAdministrator(t *testing.T) {
|
||||
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
|
||||
"NODE_ENV": "production",
|
||||
|
||||
@@ -59,6 +59,10 @@ func parseBool(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func allowUnconfiguredProviders(getenv postgres.Getenv) bool {
|
||||
return parseBool(getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"))
|
||||
}
|
||||
|
||||
func applicationJobID() string {
|
||||
return applicationID("job")
|
||||
}
|
||||
@@ -186,7 +190,27 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
|
||||
}
|
||||
}
|
||||
|
||||
func providerUnavailableMessages(getenv postgres.Getenv) map[string]string {
|
||||
missing := map[string]string{}
|
||||
if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" {
|
||||
missing["volcengine-visual"] = "即梦服务商未配置,请先配置 VOLCENGINE_ACCESS_KEY_ID 和 VOLCENGINE_SECRET_ACCESS_KEY。"
|
||||
}
|
||||
if strings.TrimSpace(getenv("EVOLINK_API_KEY")) == "" {
|
||||
missing["evolink"] = "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。"
|
||||
}
|
||||
if bailianAPIKey(getenv) == "" {
|
||||
missing["bailian"] = "百炼服务商未配置,请先配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY。"
|
||||
}
|
||||
if strings.TrimSpace(getenv("SEEDANCE_API_KEY")) == "" {
|
||||
missing["seedance"] = "Seedance 服务商未配置,请先配置 SEEDANCE_API_KEY。"
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func validateProductionProviderConfiguration(getenv postgres.Getenv) error {
|
||||
if allowUnconfiguredProviders(getenv) {
|
||||
return nil
|
||||
}
|
||||
missing := make([]string, 0, 4)
|
||||
if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" {
|
||||
missing = append(missing, "即梦 VOLCENGINE_ACCESS_KEY_ID/VOLCENGINE_SECRET_ACCESS_KEY")
|
||||
|
||||
@@ -128,6 +128,26 @@ func TestValidateProductionProviderConfigurationRequiresAllRealCredentials(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProductionProviderConfigurationCanBeSkippedForBootstrap(t *testing.T) {
|
||||
values := map[string]string{"ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": "true"}
|
||||
if err := validateProductionProviderConfiguration(func(name string) string { return values[name] }); err != nil {
|
||||
t.Fatalf("validateProductionProviderConfiguration() with bootstrap flag = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUnavailableMessagesIdentifyOnlyMissingCredentials(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"VOLCENGINE_ACCESS_KEY_ID": "access",
|
||||
"VOLCENGINE_SECRET_ACCESS_KEY": "secret",
|
||||
"EVOLINK_API_KEY": "evolink",
|
||||
"BAILIAN_API_KEY": "bailian",
|
||||
}
|
||||
missing := providerUnavailableMessages(func(name string) string { return values[name] })
|
||||
if len(missing) != 1 || !strings.Contains(missing["seedance"], "SEEDANCE_API_KEY") {
|
||||
t.Fatalf("missing = %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
for engine, target := range providerImageTargets(getenv) {
|
||||
|
||||
@@ -92,9 +92,10 @@ func (scope Scope) Owns(job Job) bool {
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorInvalid ErrorKind = "invalid"
|
||||
ErrorNotFound ErrorKind = "not_found"
|
||||
ErrorConflict ErrorKind = "conflict"
|
||||
ErrorInvalid ErrorKind = "invalid"
|
||||
ErrorNotFound ErrorKind = "not_found"
|
||||
ErrorConflict ErrorKind = "conflict"
|
||||
ErrorUnavailable ErrorKind = "unavailable"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
|
||||
@@ -121,6 +121,7 @@ type ProviderJobBuilder struct {
|
||||
ImageEngine, VideoEngine string
|
||||
ImageEngines map[string]ProviderTarget
|
||||
VideoEngines map[string]ProviderTarget
|
||||
UnavailableProviders map[string]string
|
||||
NewID func() string
|
||||
}
|
||||
|
||||
@@ -141,6 +142,9 @@ func (b ProviderJobBuilder) Build(_ context.Context, owner, client, capability,
|
||||
if err != nil {
|
||||
return CreateCommand{}, err
|
||||
}
|
||||
if message := strings.TrimSpace(b.UnavailableProviders[target.Provider]); message != "" {
|
||||
return CreateCommand{}, &Error{Kind: ErrorUnavailable, Status: 503, Message: message}
|
||||
}
|
||||
if target.Provider == "" || target.Model == "" || b.NewID == nil {
|
||||
return CreateCommand{}, errors.New("provider job builder is not configured")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,23 @@ func TestProviderJobBuilderPreparesImageRequestAndEngineOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderRejectsUnconfiguredProviderBeforeCreation(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
b.UnavailableProviders = map[string]string{
|
||||
"evolink": "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。",
|
||||
}
|
||||
_, err := b.Build(context.Background(), "owner", "client", "image.generate", "idem", map[string]any{
|
||||
"engine": "evolink", "prompt": "hello",
|
||||
})
|
||||
if err == nil || err.Error() != "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。" {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
var unavailable *Error
|
||||
if !errors.As(err, &unavailable) || unavailable.Kind != ErrorUnavailable || unavailable.Status != 503 {
|
||||
t.Fatalf("error = %#v, want unavailable 503", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderUsesPublicInputURLsForImageMaterialCoverage(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "", map[string]any{
|
||||
|
||||
@@ -97,7 +97,7 @@ func LoadEnvironment(path string, environment map[string]string) (map[string]str
|
||||
}
|
||||
merged := cloneStrings(environment)
|
||||
for key, value := range file {
|
||||
if _, allowed := fieldIndex[key]; !allowed {
|
||||
if !allowedEnvironmentKey(key) {
|
||||
continue
|
||||
}
|
||||
if _, exists := merged[key]; !exists {
|
||||
@@ -108,14 +108,25 @@ func LoadEnvironment(path string, environment map[string]string) (map[string]str
|
||||
}
|
||||
|
||||
func RuntimeSettingKeys() []string {
|
||||
keys := make([]string, 0, len(fieldIndex))
|
||||
keys := make([]string, 0, len(fieldIndex)+len(runtimeOnlyKeys))
|
||||
for key := range fieldIndex {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for key := range runtimeOnlyKeys {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func allowedEnvironmentKey(key string) bool {
|
||||
if _, allowed := fieldIndex[key]; allowed {
|
||||
return true
|
||||
}
|
||||
_, allowed := runtimeOnlyKeys[key]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func (s *Service) WithBillingAccountWriter(writer BillingAccountWriter) *Service {
|
||||
s.billing = writer
|
||||
return s
|
||||
@@ -483,3 +494,9 @@ var fieldIndex = func() map[string]Field {
|
||||
result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}}
|
||||
return result
|
||||
}()
|
||||
|
||||
// runtimeOnlyKeys are read from the process/settings file by the Go startup
|
||||
// loader but are intentionally not exposed as editable settings-panel fields.
|
||||
var runtimeOnlyKeys = map[string]struct{}{
|
||||
"ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": {},
|
||||
}
|
||||
|
||||
@@ -100,6 +100,23 @@ func TestServiceGetUsesInjectedEnvironmentWithoutMutatingProcess(t *testing.T) {
|
||||
assertSecretProjection(t, value, "SEEDANCE_API_KEY", true)
|
||||
}
|
||||
|
||||
func TestLoadEnvironmentAllowsBootstrapRuntimeFlagButIgnoresUnknownKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.local")
|
||||
if err := os.WriteFile(path, []byte("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true\nUNKNOWN_RUNTIME_FLAG=true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := LoadEnvironment(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"] != "true" {
|
||||
t.Fatalf("bootstrap flag = %q", values["ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"])
|
||||
}
|
||||
if _, exists := values["UNKNOWN_RUNTIME_FLAG"]; exists {
|
||||
t.Fatal("unknown runtime flag was loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSynchronizesBillingAccountWithoutRequiringRestart(t *testing.T) {
|
||||
writer := &billingWriterStub{}
|
||||
service := New(filepath.Join(t.TempDir(), ".env.local"), nil, nil).WithBillingAccountWriter(writer)
|
||||
|
||||
Reference in New Issue
Block a user