-
Notifications
You must be signed in to change notification settings - Fork 641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feature(kustomize): add integration for kustomize #421
Conversation
Submitted on behalf of @jorgebee65! |
Pretty cool! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall this looks good, excited for this change! I've left a number of inline comments.
The one part I didn't fully read/understand yet is all the detailed logic around downloading files into a local directory structure. I get the overall idea, but was having a bit of trouble following the various functions handling parts of it. It think that logic can probably be simplified, but I'll take a closer look tomorrow and see if I have any ideas around that.
.../main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeBakeManifestRequest.java
Outdated
Show resolved
Hide resolved
...ifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helm/HelmBakeManifestService.java
Outdated
Show resolved
Hide resolved
.../main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeBakeManifestService.java
Outdated
Show resolved
Hide resolved
} | ||
|
||
private Kustomization convert(Artifact artifact) throws IOException { | ||
return new Yaml().loadAs(downloadFile(artifact), Kustomization.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yaml
objects should be created as new Yaml(new SafeConstructor())
to avoid code injection during deserialization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SafeConstructor
limits castable classes to Java natives. Do you know how I could use SafeConstructor
but also add the Kustomization
class it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure---I thought it could also deserialize other objects? That's how we construct these all in clouddriver, but I'm not 100% the exact restrictions from SafeConstructor
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it works in Clouddriver because KubernetesManifest
is just a primitive Map
with methods on it.
...s/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java
Outdated
Show resolved
Hide resolved
...s/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java
Outdated
Show resolved
Hide resolved
...ts/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeTemplateUtils.java
Outdated
Show resolved
Hide resolved
...sts/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/mapping/Kustomization.java
Outdated
Show resolved
Hide resolved
9f6fe03
to
096de64
Compare
I've addressed most of the simple feedback. I'll tackle the more complex stuff tomorrow morning! Thanks for the detailed feedback @ezimanyi. Feel free to ping me if you'd like to talk about the logic around gathering dependencies. |
implements support for kustomize as a templating engine. one of the interesting challenges with kustomize is that it depends on files adjacent to a single kustomization file for it's templating. the kustomization file refers to files surrounding it. these files are collected and layered on top of eachother to produce a single, fully rendered manifest. because of this we've had to implement a special handling of kustomization files. essentially, before we attempt to render a kustomization we parse the file and generate artifact requests for any dependencies. we'll subsequently do this for any dependencies that happen to be kustomization files themselves.
096de64
to
44f71f8
Compare
throw an exception if kustomization file cannot be found after trying all the varieties.
import com.netflix.spinnaker.kork.artifacts.model.Artifact; | ||
import java.util.Map; | ||
|
||
public interface BakeManifestService { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the interest of reducing the amount of Map<String, Object>
s that get flung around, what if we made the interface look like this?
public interface BakeManifestService<T> {
boolean handles(String type);
Class<T> requestType();
Artifact bake(T bakeManifestRequest);
And pushed the deserialization out to the BakeryController?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's actually something I was going to ask about! I prefer that!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
…dify the getFiles From Artifact to be more readable.
makes intent of fetching logic clearer
prevent breaking out of the base staging directory
File newfile = new File(env.getStagingPath().toString().concat(artifactPath.toString())); | ||
if (!newfile.createNewFile()) { | ||
throw new IOException("creating file " + newfile.getName() + "failed."); | ||
} | ||
downloadArtifact(artifact, newfile.getPath()); | ||
} | ||
|
||
public boolean isWithinParent(String parent, String child) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isWithinParent("/foo", "/foo2")
will incorrectly return true
here
Luckily for you Path
has its own startsWith
method that handles this in a more correct way. Like this:
Path artifactDirectory = artifactPath.getParent();
if (!artifactDirectory.startsWith(env.getStagingPath()) {
throw new IllegalStateException(...)
}
In general, you usually don't want to convert Path
objects to String
s to manipulate them. Path
has methods to do these operations more safely. So, also (below) try this:
File newFile = env.getStagingPath().resolve(artifactPath).toFile()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦♂ And that's why we do code review 😄 . Thanks @plumpy!
refactors logic around writing files to the local directory based on PR feedback.
moves logic into classes where they make sense. for example, the code for downloading a temp directory structure only makes sense in the context of kustomize.
...s/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java
Outdated
Show resolved
Hide resolved
} | ||
|
||
public Kustomization getKustomization(Artifact artifact, String possibleName) { | ||
Path artifactPath = Paths.get(artifact.getReference()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused by the logic in this function...where is the possibleName
coming from? It looks like we're trying all of the KUSTOMIZATION_FILENAMES
but using an input String as a hint for what to try first?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct. A Kustomization file can have 3 names, .yml
, .yaml
and one w/o an extension. Initially, the file name comes from the artifact that you supply as an input. We're making the assumption that if you use, say kustomization.yml
, you're likely to use kustomization.yml
for your subsequent kustomizations. Since we have no way of knowing that the actual file name is before we try to fetch it we try to be smart and make a best guess before falling back to the other options.
...s/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java
Show resolved
Hide resolved
} | ||
|
||
private Kustomization convert(Artifact artifact) throws IOException { | ||
// TODO(ethanfrogers): figure out how to use safe constructor here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not an expert, but does the current form prevent deserialization code injection? I think that from a security perspective we'll need to solve this before merging (if this version hasn't already solved it).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm honestly not sure. I know SafeConstructor
only lets you deserialize to Java primitives so using it here will cause an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For reference - Yes, using a new Constructor(Kustomization.class)
will prevent code injection according to this.
.../main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeBakeManifestService.java
Outdated
Show resolved
Hide resolved
...ts/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeTemplateUtils.java
Outdated
Show resolved
Hide resolved
...rc/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/mapping/ConfigMapGenerator.java
Outdated
Show resolved
Hide resolved
...sts/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/mapping/Kustomization.java
Outdated
Show resolved
Hide resolved
...st/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy
Show resolved
Hide resolved
...est/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeTemplateUtilsSpec.groovy
Outdated
Show resolved
Hide resolved
rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/controllers/V2BakeryController.java
Outdated
Show resolved
Hide resolved
...ts/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizeTemplateUtils.java
Show resolved
Hide resolved
OK, gave it another pass, mostly looking good. I think from my perspective the main blocker is just ensuring that we're safely deserializing YAML so we don't create any security issues. The logic to download files looks a lot cleaner now. Is there a design document or some artifact describing how this is designed? It thing seeing it at a higher level might make it easier for me to understand the code. I've left a few questions still on parts I don't fully understand. |
@ezimanyi I can certainly document this in a more permanent location but will describe the design here. Generally, a "kustomization" is a folder, in a repo, and is made up of 2 things:
It's also worth pointing out that a Kustomize is typically used in CI and meant to be run against a full repo where all of the file dependencies are checked out alongside the Our solution is to treat each |
refactor bake manifest service to extract the job executor logic into a single method
@ezimanyi I'd love to see this land in 1.16 so LMK if there's anything else we need to do. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just did another pass and added some comments. I spent a bunch of time trying to parse through and understand the logic for downloading all dependencies by parsing the kustomize
files; I think I understand now and can see how it works, though I have to admit I still find the logic reasonably confusing and the amount of string concatenation and matching seems very fragile.
As one example of this being fragile, when I run the tests I see this in the logs:
downloading file https:/api.github.com/repos/org/repo/contents/base/kustomization.yml
It seems like somehow all the path matching logic is dropping one of the slashes from the https://
? It's not clear to me how this would be working at all in that case, unless this is somehow only a test issue. What examples were used to test this logic?
I feel like in retrospect this is something that would have benefited from a design document and more discussion, particularly around the question of how to handle getting all dependencies from a git repo into Spinnaker. A few other solutions I could think of:
- We expect the input artifact to be a
tar
file that already contains the dependencies and all Spinnaker needs to do is un-tar it - We add a more general artifact type that represents not just a single file in
git
but represents a folder structure
I probably would have liked more discussion around what the right solution is; this solution here is reasonable though does have the disadvantage that it's somewhat breaking the artifact abstraction.
I guess overall this seems fine, but I can't say I'm fully confident that all the downloading logic is correct given the complexity and the reasonably low test coverage. I've left a few comments about places where I think there might be edge cases, but I'm sure I didn't catch everything and would feel way more comfortable if the logic were more encapsulated and better tested.
private String extractReferenceBase(Artifact artifact) { | ||
// strip the base reference url to get the full path that the file should be written to | ||
// example: https://api.github.com/repos/org/repo/contents/kustomize.yml == kustomize.yml | ||
return artifact.getReference().replace(artifact.getName(), ""); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we're assuming the file name is artifact.getName()
whereas on line 58 we're calling FilenameUtils.getName(artifact.getReference())
to get the name. Are these guaranteed to return the same result, or will this break in some cases?
* mentoined it's (and subsequent) kustomization file. | ||
*/ | ||
private HashSet<String> getFilesFromArtifact(Artifact artifact) throws IOException { | ||
// referenceBaseURL is the base URL without the artifacts name. works for github and bitbucket |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a big fan of the fact that we're breaking the artifact extraction here; the point of an artifact is that is should be possible to use artifacts of different types interchangably (as long as the actual file type matches). If we start adding logic that only works for some types of artifacts but not others, we're regressing back to the special case-logic that artifacts were intended to avoid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we limited the input artifact types to Github and Bitbucket artifacts? I agree that it isn't ideal but maybe we can use this as an experiment to determine what artifact types people use this with most often. If people use it with more than Github or Bitbucket we can revisit.
} | ||
|
||
private boolean isFolder(String evaluate) { | ||
if (evaluate.contains(".")) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on the tests for this, and from how this is implemented, is this just assuming that any file that does not contain a .
is a folder? It seems like child/kustomize
would return true, but that's a common format for kustomize files. Might people have patch files that don't have extensions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved to just checking for an extension on the file via FilenameUtils.getExtension
.
Path artifactFilePath = env.getStagingPath().resolve(artifactFileName); | ||
// ensure file write doesn't break out of the staging directory ex. ../etc | ||
Path artifactParentDirectory = artifactFilePath.getParent(); | ||
if (!artifactParentDirectory.startsWith(env.getStagingPath())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would feel better if this logic were exercised by a unit test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I'm fairly certain this doesn't work as is; the following will return true
:
Path test = Paths.get("/tmp/mydir");
Path other = Paths.get("/tmp/mydir/../../etc/passwd");
boolean matches = other.startsWith(test);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what we need to do here - we need a call to normalize
before we to the assertion.
@ezimanyi agreed that this isn't least confusing solution out there. We opted for this solution for a couple of reasons:
I'm happy to call this feature "beta" for the 1.16 release. I've already put it behind a feature flag in Deck. Would this solution be reasonable if the logic were hardened/tested more? If so, is this something you'd feel comfortable merging or are we calling this a "no-go"? |
I think the solution is reasonable; while it might have been good to think a bit more broadly, I don't think this is a bad solution, I just wonder a bit about the tradeoffs between the options noted above. That was more of a general comment about wishing we'd have a more open design document as I read the complexity of the implementation, not something I think we need to address/fix here. From a more tactical perspective about this in 1.16, my main concern is just that I can't really convince myself this is correct---I've read it through a few times and am still having trouble understanding exactly what it's doing (or finding enough tests to convince me). At a minimum I'd want to see the few bugs noted in the latest review fixed before merging this...but in particular the one about dropping a slash from all URLs ( |
and improve test coverage
833e9b3
to
56b2484
Compare
* feature(kustomize): add integration for kustomize implements support for kustomize as a templating engine. one of the interesting challenges with kustomize is that it depends on files adjacent to a single kustomization file for it's templating. the kustomization file refers to files surrounding it. these files are collected and layered on top of eachother to produce a single, fully rendered manifest. because of this we've had to implement a special handling of kustomization files. essentially, before we attempt to render a kustomization we parse the file and generate artifact requests for any dependencies. we'll subsequently do this for any dependencies that happen to be kustomization files themselves.
* feature(kustomize): add integration for kustomize implements support for kustomize as a templating engine. one of the interesting challenges with kustomize is that it depends on files adjacent to a single kustomization file for it's templating. the kustomization file refers to files surrounding it. these files are collected and layered on top of eachother to produce a single, fully rendered manifest. because of this we've had to implement a special handling of kustomization files. essentially, before we attempt to render a kustomization we parse the file and generate artifact requests for any dependencies. we'll subsequently do this for any dependencies that happen to be kustomization files themselves.
implements support for kustomize as a templating engine. one of the
interesting challenges with kustomize is that it depends on files
adjacent to a single kustomization file for it's templating. the
kustomization file refers to files surrounding it. these files are
collected and layered on top of eachother to produce a single, fully
rendered manifest. because of this we've had to implement a special
handling of kustomization files. essentially, before we attempt to
render a kustomization we parse the file and generate artifact requests
for any dependencies. we'll subsequently do this for any dependencies
that happen to be kustomization files themselves.