|
| 1 | +package gitops |
| 2 | + |
| 3 | +// A render is a claim about a cell: the cell file is regenerated whole and a |
| 4 | +// cell admits only what it names, so a workload, a namespace or a consumer |
| 5 | +// absent from it is withdrawn. A publication promotes a render, so before it |
| 6 | +// does, the workspace's cell file is held to the one the delivery base branch |
| 7 | +// carries, and a removal it does not name is refused: a cell file derived |
| 8 | +// from a checkout missing some modules' renders would otherwise remove those |
| 9 | +// modules from admission without anyone having said so. |
| 10 | + |
| 11 | +import ( |
| 12 | + "context" |
| 13 | + "errors" |
| 14 | + "fmt" |
| 15 | + "io/fs" |
| 16 | + "os" |
| 17 | + "path/filepath" |
| 18 | + "slices" |
| 19 | + "strings" |
| 20 | + |
| 21 | + "github.com/codefly-dev/cli/pkg/environments" |
| 22 | + "github.com/codefly-dev/cli/pkg/gitops/cellfile" |
| 23 | + "github.com/codefly-dev/core/resources" |
| 24 | +) |
| 25 | + |
| 26 | +// cellRemoval is one thing the base cell file admits that the next one does |
| 27 | +// not, and the modules it concerns: the namespace's module, and for a |
| 28 | +// consumer, the consumer's module too. |
| 29 | +type cellRemoval struct { |
| 30 | + modules []string |
| 31 | + what string |
| 32 | +} |
| 33 | + |
| 34 | +// cellRemovals names every namespace, workload, endpoint and endpoint |
| 35 | +// consumer base carries that next does not, in base's order. |
| 36 | +func cellRemovals(base, next *cellfile.File) []cellRemoval { |
| 37 | + var removals []cellRemoval |
| 38 | + for n := range base.Namespaces { |
| 39 | + was := &base.Namespaces[n] |
| 40 | + index := slices.IndexFunc(next.Namespaces, func(candidate cellfile.Namespace) bool { return candidate.Name == was.Name }) |
| 41 | + if index < 0 { |
| 42 | + removals = append(removals, cellRemoval{modules: []string{was.Module}, what: fmt.Sprintf("namespace %s (module %s, %d workload(s))", was.Name, was.Module, len(was.Workloads))}) |
| 43 | + continue |
| 44 | + } |
| 45 | + is := &next.Namespaces[index] |
| 46 | + for w := range was.Workloads { |
| 47 | + workload := &was.Workloads[w] |
| 48 | + found := slices.IndexFunc(is.Workloads, func(candidate cellfile.Workload) bool { |
| 49 | + return candidate.Name == workload.Name && candidate.Kind == workload.Kind |
| 50 | + }) |
| 51 | + if found < 0 { |
| 52 | + removals = append(removals, cellRemoval{modules: []string{was.Module}, what: fmt.Sprintf("workload %s/%s %s", was.Name, workload.Kind, workload.Name)}) |
| 53 | + continue |
| 54 | + } |
| 55 | + removals = append(removals, endpointRemovals(was, workload, &is.Workloads[found])...) |
| 56 | + } |
| 57 | + } |
| 58 | + return removals |
| 59 | +} |
| 60 | + |
| 61 | +// endpointRemovals names the endpoints of one workload, and the consumers of |
| 62 | +// each, that the next file no longer carries. |
| 63 | +func endpointRemovals(namespace *cellfile.Namespace, was, is *cellfile.Workload) []cellRemoval { |
| 64 | + var removals []cellRemoval |
| 65 | + for e := range was.Endpoints { |
| 66 | + endpoint := &was.Endpoints[e] |
| 67 | + where := fmt.Sprintf("%s/%s/%s", namespace.Name, was.Name, endpoint.Name) |
| 68 | + found := slices.IndexFunc(is.Endpoints, func(candidate cellfile.Endpoint) bool { return candidate.Name == endpoint.Name }) |
| 69 | + if found < 0 { |
| 70 | + removals = append(removals, cellRemoval{modules: []string{namespace.Module}, what: "endpoint " + where}) |
| 71 | + continue |
| 72 | + } |
| 73 | + for _, consumer := range endpoint.Consumers { |
| 74 | + if slices.Contains(is.Endpoints[found].Consumers, consumer) { |
| 75 | + continue |
| 76 | + } |
| 77 | + consumerModule, _, _ := strings.Cut(consumer, "/") |
| 78 | + removals = append(removals, cellRemoval{modules: []string{namespace.Module, consumerModule}, what: fmt.Sprintf("consumer %s of %s", consumer, where)}) |
| 79 | + } |
| 80 | + } |
| 81 | + return removals |
| 82 | +} |
| 83 | + |
| 84 | +// deliveryCellPath is the environment's cell file in the delivery |
| 85 | +// repository: beside the modules tree, as the render lays it out in the |
| 86 | +// workspace (CellPath). |
| 87 | +func deliveryCellPath(gitopsPath, environment string) string { |
| 88 | + if gitopsPath == "" { |
| 89 | + gitopsPath = DefaultGitopsPath |
| 90 | + } |
| 91 | + return filepath.ToSlash(filepath.Join(gitopsPath, cellsDir, environment, cellfile.FileName)) |
| 92 | +} |
| 93 | + |
| 94 | +// holdCellToBase refuses the publication when the workspace's cell file for a |
| 95 | +// hosted environment removes anything the delivery base revision's file |
| 96 | +// carries, unless every removal concerns a module allowed names. It returns |
| 97 | +// the removals allowed, for the plan to show. A workspace with no cell file, |
| 98 | +// or a base with none, has nothing to hold. |
| 99 | +func holdCellToBase(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, repo, baseBranch, baseRevision, gitopsPath string, allowed []string) ([]string, error) { |
| 100 | + if env.Host == nil { |
| 101 | + return nil, nil |
| 102 | + } |
| 103 | + path, err := CellPath(workspace, env) |
| 104 | + if err != nil { |
| 105 | + return nil, err |
| 106 | + } |
| 107 | + data, err := os.ReadFile(path) |
| 108 | + if errors.Is(err, fs.ErrNotExist) { |
| 109 | + return nil, nil |
| 110 | + } |
| 111 | + if err != nil { |
| 112 | + return nil, fmt.Errorf("read environment %s's cell file: %w", env.Name, err) |
| 113 | + } |
| 114 | + next, err := cellfile.Parse(data) |
| 115 | + if err != nil { |
| 116 | + return nil, fmt.Errorf("environment %s's cell file %s cannot be read, so it cannot be held to %s's: %w", env.Name, path, baseBranch, err) |
| 117 | + } |
| 118 | + basePath := deliveryCellPath(gitopsPath, env.Name) |
| 119 | + baseData, err := gitCommandBytes(ctx, repo, "show", baseRevision+":"+basePath) |
| 120 | + if err != nil { |
| 121 | + if gitSaysAbsent(err) { |
| 122 | + return nil, nil |
| 123 | + } |
| 124 | + return nil, fmt.Errorf("read %s's cell file %s: %w", baseBranch, basePath, err) |
| 125 | + } |
| 126 | + base, err := cellfile.Parse(baseData) |
| 127 | + if err != nil { |
| 128 | + return nil, fmt.Errorf("%s's cell file %s cannot be read: %w", baseBranch, basePath, err) |
| 129 | + } |
| 130 | + var refused, admitted []string |
| 131 | + unnamed := map[string]bool{} |
| 132 | + for _, removal := range cellRemovals(base, next) { |
| 133 | + if slices.ContainsFunc(removal.modules, func(module string) bool { return slices.Contains(allowed, module) }) { |
| 134 | + admitted = append(admitted, removal.what) |
| 135 | + continue |
| 136 | + } |
| 137 | + refused = append(refused, removal.what) |
| 138 | + unnamed[removal.modules[len(removal.modules)-1]] = true |
| 139 | + } |
| 140 | + if len(refused) > 0 { |
| 141 | + modules := make([]string, 0, len(unnamed)) |
| 142 | + for module := range unnamed { |
| 143 | + modules = append(modules, module) |
| 144 | + } |
| 145 | + slices.Sort(modules) |
| 146 | + return nil, fmt.Errorf("environment %s's cell file %s removes from the cell what %s's admits, and says nothing of it: %s. Publishing it would withdraw those from the cell's admission set. If a module's render is missing from this checkout, render it (`codefly deploy gitops render --all --env %s`); if the removal is intended — a module the workspace no longer composes, a dependency dropped — name each module it concerns with --allow-cell-removal (%s)", |
| 147 | + env.Name, path, baseBranch, strings.Join(refused, "; "), env.Name, strings.Join(modules, ", ")) |
| 148 | + } |
| 149 | + return admitted, nil |
| 150 | +} |
0 commit comments