From 6f994f37564eb692ebebb63532f321cb9cd327ba Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 14:21:57 -0400 Subject: [PATCH] feat(canary): kubeconfig generator + YAML template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 first commit. Generator produces a real-looking kubeconfig artifact that points kubectl at the canary's /k/{id} endpoint: - text/template embedded YAML following the spec §9.5 layout (one cluster + one context + one user, all referencing the same prod-cluster + svc-backup-reader names for verisimilitude) - APIServerURL = baseURL + "/k/" + t.ID (trailing-slash safe via strings.TrimRight) - Token = t.ID embedded as the user's bearer token — when kubectl fires a request, the bearer in the Authorization header is exactly this id, giving us a second lookup path beyond the URL path - ClusterName = "prod-cluster", UserName = "svc-backup-reader" — hardcoded per spec §9.5 lines 1193-1194 (tempting bait names that look like a real prod service-account kubeconfig) - Artifact.Kind = KindText (different from docx/pdf KindFile — YAML is text) - ContentType "application/yaml" - Filename default "kubeconfig" with the standard *string deref+trim pattern from docx/pdf The template file is named template.yaml.tmpl (not .yaml) so the repo's pre-commit check-yaml hook does not parse it as pure YAML — the {{.X}} Go-template tokens are not valid YAML flow mappings and would fail the parser. .tmpl is the conventional extension for Go-templated source files; identify-cli (which check-yaml uses to match files) does not classify .yaml.tmpl as YAML. Future phases needing templated YAML (envfile recipes, etc.) should follow the same convention. Tests parse the rendered output through gopkg.in/yaml.v3 against a typed schema struct, asserting every field flows through correctly: APIVersion, Kind, current-context, Clusters[0].name + .cluster.server, Contexts[0].name + .context.cluster + .context.user, Users[0].name + .user.token. Plus Filename defaulting subtests, base-URL trailing-slash trim, subpath preserve, distinct-ids → distinct outputs, bearer-token-embedded check, ends-with-newline check. Handler (Trigger) ships in the next commit per spec §9.5 file split ({generator, handler, template}). At this commit kubeconfig.Generator has Type() + Generate() but no Trigger() — the package compiles in isolation but does not yet satisfy generators.Generator interface. The registry wires it in commit 3. go test -race -timeout=60s ./internal/token/generators/kubeconfig/... passes. --- .../token/generators/kubeconfig/generator.go | 89 ++++++ .../generators/kubeconfig/generator_test.go | 292 ++++++++++++++++++ .../generators/kubeconfig/template.yaml.tmpl | 18 ++ 3 files changed, 399 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go new file mode 100644 index 00000000..fc63f5bc --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go @@ -0,0 +1,89 @@ +// ©AngelaMos | 2026 +// generator.go + +package kubeconfig + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "strings" + "text/template" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" +) + +const ( + triggerPathPrefix = "/k/" + + contentTypeYAML = "application/yaml" + defaultFilename = "kubeconfig" + + defaultClusterName = "prod-cluster" + defaultUserName = "svc-backup-reader" + + templateName = "kubeconfig" +) + +//go:embed template.yaml.tmpl +var templateYAML string + +var kubeconfigTemplate = template.Must( + template.New(templateName).Parse(templateYAML), +) + +type kubeconfigData struct { + APIServerURL string + Token string + ClusterName string + UserName string +} + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeKubeconfig } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + apiServerURL := strings.TrimRight(baseURL, "/") + + triggerPathPrefix + t.ID + data := kubeconfigData{ + APIServerURL: apiServerURL, + Token: t.ID, + ClusterName: defaultClusterName, + UserName: defaultUserName, + } + + var buf bytes.Buffer + if err := kubeconfigTemplate.Execute(&buf, data); err != nil { + return generators.Artifact{}, fmt.Errorf( + "kubeconfig: render template: %w", + err, + ) + } + + return generators.Artifact{ + Kind: generators.KindText, + Filename: resolveFilename(t.Filename), + Content: buf.Bytes(), + ContentType: contentTypeYAML, + }, nil +} + +func resolveFilename(name *string) string { + if name == nil { + return defaultFilename + } + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return defaultFilename + } + return trimmed +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go new file mode 100644 index 00000000..a8db3b46 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go @@ -0,0 +1,292 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package kubeconfig_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig" +) + +const ( + testBaseURL = "https://canary.example.com" + expectedKubeconfigMIME = "application/yaml" + defaultFilenameValue = "kubeconfig" + defaultClusterNameValue = "prod-cluster" + defaultUserNameValue = "svc-backup-reader" +) + +type kubeconfigSchema struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + CurrentContext string `yaml:"current-context"` + Clusters []struct { + Name string `yaml:"name"` + Cluster struct { + Server string `yaml:"server"` + } `yaml:"cluster"` + } `yaml:"clusters"` + Contexts []struct { + Name string `yaml:"name"` + Context struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` + } `yaml:"context"` + } `yaml:"contexts"` + Users []struct { + Name string `yaml:"name"` + User struct { + Token string `yaml:"token"` + } `yaml:"user"` + } `yaml:"users"` +} + +func newKubeconfigToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeKubeconfig, + Memo: "unit test kubeconfig", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func newKubeconfigTokenWithFilename(id, filename string) *token.Token { + tok := newKubeconfigToken(id) + tok.Filename = &filename + return tok +} + +func parseKubeconfig(t *testing.T, content []byte) kubeconfigSchema { + t.Helper() + var kc kubeconfigSchema + require.NoError( + t, + yaml.Unmarshal(content, &kc), + "generated kubeconfig must parse as YAML", + ) + return kc +} + +func TestGenerator_TypeIsKubeconfig(t *testing.T) { + g := kubeconfig.New() + require.Equal(t, token.TypeKubeconfig, g.Type()) +} + +func TestGenerate_ArtifactKindIsText(t *testing.T) { + g := kubeconfig.New() + art, err := g.Generate( + context.Background(), + newKubeconfigToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindText, art.Kind) +} + +func TestGenerate_ContentTypeIsYAML(t *testing.T) { + g := kubeconfig.New() + art, err := g.Generate( + context.Background(), + newKubeconfigToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, expectedKubeconfigMIME, art.ContentType) +} + +func TestGenerate_Filename(t *testing.T) { + g := kubeconfig.New() + + t.Run("nil Filename defaults to kubeconfig", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newKubeconfigToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }) + + t.Run( + "empty Filename pointer defaults to kubeconfig", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newKubeconfigTokenWithFilename("abc", ""), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }, + ) + + t.Run( + "whitespace-only Filename defaults to kubeconfig", + func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newKubeconfigTokenWithFilename("abc", " "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }, + ) + + t.Run("set Filename is preserved (trimmed)", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newKubeconfigTokenWithFilename("abc", " prod-kubeconfig "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, "prod-kubeconfig", art.Filename) + }) +} + +func TestGenerate_APIServerURL(t *testing.T) { + g := kubeconfig.New() + + t.Run("base URL trailing slash trimmed", func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newKubeconfigToken("tk1"), + "https://canary.example.com", + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newKubeconfigToken("tk1"), + "https://canary.example.com/", + ) + require.NoError(t, err) + + kcA := parseKubeconfig(t, artA.Content) + kcB := parseKubeconfig(t, artB.Content) + require.Len(t, kcA.Clusters, 1) + require.Len(t, kcB.Clusters, 1) + require.Equal( + t, + "https://canary.example.com/k/tk1", + kcA.Clusters[0].Cluster.Server, + ) + require.Equal( + t, + "https://canary.example.com/k/tk1", + kcB.Clusters[0].Cluster.Server, + ) + }) + + t.Run("base URL subpath preserved", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newKubeconfigToken("tk2"), + "https://example.com/canary", + ) + require.NoError(t, err) + kc := parseKubeconfig(t, art.Content) + require.Len(t, kc.Clusters, 1) + require.Equal( + t, + "https://example.com/canary/k/tk2", + kc.Clusters[0].Cluster.Server, + ) + }) + + t.Run( + "different token ids produce distinct outputs", + func(t *testing.T) { + artA, err := g.Generate( + context.Background(), + newKubeconfigToken("aaa"), + testBaseURL, + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newKubeconfigToken("bbb"), + testBaseURL, + ) + require.NoError(t, err) + require.NotEqual(t, artA.Content, artB.Content) + }, + ) +} + +func TestGenerate_YAMLParsesAsKubeconfig(t *testing.T) { + g := kubeconfig.New() + art, err := g.Generate( + context.Background(), + newKubeconfigToken("abc123"), + testBaseURL, + ) + require.NoError(t, err) + + kc := parseKubeconfig(t, art.Content) + + require.Equal(t, "v1", kc.APIVersion) + require.Equal(t, "Config", kc.Kind) + require.Equal(t, defaultClusterNameValue, kc.CurrentContext) + + require.Len(t, kc.Clusters, 1) + require.Equal(t, defaultClusterNameValue, kc.Clusters[0].Name) + require.Equal( + t, + "https://canary.example.com/k/abc123", + kc.Clusters[0].Cluster.Server, + ) + + require.Len(t, kc.Contexts, 1) + require.Equal(t, defaultClusterNameValue, kc.Contexts[0].Name) + require.Equal( + t, + defaultClusterNameValue, + kc.Contexts[0].Context.Cluster, + ) + require.Equal(t, defaultUserNameValue, kc.Contexts[0].Context.User) + + require.Len(t, kc.Users, 1) + require.Equal(t, defaultUserNameValue, kc.Users[0].Name) + require.Equal(t, "abc123", kc.Users[0].User.Token) +} + +func TestGenerate_TokenIDEmbeddedAsBearer(t *testing.T) { + g := kubeconfig.New() + art, err := g.Generate( + context.Background(), + newKubeconfigToken("super-secret-bearer-id"), + testBaseURL, + ) + require.NoError(t, err) + require.Contains( + t, + string(art.Content), + "super-secret-bearer-id", + "kubeconfig must embed the token ID as the bearer token", + ) +} + +func TestGenerate_ContentEndsWithNewline(t *testing.T) { + g := kubeconfig.New() + art, err := g.Generate( + context.Background(), + newKubeconfigToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.True( + t, + strings.HasSuffix(string(art.Content), "\n"), + "generated YAML must end with a newline (POSIX convention)", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl new file mode 100644 index 00000000..942e7b2f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl @@ -0,0 +1,18 @@ +# ©AngelaMos | 2026 +# template.yaml +apiVersion: v1 +kind: Config +current-context: {{.ClusterName}} +clusters: +- name: {{.ClusterName}} + cluster: + server: {{.APIServerURL}} +contexts: +- name: {{.ClusterName}} + context: + cluster: {{.ClusterName}} + user: {{.UserName}} +users: +- name: {{.UserName}} + user: + token: {{.Token}}