- Interactive deploy command with 8-step walkthrough: framework → provider → token → SSH → server → inference → tailscale → discord - .env file generation from walkthrough config - DeploymentConfig struct with framework-aware defaults - Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic - Hetzner Cloud provider: token validation, SSH key listing - DotEnv parser/writer with schema validation - Destroy command with confirmation prompt - Validation subcommand for checking existing .env files - All tests passing, go vet clean
372 lines
8 KiB
Go
372 lines
8 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestSchema(t *testing.T) {
|
|
schema := Schema()
|
|
if len(schema) == 0 {
|
|
t.Fatal("schema should not be empty")
|
|
}
|
|
|
|
// Check that required vars are present
|
|
requiredCount := 0
|
|
for _, v := range schema {
|
|
if v.Required {
|
|
requiredCount++
|
|
}
|
|
}
|
|
if requiredCount == 0 {
|
|
t.Error("expected at least one required variable")
|
|
}
|
|
|
|
// Check SchemaMap
|
|
m := SchemaMap()
|
|
if _, ok := m["cloud_provider"]; !ok {
|
|
t.Error("expected cloud_provider in schema map")
|
|
}
|
|
}
|
|
|
|
func TestParseDotEnv(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want map[string]string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple key=value",
|
|
content: `TF_VAR_cloud_provider=hetzner
|
|
TF_VAR_server_name=my-server
|
|
`,
|
|
want: map[string]string{
|
|
"TF_VAR_cloud_provider": "hetzner",
|
|
"TF_VAR_server_name": "my-server",
|
|
},
|
|
},
|
|
{
|
|
name: "quoted values",
|
|
content: `TF_VAR_server_name="my server name"
|
|
TF_VAR_location='ash'
|
|
`,
|
|
want: map[string]string{
|
|
"TF_VAR_server_name": "my server name",
|
|
"TF_VAR_location": "ash",
|
|
},
|
|
},
|
|
{
|
|
name: "comments and blanklines",
|
|
content: `# This is a comment
|
|
|
|
TF_VAR_cloud_provider=hetzner # inline comment
|
|
TF_VAR_server_name=my-server
|
|
`,
|
|
want: map[string]string{
|
|
"TF_VAR_cloud_provider": "hetzner",
|
|
"TF_VAR_server_name": "my-server",
|
|
},
|
|
},
|
|
{
|
|
name: "list values",
|
|
content: `TF_VAR_ssh_key_names='["my-key"]'
|
|
TF_VAR_discord_user_id='["123", "456"]'
|
|
`,
|
|
want: map[string]string{
|
|
"TF_VAR_ssh_key_names": `["my-key"]`,
|
|
"TF_VAR_discord_user_id": `["123", "456"]`,
|
|
},
|
|
},
|
|
{
|
|
name: "values without TF_VAR prefix",
|
|
content: `CLOUD_PROVIDER=hetzner
|
|
TF_VAR_server_name=my-server
|
|
`,
|
|
want: map[string]string{
|
|
"CLOUD_PROVIDER": "hetzner",
|
|
"TF_VAR_server_name": "my-server",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temp file
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, ".env")
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
env, err := ParseDotEnv(path)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ParseDotEnv() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
for k, v := range tt.want {
|
|
if got, ok := env.Values[k]; !ok {
|
|
t.Errorf("missing key %s", k)
|
|
} else if got != v {
|
|
t.Errorf("key %s: got %q, want %q", k, got, v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteDotEnv(t *testing.T) {
|
|
cfg := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
"server_name": "my-gateway",
|
|
"venice_api_key": "secret-key-123",
|
|
},
|
|
}
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, ".env")
|
|
|
|
if err := WriteDotEnv(cfg, path); err != nil {
|
|
t.Fatalf("WriteDotEnv() error = %v", err)
|
|
}
|
|
|
|
// Read back and verify
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check for expected content
|
|
tests := []string{
|
|
"TF_VAR_cloud_provider=hetzner",
|
|
"TF_VAR_server_name=my-gateway",
|
|
"TF_VAR_venice_api_key=secret-key-123",
|
|
"Cloud provider to use",
|
|
"Hostname for the server",
|
|
}
|
|
|
|
for _, want := range tests {
|
|
if !contains(string(content), want) {
|
|
t.Errorf("expected %q in output", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteTfVars(t *testing.T) {
|
|
cfg := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
"server_name": "my-gateway",
|
|
"enable_tailscale": "true",
|
|
"swap_size": "2",
|
|
"ssh_key_names": `["my-key"]`,
|
|
},
|
|
}
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "terraform.tfvars")
|
|
|
|
if err := WriteTfVars(cfg, path); err != nil {
|
|
t.Fatalf("WriteTfVars() error = %v", err)
|
|
}
|
|
|
|
// Read back and verify
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check for expected content
|
|
tests := []string{
|
|
`cloud_provider = "hetzner"`,
|
|
`server_name = "my-gateway"`,
|
|
`enable_tailscale = true`,
|
|
`swap_size = 2`,
|
|
`ssh_key_names = ["my-key"]`,
|
|
}
|
|
|
|
for _, want := range tests {
|
|
if !contains(string(content), want) {
|
|
t.Errorf("expected %q in output", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigGetValue(t *testing.T) {
|
|
cfg := &Config{
|
|
Variables: map[string]string{
|
|
"server_name": "my-server",
|
|
},
|
|
}
|
|
|
|
// Existing value
|
|
if v, ok := cfg.GetValue("server_name"); !ok || v != "my-server" {
|
|
t.Errorf("GetValue(server_name) = %q, %v; want my-server, true", v, ok)
|
|
}
|
|
|
|
// Missing value
|
|
if _, ok := cfg.GetValue("missing"); ok {
|
|
t.Error("GetValue(missing) should return false")
|
|
}
|
|
}
|
|
|
|
func TestConfigGetWithDefault(t *testing.T) {
|
|
cfg := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
},
|
|
}
|
|
|
|
// With value set
|
|
if v := cfg.GetWithDefault("cloud_provider"); v != "hetzner" {
|
|
t.Errorf("GetWithDefault(cloud_provider) = %q, want hetzner", v)
|
|
}
|
|
|
|
// Without value, using schema default
|
|
if v := cfg.GetWithDefault("server_type_hetzner"); v != "cpx21" {
|
|
t.Errorf("GetWithDefault(server_type_hetzner) = %q, want cpx21", v)
|
|
}
|
|
}
|
|
|
|
func TestConfigValidate(t *testing.T) {
|
|
// Valid config (cloud_provider set)
|
|
cfg := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
},
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Errorf("Validate() error = %v", err)
|
|
}
|
|
|
|
// Invalid config (missing requiredcloud_provider)
|
|
cfg2 := &Config{
|
|
Variables: map[string]string{},
|
|
}
|
|
if err := cfg2.Validate(); err == nil {
|
|
t.Error("Validate() should fail without cloud_provider")
|
|
}
|
|
}
|
|
|
|
func TestConfigMerge(t *testing.T) {
|
|
base := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
"server_name": "original",
|
|
},
|
|
}
|
|
|
|
other := &Config{
|
|
Variables: map[string]string{
|
|
"server_name": "updated",
|
|
"location": "ash",
|
|
},
|
|
}
|
|
|
|
base.Merge(other)
|
|
|
|
if base.Variables["cloud_provider"] != "hetzner" {
|
|
t.Error("Merge should preserve existing keys")
|
|
}
|
|
if base.Variables["server_name"] != "updated" {
|
|
t.Error("Merge should overwrite with new values")
|
|
}
|
|
if base.Variables["location"] != "ash" {
|
|
t.Error("Merge should add new keys")
|
|
}
|
|
}
|
|
|
|
func TestDotEnvRoundTrip(t *testing.T) {
|
|
// Write a config
|
|
original := &Config{
|
|
Variables: map[string]string{
|
|
"cloud_provider": "hetzner",
|
|
"server_name": "test-server",
|
|
"enable_tailscale": "true",
|
|
"ssh_key_names": `["key-1", "key-2"]`,
|
|
"venice_api_key": "secret-key",
|
|
},
|
|
}
|
|
|
|
tmpdir := t.TempDir()
|
|
envPath := filepath.Join(tmpdir, ".env")
|
|
|
|
if err := WriteDotEnv(original, envPath); err != nil {
|
|
t.Fatalf("WriteDotEnv() error = %v", err)
|
|
}
|
|
|
|
// Read back
|
|
env, err := ParseDotEnv(envPath)
|
|
if err != nil {
|
|
t.Fatalf("ParseDotEnv() error = %v", err)
|
|
}
|
|
parsed := env.ToConfig()
|
|
|
|
// Verify key values
|
|
for _, key := range []string{"cloud_provider", "server_name", "enable_tailscale"} {
|
|
if got, want := parsed.Variables[key], original.Variables[key]; got != want {
|
|
t.Errorf("round-trip %s: got %q, want %q", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatTfVarsValue(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", "\"\""},
|
|
{"hello", "\"hello\""},
|
|
{"hello world", "\"hello world\""},
|
|
{"true", "true"},
|
|
{"false", "false"},
|
|
{"42", "42"},
|
|
{"3.14", "3.14"},
|
|
{`["a", "b"]`, `["a", "b"]`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
if got := formatTfVarsValue(tt.input); got != tt.want {
|
|
t.Errorf("formatTfVarsValue(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatDotEnvValue(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", "\"\""},
|
|
{"simple", "simple"},
|
|
{"has space", `"has space"`},
|
|
{"has#hash", `"has#hash"`},
|
|
{`["list"]`, `["list"]`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
if got := formatDotEnvValue(tt.input); got != tt.want {
|
|
t.Errorf("formatDotEnvValue(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
|
}
|
|
|
|
func containsSubstring(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|