feat(canary): kubeconfig generator + YAML template

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.
This commit is contained in:
CarterPerez-dev 2026-05-13 14:21:57 -04:00
parent 4c25db7b1a
commit 6f994f3756
3 changed files with 399 additions and 0 deletions

View File

@ -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
}

View File

@ -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)",
)
}

View File

@ -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}}