Skip to content
2 changes: 1 addition & 1 deletion docs/design-doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ GitHub Actions 上で実行された Terraform apply の証跡(v1)。`eviden

1. `scope.repositories` の各リポジトリについて、対象日に完了した CI run を列挙する
2. workflow 名 / job 名が `apply_workflows` / `apply_job_names` に一致する run を apply 実行とみなす
3. run に紐づく commit SHA、実行者、URL、workspace(run パラメータまたは環境変数から取得)を記録する
3. マッチした apply job ごとに commit SHA、実行者、URL、workspace を記録する。workspace は GHA matrix job 名から取得する(共有 workflow で apply job 名が固定でも、GitHub が matrix 次元を `(dim1, dim2, ...)` 形式で suffix する)。`workspace` は先頭次元とし、KAO は `workspace_job_name_pattern`(デフォルト: `terraform apply \(([^,)]+)`)で抽出する

```json
{
Expand Down
19 changes: 18 additions & 1 deletion internal/collect/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,34 @@ func NewCommand() *cobra.Command {
},
}

for _, c := range []*cobra.Command{cmd, repoControlCmd, codeChangeCmd} {
infraDeploymentCmd := &cobra.Command{
Use: "infra-deployment",
Short: "Collect infrastructure deployment evidence for a date",
RunE: func(cmd *cobra.Command, args []string) error {
return RunInfraDeployment(context.Background(), Options{
ConfigPath: configPath,
Repositories: repositories,
Output: output,
Date: date,
})
},
}

for _, c := range []*cobra.Command{cmd, repoControlCmd, codeChangeCmd, infraDeploymentCmd} {
c.PersistentFlags().StringVar(&configPath, "config", "", "path to kao.yaml")
}
repoControlCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path")
codeChangeCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path")
infraDeploymentCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path")
codeChangeCmd.Flags().StringVar(&date, "date", "", "target date (YYYY-MM-DD, UTC)")
infraDeploymentCmd.Flags().StringVar(&date, "date", "", "target date (YYYY-MM-DD, UTC)")

repoControlCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)")
codeChangeCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)")
infraDeploymentCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)")

cmd.AddCommand(repoControlCmd)
cmd.AddCommand(codeChangeCmd)
cmd.AddCommand(infraDeploymentCmd)
return cmd
}
77 changes: 77 additions & 0 deletions internal/collect/infra_deployment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package collect

import (
"context"
"fmt"
"os"

"github.com/keyskey/kao/internal/evidence"
gh "github.com/keyskey/kao/internal/provider/github"
)

func RunInfraDeployment(ctx context.Context, opts Options) error {
if opts.Date == "" {
return fmt.Errorf("--date is required for infra-deployment collection")
}

cfg, err := loadConfig(opts.ConfigPath)
if err != nil {
return err
}

client, err := gh.New(cfg)
if err != nil {
return err
}

repos := repoList(cfg, "infra_deployment", opts.Repositories)
if len(repos) == 0 {
return fmt.Errorf("no repositories to collect")
}

backend, writer, err := resolveOutput(cfg, opts.Output)
if err != nil {
return err
}
if writer != nil {
defer func() {
if f, ok := writer.(*os.File); ok && f != os.Stdout {
f.Close()
}
}()
}

var records []evidence.InfraDeployment
var failures []error

for _, repo := range repos {
deployments, err := client.CollectInfraDeployment(ctx, repo, opts.Date, cfg)
if err != nil {
failures = append(failures, fmt.Errorf("%s: %w", repo, err))
continue
}
records = append(records, deployments...)
}

if writer != nil {
items := make([]any, len(records))
for i, r := range records {
items[i] = r
}
if err := writeJSONL(writer, items); err != nil {
return err
}
} else if backend != nil && len(records) > 0 {
if err := backend.PutInfraDeployment(ctx, records); err != nil {
return err
}
}

if len(failures) > 0 {
for _, f := range failures {
fmt.Fprintf(os.Stderr, "collect infra-deployment: %v\n", f)
}
return fmt.Errorf("%d repository(s) failed", len(failures))
}
return nil
}
61 changes: 57 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ type RepositoryScope struct {
}

type Evidence struct {
RepoControl EvidenceRepoControl `yaml:"repo_control"`
CodeChange EvidenceCodeChange `yaml:"code_change"`
RepoControl EvidenceRepoControl `yaml:"repo_control"`
CodeChange EvidenceCodeChange `yaml:"code_change"`
InfraDeployment EvidenceInfraDeployment `yaml:"infra_deployment"`
}

