Skip to content

Commit abe6f1b

Browse files
authored
feat(convert): add "import" from knative service (ksvc) (#48)
An example would look like this: ```sh kuba convert \ --from ksvc \ --infile path/to/ksvc.yaml \ --outfile kuba.yaml \ --env prod ```
1 parent a35a6ab commit abe6f1b

File tree

4 files changed

+299
-46
lines changed

4 files changed

+299
-46
lines changed

cmd/kuba/convert.go

Lines changed: 153 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ var (
2323
var convertCmd = &cobra.Command{
2424
Use: "convert",
2525
Short: "Convert configuration from other formats to kuba.yaml",
26-
Long: `Convert configuration files from other formats (e.g., dotenv) to kuba.yaml format.
26+
Long: `Convert configuration files from other formats (e.g., dotenv, ksvc) to kuba.yaml format.
2727
2828
This command helps migrate existing configurations to kuba.yaml format.
2929
For dotenv files, it will create environment variable entries using the 'value' field.
30+
For ksvc files, it will convert container env definitions (including secret refs)
31+
into kuba.yaml env mappings.
3032
3133
Note: When updating an existing kuba.yaml file, comments within the modified
3234
environment section will be lost as the section is regenerated. This is a limitation
@@ -40,7 +42,7 @@ comments are important.`,
4042
}
4143

4244
func init() {
43-
convertCmd.Flags().StringVar(&convertFrom, "from", "", "Source format (e.g., 'dotenv')")
45+
convertCmd.Flags().StringVar(&convertFrom, "from", "", "Source format (e.g., 'dotenv', 'ksvc')")
4446
convertCmd.Flags().StringVarP(&convertEnv, "env", "e", "default", "Environment name to use in kuba.yaml (default: default)")
4547
convertCmd.Flags().StringVar(&convertInfile, "infile", "", "Input file path (e.g., .env.example)")
4648
convertCmd.Flags().StringVar(&convertOutfile, "outfile", "", "Output kuba.yaml file path (default: kuba.yaml in current directory)")
@@ -54,8 +56,8 @@ func init() {
5456
func runConvert() error {
5557
logger := log.NewLogger()
5658

57-
if convertFrom != "dotenv" {
58-
return fmt.Errorf("unsupported source format: %s (only 'dotenv' is currently supported)", convertFrom)
59+
if convertFrom != "dotenv" && convertFrom != "ksvc" {
60+
return fmt.Errorf("unsupported source format: %s (supported: 'dotenv', 'ksvc')", convertFrom)
5961
}
6062

6163
// Determine output file path
@@ -64,15 +66,59 @@ func runConvert() error {
6466
outPath = "kuba.yaml"
6567
}
6668

67-
logger.Debug("Converting dotenv to kuba.yaml", "infile", convertInfile, "outfile", outPath, "env", convertEnv)
69+
logger.Debug("Converting configuration to kuba.yaml", "infile", convertInfile, "outfile", outPath, "env", convertEnv, "from", convertFrom)
6870

69-
// Read and parse dotenv file
70-
logger.Debug("Reading dotenv file", "path", convertInfile)
71-
envVars, err := parseDotenvFile(convertInfile)
72-
if err != nil {
73-
return fmt.Errorf("failed to parse dotenv file: %w", err)
71+
// Read and parse input file based on source format
72+
var (
73+
newEnvItems map[string]config.EnvItem
74+
sourceVarsCount int
75+
defaultProvider string
76+
defaultProject string
77+
)
78+
79+
switch convertFrom {
80+
case "dotenv":
81+
logger.Debug("Reading dotenv file", "path", convertInfile)
82+
envVars, err := parseDotenvFile(convertInfile)
83+
if err != nil {
84+
return fmt.Errorf("failed to parse dotenv file: %w", err)
85+
}
86+
logger.Debug("Parsed dotenv file", "variables_count", len(envVars))
87+
88+
newEnvItems = make(map[string]config.EnvItem, len(envVars))
89+
for key, value := range envVars {
90+
// Skip empty values
91+
if strings.TrimSpace(value) == "" {
92+
logger.Debug("Skipping empty environment variable", "key", key)
93+
continue
94+
}
95+
newEnvItems[key] = config.EnvItem{
96+
Value: value,
97+
}
98+
}
99+
sourceVarsCount = len(newEnvItems)
100+
// For dotenv we default to local provider
101+
defaultProvider = "local"
102+
defaultProject = ""
103+
case "ksvc":
104+
logger.Debug("Reading ksvc file", "path", convertInfile)
105+
items, provider, project, err := parseKsvcFile(convertInfile)
106+
if err != nil {
107+
return fmt.Errorf("failed to parse ksvc file: %w", err)
108+
}
109+
logger.Debug("Parsed ksvc file", "variables_count", len(items), "provider", provider, "project", project)
110+
111+
newEnvItems = items
112+
sourceVarsCount = len(newEnvItems)
113+
// For ksvc we default to gcp provider with project/namespace if present
114+
if provider == "" {
115+
provider = "gcp"
116+
}
117+
defaultProvider = provider
118+
defaultProject = project
119+
default:
120+
return fmt.Errorf("unsupported source format: %s (supported: 'dotenv', 'ksvc')", convertFrom)
74121
}
75-
logger.Debug("Parsed dotenv file", "variables_count", len(envVars))
76122

77123
// Load existing kuba.yaml if it exists, or create new config
78124
var kubaConfig *config.KubaConfig
@@ -107,10 +153,10 @@ func runConvert() error {
107153
env, exists := kubaConfig.Environments[convertEnv]
108154
if !exists {
109155
logger.Debug("Creating new environment", "env", convertEnv)
110-
// Create a new environment with local provider (since we're using values)
156+
// Create a new environment
111157
env = config.Environment{
112-
Provider: "local",
113-
Project: "",
158+
Provider: defaultProvider,
159+
Project: defaultProject,
114160
Env: make(map[string]config.EnvItem),
115161
}
116162
} else {
@@ -120,21 +166,10 @@ func runConvert() error {
120166
}
121167
}
122168

123-
// Add dotenv entries to the environment
124-
// Since dotenv files contain actual values, we'll use the 'value' field
125-
// If the environment uses a different provider, we'll keep that but still add values
126-
// The user can later convert values to secrets if needed
127-
// Skip empty values - they should not be included in the config
128-
for key, value := range envVars {
129-
// Skip empty values
130-
if strings.TrimSpace(value) == "" {
131-
logger.Debug("Skipping empty environment variable", "key", key)
132-
continue
133-
}
134-
env.Env[key] = config.EnvItem{
135-
Value: value,
136-
}
137-
logger.Debug("Added environment variable", "key", key)
169+
// Add or update entries in the environment
170+
for key, item := range newEnvItems {
171+
env.Env[key] = item
172+
logger.Debug("Added or updated environment variable", "key", key)
138173
}
139174

140175
// Clean up empty values from the environment before writing
@@ -149,11 +184,99 @@ func runConvert() error {
149184
return fmt.Errorf("failed to write kuba.yaml: %w", err)
150185
}
151186

152-
fmt.Printf("Successfully converted %d variables from %s to kuba.yaml (environment: %s)\n", len(envVars), convertInfile, convertEnv)
187+
fmt.Printf("Successfully converted %d variables from %s to kuba.yaml (environment: %s)\n", sourceVarsCount, convertInfile, convertEnv)
153188
logger.Debug("Conversion completed successfully")
154189
return nil
155190
}
156191

192+
// parseKsvcFile reads and parses a Knative Service (ksvc) YAML file and converts
193+
// its container environment variables into kuba EnvItems.
194+
// It returns the env items, along with a suggested default provider and project.
195+
func parseKsvcFile(filePath string) (map[string]config.EnvItem, string, string, error) {
196+
type ksvcEnv struct {
197+
Name string `yaml:"name"`
198+
Value string `yaml:"value,omitempty"`
199+
ValueFrom struct {
200+
SecretKeyRef struct {
201+
Name string `yaml:"name"`
202+
Key string `yaml:"key"`
203+
} `yaml:"secretKeyRef"`
204+
} `yaml:"valueFrom,omitempty"`
205+
}
206+
207+
type ksvcContainer struct {
208+
Env []ksvcEnv `yaml:"env"`
209+
}
210+
211+
type ksvcSpecTemplateSpec struct {
212+
Containers []ksvcContainer `yaml:"containers"`
213+
}
214+
215+
type ksvcSpecTemplate struct {
216+
Spec ksvcSpecTemplateSpec `yaml:"spec"`
217+
}
218+
219+
type ksvcSpec struct {
220+
Template ksvcSpecTemplate `yaml:"template"`
221+
}
222+
223+
type ksvcMetadata struct {
224+
Namespace string `yaml:"namespace"`
225+
}
226+
227+
type ksvcRoot struct {
228+
Metadata ksvcMetadata `yaml:"metadata"`
229+
Spec ksvcSpec `yaml:"spec"`
230+
}
231+
232+
data, err := os.ReadFile(filePath)
233+
if err != nil {
234+
return nil, "", "", fmt.Errorf("failed to read file: %w", err)
235+
}
236+
237+
var svc ksvcRoot
238+
if err := yaml.Unmarshal(data, &svc); err != nil {
239+
return nil, "", "", fmt.Errorf("failed to unmarshal ksvc yaml: %w", err)
240+
}
241+
242+
envItems := make(map[string]config.EnvItem)
243+
244+
// Iterate all containers and their env vars; last one wins on duplicates
245+
for _, container := range svc.Spec.Template.Spec.Containers {
246+
for _, e := range container.Env {
247+
if e.Name == "" {
248+
continue
249+
}
250+
251+
// Hard-coded value
252+
if strings.TrimSpace(e.Value) != "" {
253+
envItems[e.Name] = config.EnvItem{
254+
Value: e.Value,
255+
}
256+
continue
257+
}
258+
259+
// Secret reference
260+
if e.ValueFrom.SecretKeyRef.Name != "" {
261+
// We treat the Kubernetes secret name as the secret-key identifier.
262+
// The key (often "latest") typically represents the version and is
263+
// intentionally not modeled here; providers usually default to latest.
264+
envItems[e.Name] = config.EnvItem{
265+
SecretKey: e.ValueFrom.SecretKeyRef.Name,
266+
}
267+
continue
268+
}
269+
}
270+
}
271+
272+
// Suggested provider/project defaults for the created environment.
273+
// For Cloud Run/Knative on GCP the namespace is typically the project number.
274+
suggestedProvider := "gcp"
275+
suggestedProject := strings.TrimSpace(svc.Metadata.Namespace)
276+
277+
return envItems, suggestedProvider, suggestedProject, nil
278+
}
279+
157280
// parseDotenvFile reads and parses a dotenv file
158281
// It handles:
159282
// - Comments (lines starting with #)

cmd/kuba/convert_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package kuba
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/mistweaverco/kuba/internal/config"
9+
)
10+
11+
func TestParseKsvcFile_ParsesEnvAndSecrets(t *testing.T) {
12+
tmpDir := t.TempDir()
13+
ksvcPath := filepath.Join(tmpDir, "ksvc.yaml")
14+
15+
ksvcContent := `
16+
apiVersion: serving.knative.dev/v1
17+
kind: Service
18+
metadata:
19+
name: api-iam-prod
20+
namespace: "4467360136"
21+
spec:
22+
template:
23+
spec:
24+
containers:
25+
- image: example
26+
env:
27+
- name: GCP_REGION
28+
value: europe-west3
29+
- name: GCP_PROJECT
30+
value: api-infra
31+
- name: DB_PORT
32+
value: "5432"
33+
- name: SALT
34+
valueFrom:
35+
secretKeyRef:
36+
key: latest
37+
name: api-iam-salt
38+
- name: JWT_SECRET
39+
valueFrom:
40+
secretKeyRef:
41+
key: latest
42+
name: api-jwt-secret
43+
`
44+
45+
if err := os.WriteFile(ksvcPath, []byte(ksvcContent), 0o644); err != nil {
46+
t.Fatalf("failed to write temp ksvc file: %v", err)
47+
}
48+
49+
items, provider, project, err := parseKsvcFile(ksvcPath)
50+
if err != nil {
51+
t.Fatalf("parseKsvcFile returned error: %v", err)
52+
}
53+
54+
if provider != "gcp" {
55+
t.Fatalf("expected provider 'gcp', got %q", provider)
56+
}
57+
58+
if project != "4467360136" {
59+
t.Fatalf("expected project '4467360136', got %q", project)
60+
}
61+
62+
// Hard-coded values
63+
expectValue := map[string]string{
64+
"GCP_REGION": "europe-west3",
65+
"GCP_PROJECT": "api-infra",
66+
"DB_PORT": "5432",
67+
}
68+
69+
for key, expected := range expectValue {
70+
item, ok := items[key]
71+
if !ok {
72+
t.Fatalf("expected env item %q to be present", key)
73+
}
74+
if item.Value == nil {
75+
t.Fatalf("expected env item %q to have value, got nil", key)
76+
}
77+
if got := item.Value.(string); got != expected {
78+
t.Fatalf("env item %q: expected value %q, got %q", key, expected, got)
79+
}
80+
if item.SecretKey != "" || item.SecretPath != "" {
81+
t.Fatalf("env item %q: expected no secret fields, got secret-key=%q secret-path=%q", key, item.SecretKey, item.SecretPath)
82+
}
83+
}
84+
85+
// Secret-backed env vars
86+
secretExpectations := map[string]string{
87+
"SALT": "api-iam-salt",
88+
"JWT_SECRET": "api-jwt-secret",
89+
}
90+
91+
for key, expectedSecret := range secretExpectations {
92+
item, ok := items[key]
93+
if !ok {
94+
t.Fatalf("expected env item %q to be present", key)
95+
}
96+
if item.SecretKey != expectedSecret {
97+
t.Fatalf("env item %q: expected secret-key %q, got %q", key, expectedSecret, item.SecretKey)
98+
}
99+
if item.Value != nil {
100+
t.Fatalf("env item %q: expected nil value for secret-backed var, got %#v", key, item.Value)
101+
}
102+
}
103+
}
104+
105+
// Basic sanity check to ensure we can merge ksvc-derived env items into a config.Environment.
106+
func TestKsvcItemsMergeIntoEnvironment(t *testing.T) {
107+
items := map[string]config.EnvItem{
108+
"FOO": {Value: "bar"},
109+
"BAZ": {SecretKey: "secret-id"},
110+
}
111+
112+
env := config.Environment{
113+
Provider: "gcp",
114+
Project: "1234",
115+
Env: map[string]config.EnvItem{},
116+
}
117+
118+
for k, v := range items {
119+
env.Env[k] = v
120+
}
121+
122+
if len(env.Env) != 2 {
123+
t.Fatalf("expected 2 env items, got %d", len(env.Env))
124+
}
125+
126+
if env.Env["FOO"].Value != "bar" {
127+
t.Fatalf("expected FOO value 'bar', got %#v", env.Env["FOO"].Value)
128+
}
129+
130+
if env.Env["BAZ"].SecretKey != "secret-id" {
131+
t.Fatalf("expected BAZ secret-key 'secret-id', got %q", env.Env["BAZ"].SecretKey)
132+
}
133+
}

0 commit comments

Comments
 (0)