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:
parent
4c25db7b1a
commit
6f994f3756
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)",
|
||||
)
|
||||
}
|
||||
|
|
@ -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}}
|
||||
Loading…
Reference in New Issue