type EvidenceRepoControl struct {
Expand All @@ -57,9 +58,32 @@ type EvidenceCodeChange struct {
Scope *RepositoryScope `yaml:"scope,omitempty"`
}

type InfraDeploymentScope struct {
Repositories RepositoryScope `yaml:"repositories"`
Workspaces RepositoryScope `yaml:"workspaces"`
}

type EvidenceInfraDeployment struct {
Provider string `yaml:"provider"`
Environment string `yaml:"environment"`
Scope *InfraDeploymentScope `yaml:"scope,omitempty"`
ApplyWorkflows []string `yaml:"apply_workflows"`
ApplyJobNames []string `yaml:"apply_job_names"`
WorkspaceJobNamePattern string `yaml:"workspace_job_name_pattern"`
}

type Controls struct {
RepoControl RepoControlControls `yaml:"repo_control"`
CodeChange CodeChangeControls `yaml:"code_change"`
RepoControl RepoControlControls `yaml:"repo_control"`
CodeChange CodeChangeControls `yaml:"code_change"`
InfraDeployment InfraDeploymentControls `yaml:"infra_deployment"`
}

type InfraDeploymentControls struct {
Traceability InfraDeploymentTraceabilityControls `yaml:"traceability"`
}

type InfraDeploymentTraceabilityControls struct {
Required *bool `yaml:"required,omitempty"`
}

type RepoControlControls struct {
Expand Down Expand Up @@ -172,6 +196,20 @@ func applyDefaults(cfg *Config) {
v := 90
cfg.Evaluation.CodeChange.LookbackDays = &v
}

id := &cfg.Evidence.InfraDeployment
if id.Environment == "" {
id.Environment = "production"
}
if id.WorkspaceJobNamePattern == "" {
id.WorkspaceJobNamePattern = `terraform apply \(([^,)]+)`
}

idc := &cfg.Controls.InfraDeployment.Traceability
if idc.Required == nil {
v := true
idc.Required = &v
}
}

func (c *Config) GitHubToken() (string, error) {
Expand Down Expand Up @@ -211,6 +249,21 @@ func (c *Config) RepositoriesFor(domain string) RepositoryScope {
if c.Evidence.CodeChange.Scope != nil {
return *c.Evidence.CodeChange.Scope
}
case "infra_deployment":
if c.Evidence.InfraDeployment.Scope != nil && len(c.Evidence.InfraDeployment.Scope.Repositories.Include) > 0 {
return c.Evidence.InfraDeployment.Scope.Repositories
}
}
return c.Scope.Defaults.Repositories
}

func (c *Config) WorkspacesForInfraDeployment() RepositoryScope {
if c.Evidence.InfraDeployment.Scope != nil {
return c.Evidence.InfraDeployment.Scope.Workspaces
}
return RepositoryScope{}
}

func (c *Config) InfraDeploymentConfig() EvidenceInfraDeployment {
return c.Evidence.InfraDeployment
}
9 changes: 9 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,13 @@ controls: {}
if *cfg.Evaluation.CodeChange.LookbackDays != 90 {
t.Error("expected lookback_days default 90")
}
if cfg.Evidence.InfraDeployment.Environment != "production" {
t.Error("expected infra_deployment.environment default production")
}
if cfg.Evidence.InfraDeployment.WorkspaceJobNamePattern == "" {
t.Error("expected workspace_job_name_pattern default")
}
if *cfg.Controls.InfraDeployment.Traceability.Required != true {
t.Error("expected infra_deployment.traceability.required default true")
}
}
1 change: 1 addition & 0 deletions internal/evaluate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func NewCommand() *cobra.Command {

cmd.Flags().StringVar(&opts.RepoControlFile, "repo-control", "", "repo_control JSONL file (--input files)")
cmd.Flags().StringArrayVar(&opts.CodeChangeFiles, "code-change", nil, "code_change JSONL file (--input files, repeatable)")
cmd.Flags().StringArrayVar(&opts.InfraDeploymentFiles, "infra-deployment", nil, "infra_deployment JSONL file (--input files, repeatable)")

return cmd
}
50 changes: 50 additions & 0 deletions internal/evaluate/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,55 @@ func TestEvaluateCodeChangeCM008Fail(t *testing.T) {
}
}

func TestEvaluateInfraDeploymentCM007Pass(t *testing.T) {
cfg := &config.Config{}
applyTestDefaults(cfg)

id := evidence.InfraDeployment{
Repository: "platform-infra",
Workspace: "prod-gke",
CommitSHA: "def456",
AppliedAt: time.Date(2026, 6, 5, 16, 5, 0, 0, time.UTC),
Execution: evidence.Execution{URL: "https://github.com/org/platform-infra/actions/runs/1"},
}
cc := evidence.CodeChange{
Repository: "platform-infra",
PRNumber: 10,
CommitSHA: "def456",
Author: "alice",
ApprovalCount: 1,
MergedAt: time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC),
}
join := map[JoinKey]evidence.CodeChange{
{Repository: "platform-infra", CommitSHA: "def456"}: cc,
}

results := EvaluateAll(cfg, nil, nil, []evidence.InfraDeployment{id}, join, id.AppliedAt)
cm007 := findResult(results, "CM-007")
if cm007 == nil || cm007.Status != evidence.StatusPassed {
t.Fatalf("CM-007 should pass, got %+v", cm007)
}
}

func TestEvaluateInfraDeploymentCM007JoinFail(t *testing.T) {
cfg := &config.Config{}
applyTestDefaults(cfg)

id := evidence.InfraDeployment{
Repository: "platform-infra",
Workspace: "prod-gke",
CommitSHA: "missing",
AppliedAt: time.Date(2026, 6, 5, 16, 5, 0, 0, time.UTC),
Execution: evidence.Execution{URL: "https://github.com/org/platform-infra/actions/runs/1"},
}

results := EvaluateAll(cfg, nil, nil, []evidence.InfraDeployment{id}, map[JoinKey]evidence.CodeChange{}, id.AppliedAt)
cm007 := findResult(results, "CM-007")
if cm007 == nil || cm007.Status != evidence.StatusFailed {
t.Fatalf("CM-007 should fail, got %+v", cm007)
}
}

func applyTestDefaults(cfg *config.Config) {
trueVal := true
falseVal := false
Expand All @@ -82,6 +131,7 @@ func applyTestDefaults(cfg *config.Config) {
cfg.Controls.RepoControl.Codeowners.Required = &trueVal
cfg.Controls.CodeChange.CI.RequirePass = &trueVal
cfg.Controls.CodeChange.Approver.MustBeAuthorized = &trueVal
cfg.Controls.InfraDeployment.Traceability.Required = &trueVal
}

func findResult(results []evidence.Evaluation, controlID string) *evidence.Evaluation {
Expand Down
69 changes: 69 additions & 0 deletions internal/evaluate/infra_deployment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package evaluate

import (
"fmt"
"time"

"github.com/keyskey/kao/internal/config"
"github.com/keyskey/kao/internal/evidence"
)

func evaluateInfraDeployment(cfg *config.Config, id evidence.InfraDeployment, cc *evidence.CodeChange, evaluatedAt time.Time) []evidence.Evaluation {
if !*cfg.Controls.InfraDeployment.Traceability.Required {
return nil
}

resourceID := infraDeploymentResourceID(id)
refs := []evidence.EvidenceRef{{
Type: "infra_deployment",
ResourceID: resourceID,
}}

ev := evidence.NewEvaluation("CM-007", "infra_deployment", resourceID, evidence.SeverityCritical)
ev.EvidenceRefs = refs
ev.EvaluatedAt = evaluatedAt

if cc == nil {
ev.Status = evidence.StatusFailed
ev.Expected = "code_change joined by repository + commit_sha"
ev.Actual = "no matching code_change"
ev.Reason = "infra_deployment → code_change join failed"
return []evidence.Evaluation{ev}
}

refs = append(refs, evidence.EvidenceRef{
Type: "code_change",
ResourceID: fmt.Sprintf("%s#%d", cc.Repository, cc.PRNumber),
})
ev.EvidenceRefs = refs

requiredReviews := *cfg.Controls.RepoControl.BranchProtection.RequiredReviews

switch {
case cc.Author == "":
ev.Status = evidence.StatusFailed
ev.Expected = "code_change.author present"
ev.Actual = "author missing"
ev.Reason = "infra_deployment → code_change → author missing"
case cc.ApprovalCount < requiredReviews:
ev.Status = evidence.StatusFailed
ev.Expected = fmt.Sprintf("approval_count >= %d", requiredReviews)
ev.Actual = fmt.Sprintf("approval_count == %d", cc.ApprovalCount)
ev.Reason = "infra_deployment → code_change → approvals insufficient"
case id.Execution.URL == "":
ev.Status = evidence.StatusFailed
ev.Expected = "execution.url present"
ev.Actual = "execution.url missing"
ev.Reason = "infra_deployment → execution.url missing"
default:
ev.Status = evidence.StatusPassed
ev.Expected = "traceability chain complete"
ev.Actual = "repository+sha join, author, approvals, execution.url present"
}

return []evidence.Evaluation{ev}
}

func infraDeploymentResourceID(id evidence.InfraDeployment) string {
return fmt.Sprintf("%s#%s@%s", id.Repository, id.Workspace, id.AppliedAt.UTC().Format(time.RFC3339))
}
26 changes: 26 additions & 0 deletions internal/evaluate/join.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package evaluate

import (
"github.com/keyskey/kao/internal/evidence"
)

type JoinKey struct {
Repository string
CommitSHA string
}

// JoinCodeChange maps repository+commit_sha to the code_change with the newest merged_at.
func JoinCodeChange(changes []evidence.CodeChange) map[JoinKey]evidence.CodeChange {
best := make(map[JoinKey]evidence.CodeChange)
for _, cc := range changes {
if cc.Repository == "" || cc.CommitSHA == "" {
continue
}
key := JoinKey{Repository: cc.Repository, CommitSHA: cc.CommitSHA}
existing, ok := best[key]
if !ok || cc.MergedAt.After(existing.MergedAt) {
best[key] = cc
}
}
return best
}
Loading
Loading