├── .github ├── CODEOWNERS └── FUNDING.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── NOTICE.md ├── README.md ├── check └── main.go ├── check_command.go ├── check_command_test.go ├── go.mod ├── go.sum ├── in └── main.go ├── in_command.go ├── in_command_test.go ├── lord ├── lord_suite_test.go ├── time_lord.go └── time_lord_test.go ├── models ├── models.go ├── models_suite_test.go └── source_test.go ├── offset.go ├── offset_test.go ├── out └── main.go ├── out_command.go ├── out_command_test.go └── resource_suite_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @concourse/maintainers 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [taylorsilva] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | built-* 2 | .envrc 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG base_image=cgr.dev/chainguard/wolfi-base 2 | ARG builder_image=concourse/golang-builder 3 | 4 | ARG BUILDPLATFORM 5 | FROM --platform=${BUILDPLATFORM} ${builder_image} AS builder 6 | 7 | ARG TARGETOS 8 | ARG TARGETARCH 9 | ENV GOOS=$TARGETOS 10 | ENV GOARCH=$TARGETARCH 11 | 12 | WORKDIR /concourse/time-resource 13 | COPY go.mod . 14 | COPY go.sum . 15 | RUN go mod download 16 | COPY . /concourse/time-resource 17 | ENV CGO_ENABLED 0 18 | RUN go build -o /assets/out github.com/concourse/time-resource/out 19 | RUN go build -o /assets/in github.com/concourse/time-resource/in 20 | RUN go build -o /assets/check github.com/concourse/time-resource/check 21 | RUN set -e; for pkg in $(go list ./...); do \ 22 | go test -o "/tests/$(basename $pkg).test" -c $pkg; \ 23 | done 24 | 25 | FROM ${base_image} AS resource 26 | COPY --from=builder /assets /opt/resource 27 | RUN apk --no-cache add tzdata 28 | 29 | FROM resource AS tests 30 | COPY --from=builder /tests /tests 31 | RUN set -e; for test in /tests/*.test; do \ 32 | $test; \ 33 | done 34 | 35 | FROM resource 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | Copyright 2014-2016 Alex Suraci, Chris Brown, and Pivotal Software, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 4 | this file except in compliance with the License. You may obtain a copy of the 5 | License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software distributed 10 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 11 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 12 | specific language governing permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Resource 2 | 3 | Implements a resource that reports new versions on a configured interval. The 4 | interval can be arbitrarily long. 5 | 6 | 7 | Build Status 8 | 9 | 10 | This resource is built to satisfy "trigger this build at least once every 5 11 | minutes," not "trigger this build on the 10th hour of every Sunday." That 12 | level of precision is better left to other tools. 13 | 14 | ## Source Configuration 15 | 16 | * `interval`: *Optional.* The interval on which to report new versions. Valid 17 | units are: “ns”, “us” (or “µs”), “ms”, “s”, “m”, “h”. Examples: `60s`, `90m`, 18 | `1h30m`. If not specified, this resource will generate exactly 1 new version 19 | per calendar day on each of the valid `days`. 20 | 21 | * `location`: *Optional. Default `UTC`.* The 22 | [location](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) in 23 | which to interpret `start`, `stop`, and `days`. 24 | 25 | e.g. 26 | 27 | ``` 28 | location: Africa/Abidjan 29 | ``` 30 | 31 | * `start` and `stop`: *Optional.* Limit the creation of new versions to times 32 | on/after `start` and before `stop`. The supported formats for the times are: 33 | `3:04 PM`, `3PM`, `3PM`, `15:04`, and `1504`. If a `start` is specified, a 34 | `stop` must also be specified, and vice versa. If neither value is specified, 35 | both values will default to `00:00` and this resource can generate a new 36 | version (based on `interval`) at any time of day. 37 | 38 | e.g. 39 | 40 | ``` 41 | start: 8:00 PM 42 | stop: 9:00 PM 43 | ``` 44 | 45 | **Deprecation: an offset may be appended, e.g. `+0700` or `-0400`, but you 46 | should use `location` instead.** 47 | 48 | To explicitly represent a full calendar day, set `start` and `stop` to 49 | the same value. 50 | 51 | e.g. 52 | 53 | ``` 54 | start: 6:00 AM 55 | stop: 6:00 AM 56 | ``` 57 | 58 | **Note: YAML parsers like PyYAML may parse time values in the 24h format as integers, not strings (e.g. `19:00` is parsed as `1140`). If you pre-process your pipeline configuration with such a parser this might trigger a marshaling error. In that case you can quote your `start` and `stop` values, so they will be correctly treated as string.** 59 | 60 | * `days`: *Optional.* Limit the creation of new time versions to the specified 61 | day(s). Supported days are: `Sunday`, `Monday`, `Tuesday`, `Wednesday`, 62 | `Thursday`, `Friday` and `Saturday`. 63 | 64 | e.g. 65 | 66 | ``` 67 | days: [Monday, Wednesday] 68 | ``` 69 | 70 | These can be combined to emit a new version on an interval during a particular 71 | time period. 72 | 73 | * `initial_version`: *Optional.* When using `start` and `stop` as a trigger for 74 | a job, you will be unable to run the job manually until it goes into the 75 | configured time range for the first time (manual runs will work once the `time` 76 | resource has produced it's first version). 77 | 78 | To get around this issue, there are two approaches: 79 | * Use `initial_version: true`, which will produce a new version that is 80 | set to the current time, if `check` runs and there isn't already a version 81 | specified. **NOTE: This has a downside that if used with `trigger: true`, it will 82 | kick off the correlating job when the pipeline is first created, even 83 | outside of the specified window**. 84 | * Alternatively, once you push a pipeline that utilizes `start` and `stop`, run the 85 | following fly command to run the resource check from a previous point 86 | in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764) 87 | for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742) 88 | for older Concourse versions). 89 | 90 | ``` 91 | fly -t \ 92 | check-resource --resource / 93 | --from "time:2000-01-01T00:00:00Z" # the important part 94 | ``` 95 | 96 | This has the benefit that it shouldn't trigger that initial job run, but 97 | will still allow you to manually run the job if needed. 98 | 99 | e.g. 100 | 101 | ``` 102 | initial_version: true 103 | ``` 104 | 105 | ## Behavior 106 | 107 | ### `check`: Produce timestamps satisfying the interval. 108 | 109 | Returns current version and new version only if it has been longer than `interval` since the 110 | given version, or if there is no version given. 111 | 112 | 113 | ### `in`: Report the given time. 114 | 115 | Fetches the given timestamp. Creates three files: 116 | 1. `input` which contains the request provided by Concourse 117 | 1. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST` 118 | 1. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only) 119 | 120 | #### Parameters 121 | 122 | *None.* 123 | 124 | 125 | ### `out`: Produce the current time. 126 | 127 | Returns a version for the current timestamp. This can be used to record the 128 | time within a build plan, e.g. after running some long-running task. 129 | 130 | #### Parameters 131 | 132 | *None.* 133 | 134 | 135 | ## Examples 136 | 137 | ### Periodic trigger 138 | 139 | ```yaml 140 | resources: 141 | - name: 5m 142 | type: time 143 | source: {interval: 5m} 144 | 145 | jobs: 146 | - name: something-every-5m 147 | plan: 148 | - get: 5m 149 | trigger: true 150 | - task: something 151 | config: # ... 152 | ``` 153 | 154 | ### Trigger once within time range 155 | 156 | ```yaml 157 | resources: 158 | - name: after-midnight 159 | type: time 160 | source: 161 | start: 12:00 AM 162 | stop: 1:00 AM 163 | location: Asia/Sakhalin 164 | 165 | jobs: 166 | - name: something-after-midnight 167 | plan: 168 | - get: after-midnight 169 | trigger: true 170 | - task: something 171 | config: # ... 172 | ``` 173 | 174 | ### Trigger on an interval within time range 175 | 176 | ```yaml 177 | resources: 178 | - name: 5m-during-midnight-hour 179 | type: time 180 | source: 181 | interval: 5m 182 | start: 12:00 AM 183 | stop: 1:00 AM 184 | location: America/Bahia_Banderas 185 | 186 | jobs: 187 | - name: something-every-5m-during-midnight-hour 188 | plan: 189 | - get: 5m-during-midnight-hour 190 | trigger: true 191 | - task: something 192 | config: # ... 193 | ``` 194 | 195 | ## Development 196 | 197 | ### Prerequisites 198 | 199 | * golang is *required* - version 1.9.x is tested; earlier versions may also 200 | work. 201 | * docker is *required* - version 17.06.x is tested; earlier versions may also 202 | work. 203 | * go mod is used for dependency management of the golang packages. 204 | 205 | ### Running the tests 206 | 207 | The tests have been embedded with the `Dockerfile`; ensuring that the testing 208 | environment is consistent across any `docker` enabled platform. When the docker 209 | image builds, the test are run inside the docker container, on failure they 210 | will stop the build. 211 | 212 | Run the tests with the following command: 213 | 214 | ```sh 215 | docker build -t time-resource --target tests . 216 | ``` 217 | 218 | ### Contributing 219 | 220 | Please make all pull requests to the `master` branch and ensure tests pass 221 | locally. 222 | -------------------------------------------------------------------------------- /check/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | resource "github.com/concourse/time-resource" 9 | "github.com/concourse/time-resource/models" 10 | ) 11 | 12 | func main() { 13 | var request models.CheckRequest 14 | 15 | err := json.NewDecoder(os.Stdin).Decode(&request) 16 | if err != nil { 17 | fmt.Fprintln(os.Stderr, "parse error:", err.Error()) 18 | os.Exit(1) 19 | } 20 | 21 | command := resource.CheckCommand{} 22 | 23 | versions, err := command.Run(request) 24 | if err != nil { 25 | fmt.Fprintln(os.Stderr, "running command:", err.Error()) 26 | os.Exit(1) 27 | } 28 | 29 | json.NewEncoder(os.Stdout).Encode(versions) 30 | } 31 | -------------------------------------------------------------------------------- /check_command.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/concourse/time-resource/lord" 7 | "github.com/concourse/time-resource/models" 8 | ) 9 | 10 | type CheckCommand struct { 11 | } 12 | 13 | func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) { 14 | err := request.Source.Validate() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | previousTime := request.Version.Time 20 | currentTime := time.Now().UTC() 21 | 22 | specifiedLocation := request.Source.Location 23 | if specifiedLocation != nil { 24 | currentTime = currentTime.In((*time.Location)(specifiedLocation)) 25 | } 26 | 27 | tl := lord.TimeLord{ 28 | PreviousTime: previousTime, 29 | Location: specifiedLocation, 30 | Start: request.Source.Start, 31 | Stop: request.Source.Stop, 32 | Interval: request.Source.Interval, 33 | Days: request.Source.Days, 34 | } 35 | 36 | var versions []models.Version 37 | 38 | if !previousTime.IsZero() { 39 | versions = append(versions, models.Version{Time: previousTime}) 40 | } else if request.Source.InitialVersion { 41 | versions = append(versions, models.Version{Time: currentTime}) 42 | return versions, nil 43 | } 44 | 45 | if tl.Check(currentTime) { 46 | versions = append(versions, models.Version{Time: currentTime}) 47 | } 48 | 49 | return versions, nil 50 | } 51 | -------------------------------------------------------------------------------- /check_command_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "time" 5 | 6 | resource "github.com/concourse/time-resource" 7 | "github.com/concourse/time-resource/models" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Check", func() { 14 | var ( 15 | now time.Time 16 | ) 17 | 18 | BeforeEach(func() { 19 | now = time.Now().UTC() 20 | }) 21 | 22 | Context("when executed", func() { 23 | var source models.Source 24 | var version models.Version 25 | var response models.CheckResponse 26 | 27 | BeforeEach(func() { 28 | source = models.Source{} 29 | version = models.Version{} 30 | response = models.CheckResponse{} 31 | }) 32 | 33 | JustBeforeEach(func() { 34 | command := resource.CheckCommand{} 35 | 36 | var err error 37 | response, err = command.Run(models.CheckRequest{ 38 | Source: source, 39 | Version: version, 40 | }) 41 | Expect(err).NotTo(HaveOccurred()) 42 | }) 43 | 44 | Context("when nothing is specified", func() { 45 | Context("when no version is given", func() { 46 | It("outputs a version containing the current time", func() { 47 | Expect(response).To(HaveLen(1)) 48 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 49 | }) 50 | }) 51 | 52 | Context("when a version is given", func() { 53 | var prev time.Time 54 | 55 | Context("when the resource has already triggered on the current day", func() { 56 | BeforeEach(func() { 57 | prev = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location()) 58 | version.Time = prev 59 | }) 60 | 61 | It("outputs a supplied version", func() { 62 | Expect(response).To(HaveLen(1)) 63 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 64 | }) 65 | }) 66 | 67 | Context("when the resource was triggered yesterday", func() { 68 | BeforeEach(func() { 69 | prev = now.Add(-24 * time.Hour) 70 | version.Time = prev 71 | }) 72 | 73 | It("outputs a version containing the current time and supplied version", func() { 74 | Expect(response).To(HaveLen(2)) 75 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 76 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 77 | }) 78 | }) 79 | }) 80 | }) 81 | 82 | Context("when a time range is specified", func() { 83 | Context("when we are in the specified time range", func() { 84 | BeforeEach(func() { 85 | start := now.Add(-1 * time.Hour) 86 | stop := now.Add(1 * time.Hour) 87 | 88 | source.Start = tod(start.Hour(), start.Minute(), 0) 89 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 90 | }) 91 | 92 | Context("when no version is given", func() { 93 | It("outputs a version containing the current time", func() { 94 | Expect(response).To(HaveLen(1)) 95 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 96 | }) 97 | }) 98 | 99 | Context("when a version is given", func() { 100 | var prev time.Time 101 | 102 | Context("when the resource has already triggered with in the current time range", func() { 103 | BeforeEach(func() { 104 | prev = now.Add(-30 * time.Minute) 105 | version.Time = prev 106 | }) 107 | 108 | It("outputs a supplied version", func() { 109 | Expect(response).To(HaveLen(1)) 110 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 111 | }) 112 | }) 113 | 114 | Context("when the resource was triggered yesterday near the end of the time frame", func() { 115 | BeforeEach(func() { 116 | prev = now.Add(-23 * time.Hour) 117 | version.Time = prev 118 | }) 119 | 120 | It("outputs a version containing the current time and supplied version", func() { 121 | Expect(response).To(HaveLen(2)) 122 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 123 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 124 | }) 125 | }) 126 | 127 | Context("when the resource was triggered last year near the end of the time frame", func() { 128 | BeforeEach(func() { 129 | prev = now.AddDate(-1, 0, 0) 130 | version.Time = prev 131 | }) 132 | 133 | It("outputs a version containing the current time and supplied version", func() { 134 | Expect(response).To(HaveLen(2)) 135 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 136 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 137 | }) 138 | }) 139 | 140 | Context("when the resource was triggered yesterday in the current time frame", func() { 141 | BeforeEach(func() { 142 | prev = now.Add(-24 * time.Hour) 143 | version.Time = prev 144 | }) 145 | 146 | It("outputs a version containing the current time and supplied version", func() { 147 | Expect(response).To(HaveLen(2)) 148 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) 149 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 150 | }) 151 | }) 152 | }) 153 | 154 | Context("when an interval is specified", func() { 155 | BeforeEach(func() { 156 | interval := models.Interval(time.Minute) 157 | source.Interval = &interval 158 | }) 159 | 160 | Context("when no version is given", func() { 161 | It("outputs a version containing the current time", func() { 162 | Expect(response).To(HaveLen(1)) 163 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 164 | }) 165 | }) 166 | 167 | Context("when a version is given", func() { 168 | var prev time.Time 169 | 170 | Context("when the interval has not elapsed", func() { 171 | BeforeEach(func() { 172 | prev = now 173 | version.Time = prev 174 | }) 175 | 176 | It("outputs only the supplied version", func() { 177 | Expect(response).To(HaveLen(1)) 178 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 179 | }) 180 | }) 181 | 182 | Context("when the interval has elapsed", func() { 183 | BeforeEach(func() { 184 | prev = now.Add(-1 * time.Minute) 185 | version.Time = prev 186 | }) 187 | 188 | It("outputs a version containing the current time and supplied version", func() { 189 | Expect(response).To(HaveLen(2)) 190 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 191 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 192 | }) 193 | }) 194 | 195 | Context("with its time N intervals ago", func() { 196 | BeforeEach(func() { 197 | prev = now.Add(-5 * time.Minute) 198 | version.Time = prev 199 | }) 200 | 201 | It("outputs a version containing the current time and supplied version", func() { 202 | Expect(response).To(HaveLen(2)) 203 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 204 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 205 | }) 206 | }) 207 | }) 208 | }) 209 | 210 | Context("when the current day is specified", func() { 211 | BeforeEach(func() { 212 | source.Days = []models.Weekday{ 213 | models.Weekday(now.Weekday()), 214 | models.Weekday(now.AddDate(0, 0, 2).Weekday()), 215 | } 216 | }) 217 | 218 | It("outputs a version containing the current time", func() { 219 | Expect(response).To(HaveLen(1)) 220 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 221 | }) 222 | }) 223 | 224 | Context("when we are out of the specified day", func() { 225 | BeforeEach(func() { 226 | source.Days = []models.Weekday{ 227 | models.Weekday(now.AddDate(0, 0, 1).Weekday()), 228 | models.Weekday(now.AddDate(0, 0, 2).Weekday()), 229 | } 230 | }) 231 | 232 | It("does not output any versions", func() { 233 | Expect(response).To(BeEmpty()) 234 | }) 235 | }) 236 | }) 237 | 238 | Context("when we are not within the specified time range", func() { 239 | BeforeEach(func() { 240 | start := now.Add(6 * time.Hour) 241 | stop := now.Add(7 * time.Hour) 242 | 243 | source.Start = tod(start.Hour(), start.Minute(), 0) 244 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 245 | }) 246 | 247 | Context("when no version is given", func() { 248 | It("does not output any versions", func() { 249 | Expect(response).To(BeEmpty()) 250 | }) 251 | }) 252 | 253 | Context("when an interval is given", func() { 254 | BeforeEach(func() { 255 | start := now.Add(6 * time.Hour) 256 | stop := now.Add(7 * time.Hour) 257 | 258 | source.Start = tod(start.Hour(), start.Minute(), 0) 259 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 260 | 261 | interval := models.Interval(time.Minute) 262 | source.Interval = &interval 263 | }) 264 | 265 | It("does not output any versions", func() { 266 | Expect(response).To(BeEmpty()) 267 | }) 268 | }) 269 | }) 270 | 271 | Context("with a location configured", func() { 272 | var loc *time.Location 273 | 274 | BeforeEach(func() { 275 | var err error 276 | loc, err = time.LoadLocation("America/Indiana/Indianapolis") 277 | Expect(err).ToNot(HaveOccurred()) 278 | 279 | srcLoc := models.Location(*loc) 280 | source.Location = &srcLoc 281 | 282 | now = now.In(loc) 283 | }) 284 | 285 | Context("when we are in the specified time range", func() { 286 | BeforeEach(func() { 287 | start := now.Add(-1 * time.Hour) 288 | stop := now.Add(1 * time.Hour) 289 | 290 | source.Start = tod(start.Hour(), start.Minute(), 0) 291 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 292 | }) 293 | 294 | Context("when no version is given", func() { 295 | It("outputs a version containing the current time", func() { 296 | Expect(response).To(HaveLen(1)) 297 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 298 | }) 299 | }) 300 | 301 | Context("when a version is given", func() { 302 | var prev time.Time 303 | 304 | Context("when the resource has already triggered with in the current time range", func() { 305 | BeforeEach(func() { 306 | prev = now.Add(-30 * time.Minute) 307 | version.Time = prev 308 | }) 309 | 310 | It("outputs a supplied version", func() { 311 | Expect(response).To(HaveLen(1)) 312 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 313 | }) 314 | }) 315 | 316 | Context("when the resource was triggered yesterday near the end of the time frame", func() { 317 | BeforeEach(func() { 318 | prev = now.Add(-23 * time.Hour) 319 | version.Time = prev 320 | }) 321 | 322 | It("outputs a version containing the current time and supplied version", func() { 323 | Expect(response).To(HaveLen(2)) 324 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 325 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 326 | }) 327 | }) 328 | 329 | Context("when the resource was triggered yesterday in the current time frame", func() { 330 | BeforeEach(func() { 331 | prev = now.AddDate(0, 0, -1) 332 | version.Time = prev 333 | }) 334 | 335 | It("outputs a version containing the current time and supplied version", func() { 336 | Expect(response).To(HaveLen(2)) 337 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 338 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 339 | }) 340 | }) 341 | }) 342 | 343 | Context("when an interval is specified", func() { 344 | BeforeEach(func() { 345 | interval := models.Interval(time.Minute) 346 | source.Interval = &interval 347 | }) 348 | 349 | Context("when no version is given", func() { 350 | It("outputs a version containing the current time", func() { 351 | Expect(response).To(HaveLen(1)) 352 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 353 | }) 354 | }) 355 | 356 | Context("when a version is given", func() { 357 | var prev time.Time 358 | 359 | Context("with its time within the interval", func() { 360 | BeforeEach(func() { 361 | prev = now 362 | version.Time = prev 363 | }) 364 | 365 | It("outputs the given version", func() { 366 | Expect(response).To(HaveLen(1)) 367 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 368 | }) 369 | }) 370 | 371 | Context("with its time one interval ago", func() { 372 | BeforeEach(func() { 373 | prev = now.Add(-1 * time.Minute) 374 | version.Time = prev 375 | }) 376 | 377 | It("outputs a version containing the current time and supplied version", func() { 378 | Expect(response).To(HaveLen(2)) 379 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 380 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 381 | }) 382 | }) 383 | 384 | Context("with its time N intervals ago", func() { 385 | BeforeEach(func() { 386 | prev = now.Add(-5 * time.Minute) 387 | version.Time = prev 388 | }) 389 | 390 | It("outputs a version containing the current time and supplied version", func() { 391 | Expect(response).To(HaveLen(2)) 392 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 393 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 394 | }) 395 | }) 396 | }) 397 | }) 398 | 399 | Context("when the current day is specified", func() { 400 | BeforeEach(func() { 401 | source.Days = []models.Weekday{ 402 | models.Weekday(now.Weekday()), 403 | models.Weekday(now.AddDate(0, 0, 2).Weekday()), 404 | } 405 | }) 406 | 407 | It("outputs a version containing the current time", func() { 408 | Expect(response).To(HaveLen(1)) 409 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 410 | }) 411 | }) 412 | 413 | Context("when we are out of the specified day", func() { 414 | BeforeEach(func() { 415 | source.Days = []models.Weekday{ 416 | models.Weekday(now.AddDate(0, 0, 1).Weekday()), 417 | models.Weekday(now.AddDate(0, 0, 2).Weekday()), 418 | } 419 | }) 420 | 421 | It("does not output any versions", func() { 422 | Expect(response).To(BeEmpty()) 423 | }) 424 | }) 425 | }) 426 | 427 | Context("when we are not within the specified time range", func() { 428 | BeforeEach(func() { 429 | start := now.Add(6 * time.Hour) 430 | stop := now.Add(7 * time.Hour) 431 | 432 | source.Start = tod(start.Hour(), start.Minute(), 0) 433 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 434 | }) 435 | 436 | Context("when no version is given", func() { 437 | It("does not output any versions", func() { 438 | Expect(response).To(BeEmpty()) 439 | }) 440 | }) 441 | 442 | Context("when an interval is given", func() { 443 | BeforeEach(func() { 444 | start := now.Add(6 * time.Hour) 445 | stop := now.Add(7 * time.Hour) 446 | 447 | source.Start = tod(start.Hour(), start.Minute(), 0) 448 | source.Stop = tod(stop.Hour(), stop.Minute(), 0) 449 | 450 | interval := models.Interval(time.Minute) 451 | source.Interval = &interval 452 | }) 453 | 454 | It("does not output any versions", func() { 455 | Expect(response).To(BeEmpty()) 456 | }) 457 | }) 458 | }) 459 | }) 460 | }) 461 | 462 | Context("when an interval is specified", func() { 463 | BeforeEach(func() { 464 | interval := models.Interval(time.Minute) 465 | source.Interval = &interval 466 | }) 467 | 468 | Context("when no version is given", func() { 469 | It("outputs a version containing the current time", func() { 470 | Expect(response).To(HaveLen(1)) 471 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 472 | }) 473 | }) 474 | 475 | Context("when a version is given", func() { 476 | var prev time.Time 477 | 478 | Context("with its time within the interval", func() { 479 | BeforeEach(func() { 480 | prev = now 481 | version.Time = prev 482 | }) 483 | 484 | It("outputs a supplied version", func() { 485 | Expect(response).To(HaveLen(1)) 486 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 487 | }) 488 | }) 489 | 490 | Context("with its time one interval ago", func() { 491 | BeforeEach(func() { 492 | prev = now.Add(-1 * time.Minute) 493 | version.Time = prev 494 | }) 495 | 496 | It("outputs a version containing the current time and supplied version", func() { 497 | Expect(response).To(HaveLen(2)) 498 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 499 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 500 | }) 501 | }) 502 | 503 | Context("with its time N intervals ago", func() { 504 | BeforeEach(func() { 505 | prev = now.Add(-5 * time.Minute) 506 | version.Time = prev 507 | }) 508 | 509 | It("outputs a version containing the current time and supplied version", func() { 510 | Expect(response).To(HaveLen(2)) 511 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) 512 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 513 | }) 514 | }) 515 | }) 516 | }) 517 | }) 518 | }) 519 | 520 | func tod(hours, minutes, offset int) *models.TimeOfDay { 521 | loc := time.UTC 522 | if offset != 0 { 523 | loc = time.FixedZone("UnitTest", 60*60*offset) 524 | } 525 | 526 | now := time.Now() 527 | tod := models.NewTimeOfDay(time.Date(now.Year(), now.Month(), now.Day(), hours, minutes, 0, 0, loc)) 528 | 529 | return &tod 530 | } 531 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/concourse/time-resource 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/onsi/ginkgo/v2 v2.23.0 7 | github.com/onsi/gomega v1.36.2 8 | ) 9 | 10 | require ( 11 | github.com/go-logr/logr v1.4.2 // indirect 12 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 13 | github.com/google/go-cmp v0.6.0 // indirect 14 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 15 | golang.org/x/net v0.38.0 // indirect 16 | golang.org/x/sys v0.31.0 // indirect 17 | golang.org/x/text v0.23.0 // indirect 18 | golang.org/x/tools v0.30.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 4 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 5 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 6 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 10 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 11 | github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ= 12 | github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= 13 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 14 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 19 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 20 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 21 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 22 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 23 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 24 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 25 | golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= 26 | golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= 27 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 28 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /in/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | resource "github.com/concourse/time-resource" 9 | "github.com/concourse/time-resource/models" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) < 2 { 14 | println("usage: " + os.Args[0] + " ") 15 | os.Exit(1) 16 | } 17 | 18 | destination := os.Args[1] 19 | 20 | var request models.InRequest 21 | 22 | err := json.NewDecoder(os.Stdin).Decode(&request) 23 | if err != nil { 24 | fmt.Fprintln(os.Stderr, "parse error:", err.Error()) 25 | os.Exit(1) 26 | } 27 | 28 | command := resource.InCommand{} 29 | 30 | response, err := command.Run(destination, request) 31 | if err != nil { 32 | fmt.Fprintln(os.Stderr, "running command:", err.Error()) 33 | os.Exit(1) 34 | } 35 | 36 | json.NewEncoder(os.Stdout).Encode(response) 37 | } 38 | -------------------------------------------------------------------------------- /in_command.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/concourse/time-resource/models" 12 | ) 13 | 14 | type InCommand struct { 15 | } 16 | 17 | func (*InCommand) Run(destination string, request models.InRequest) (models.InResponse, error) { 18 | err := os.MkdirAll(destination, 0755) 19 | if err != nil { 20 | return models.InResponse{}, fmt.Errorf("creating destination: %w", err) 21 | } 22 | 23 | file, err := os.Create(filepath.Join(destination, "input")) 24 | if err != nil { 25 | return models.InResponse{}, fmt.Errorf("creating input file: %w", err) 26 | } 27 | defer file.Close() 28 | 29 | err = json.NewEncoder(file).Encode(request) 30 | if err != nil { 31 | return models.InResponse{}, fmt.Errorf("writing input file: %w", err) 32 | } 33 | 34 | versionTime := request.Version.Time 35 | if versionTime.IsZero() { 36 | versionTime = time.Now() 37 | } 38 | 39 | timeFile, err := os.Create(filepath.Join(destination, "timestamp")) 40 | if err != nil { 41 | return models.InResponse{}, fmt.Errorf("creating timestamp file: %w", err) 42 | } 43 | defer timeFile.Close() 44 | 45 | _, err = timeFile.WriteString(versionTime.Format("2006-01-02 15:04:05.999999999 -0700 MST")) 46 | if err != nil { 47 | return models.InResponse{}, fmt.Errorf("writing timestamp file: %w", err) 48 | } 49 | 50 | epochFile, err := os.Create(filepath.Join(destination, "epoch")) 51 | if err != nil { 52 | return models.InResponse{}, fmt.Errorf("creating epoch file: %w", err) 53 | } 54 | defer epochFile.Close() 55 | _, err = epochFile.WriteString(strconv.FormatInt(versionTime.Unix(), 10)) 56 | if err != nil { 57 | return models.InResponse{}, fmt.Errorf("writing epoch file: %w", err) 58 | } 59 | 60 | inVersion := models.Version{Time: versionTime} 61 | response := models.InResponse{Version: inVersion} 62 | 63 | return response, nil 64 | } 65 | -------------------------------------------------------------------------------- /in_command_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strconv" 9 | "time" 10 | 11 | resource "github.com/concourse/time-resource" 12 | "github.com/concourse/time-resource/models" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("In", func() { 18 | var ( 19 | tmpdir string 20 | destination string 21 | 22 | source models.Source 23 | version models.Version 24 | response models.InResponse 25 | 26 | err error 27 | ) 28 | 29 | BeforeEach(func() { 30 | tmpdir, err = os.MkdirTemp("", "in-destination") 31 | Expect(err).NotTo(HaveOccurred()) 32 | 33 | destination = path.Join(tmpdir, "in-dir") 34 | 35 | version = models.Version{Time: time.Now()} 36 | 37 | interval := models.Interval(time.Second) 38 | source = models.Source{Interval: &interval} 39 | 40 | response = models.InResponse{} 41 | }) 42 | 43 | JustBeforeEach(func() { 44 | command := resource.InCommand{} 45 | response, err = command.Run(destination, models.InRequest{ 46 | Source: source, 47 | Version: version, 48 | }) 49 | }) 50 | 51 | AfterEach(func() { 52 | os.RemoveAll(tmpdir) 53 | }) 54 | 55 | Context("when executed", func() { 56 | 57 | BeforeEach(func() { 58 | Expect(err).NotTo(HaveOccurred()) 59 | }) 60 | 61 | It("reports the version's time as the version", func() { 62 | Expect(response.Version.Time.UnixNano()).To(Equal(version.Time.UnixNano())) 63 | }) 64 | 65 | It("writes the requested version and source to the destination", func() { 66 | input, err := os.Open(filepath.Join(destination, "input")) 67 | Expect(err).NotTo(HaveOccurred()) 68 | 69 | var requested models.InRequest 70 | err = json.NewDecoder(input).Decode(&requested) 71 | Expect(err).NotTo(HaveOccurred()) 72 | 73 | Expect(requested.Version.Time.Unix()).To(Equal(version.Time.Unix())) 74 | Expect(requested.Source).To(Equal(source)) 75 | }) 76 | 77 | It("writes the requested version to the destination", func() { 78 | input, err := os.ReadFile(filepath.Join(destination, "timestamp")) 79 | Expect(err).NotTo(HaveOccurred()) 80 | 81 | epoch, err := os.ReadFile(filepath.Join(destination, "epoch")) 82 | Expect(err).NotTo(HaveOccurred()) 83 | epochi, err := strconv.Atoi(string(epoch)) 84 | Expect(err).NotTo(HaveOccurred()) 85 | 86 | givenTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(input)) 87 | Expect(err).NotTo(HaveOccurred()) 88 | Expect(givenTime.Unix()).To(Equal(version.Time.Unix())) 89 | Expect(givenTime.Unix()).To(Equal(int64(epochi))) 90 | }) 91 | 92 | Context("when the request has no time in its version", func() { 93 | BeforeEach(func() { 94 | version = models.Version{} 95 | }) 96 | 97 | It("reports the current time as the version", func() { 98 | Expect(response.Version.Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) 99 | }) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /lord/lord_suite_test.go: -------------------------------------------------------------------------------- 1 | package lord_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestLord(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Lord Suite") 13 | } 14 | -------------------------------------------------------------------------------- /lord/time_lord.go: -------------------------------------------------------------------------------- 1 | package lord 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/concourse/time-resource/models" 7 | ) 8 | 9 | var DEFAULT_TIME_OF_DAY = models.TimeOfDay(time.Duration(0)) 10 | 11 | type TimeLord struct { 12 | PreviousTime time.Time 13 | Location *models.Location 14 | Start *models.TimeOfDay 15 | Stop *models.TimeOfDay 16 | Interval *models.Interval 17 | Days []models.Weekday 18 | } 19 | 20 | func (tl TimeLord) Check(now time.Time) bool { 21 | 22 | start, stop := tl.LatestRangeBefore(now) 23 | 24 | if !tl.daysMatch(now) { 25 | return false 26 | } 27 | 28 | if !start.IsZero() && (now.Before(start) || !now.Before(stop)) { 29 | return false 30 | } 31 | 32 | if tl.PreviousTime.IsZero() { 33 | return true 34 | } 35 | 36 | if tl.Interval != nil { 37 | if now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval) { 38 | return true 39 | } 40 | } else if !start.IsZero() { 41 | return tl.PreviousTime.Before(start) 42 | } 43 | 44 | return false 45 | } 46 | 47 | func (tl TimeLord) Latest(reference time.Time) time.Time { 48 | if tl.PreviousTime.After(reference) { 49 | return time.Time{} 50 | } 51 | 52 | refInLoc := reference.In(tl.loc()) 53 | for !tl.daysMatch(refInLoc) { 54 | refInLoc = refInLoc.AddDate(0, 0, -1) 55 | } 56 | 57 | start, stop := tl.LatestRangeBefore(refInLoc) 58 | 59 | if tl.PreviousTime.IsZero() && !reference.Before(stop) { 60 | return time.Time{} 61 | } 62 | 63 | if tl.Interval == nil { 64 | if tl.PreviousTime.After(start) { 65 | return time.Time{} 66 | } 67 | return start 68 | } 69 | 70 | tlDuration := time.Duration(*tl.Interval) 71 | 72 | var latestValidTime time.Time 73 | for intervalTime := start; !intervalTime.After(reference) && intervalTime.Before(stop); intervalTime = intervalTime.Add(tlDuration) { 74 | latestValidTime = intervalTime 75 | } 76 | return latestValidTime 77 | } 78 | 79 | func (tl TimeLord) List(reference time.Time) []time.Time { 80 | start := tl.PreviousTime 81 | 82 | var addForRange func(time.Time, time.Time) 83 | versions := []time.Time{} 84 | 85 | if tl.Interval == nil { 86 | 87 | if start.IsZero() { 88 | refRangeStart, refRangeEnd := tl.LatestRangeBefore(reference) 89 | if !reference.Before(refRangeEnd) { 90 | return versions 91 | } 92 | start = refRangeStart 93 | } 94 | 95 | addForRange = func(dailyStart, _ time.Time) { 96 | if !dailyStart.Before(start) && !dailyStart.After(reference) { 97 | versions = append(versions, dailyStart) 98 | } 99 | } 100 | 101 | } else { 102 | tlDuration := time.Duration(*tl.Interval) 103 | 104 | if start.IsZero() { 105 | start = reference 106 | } 107 | 108 | addForRange = func(dailyStart, dailyEnd time.Time) { 109 | intervalTime := dailyStart.Truncate(tlDuration) 110 | 111 | for intervalTime.Before(dailyStart) || intervalTime.Before(start) { 112 | intervalTime = intervalTime.Add(tlDuration) 113 | } 114 | 115 | for !intervalTime.After(reference) && intervalTime.Before(dailyEnd) { 116 | versions = append(versions, intervalTime) 117 | intervalTime = intervalTime.Add(tlDuration) 118 | } 119 | } 120 | } 121 | 122 | var dailyStart, dailyEnd time.Time 123 | for dailyInterval := start; !dailyStart.After(reference); dailyInterval = dailyInterval.AddDate(0, 0, 1) { 124 | if tl.daysMatch(dailyInterval) { 125 | dailyStart, dailyEnd = tl.LatestRangeBefore(dailyInterval) 126 | if dailyStart.After(reference) { 127 | break 128 | } 129 | addForRange(dailyStart, dailyEnd) 130 | } 131 | } 132 | return versions 133 | } 134 | 135 | func (tl TimeLord) daysMatch(now time.Time) bool { 136 | if len(tl.Days) == 0 { 137 | return true 138 | } 139 | 140 | todayInLoc := models.Weekday(now.In(tl.loc()).Weekday()) 141 | 142 | for _, day := range tl.Days { 143 | if day == todayInLoc { 144 | return true 145 | } 146 | } 147 | 148 | return false 149 | } 150 | 151 | func (tl TimeLord) LatestRangeBefore(reference time.Time) (time.Time, time.Time) { 152 | 153 | tlStart := DEFAULT_TIME_OF_DAY 154 | if tl.Start != nil { 155 | tlStart = *tl.Start 156 | } 157 | tlStop := DEFAULT_TIME_OF_DAY 158 | if tl.Stop != nil { 159 | tlStop = *tl.Stop 160 | } 161 | 162 | refInLoc := reference.In(tl.loc()) 163 | 164 | start := time.Date(refInLoc.Year(), refInLoc.Month(), refInLoc.Day(), 165 | tlStart.Hour(), tlStart.Minute(), 0, 0, tl.loc()) 166 | 167 | if start.After(refInLoc) { 168 | start = start.AddDate(0, 0, -1) 169 | } 170 | 171 | stop := time.Date(start.Year(), start.Month(), start.Day(), 172 | tlStop.Hour(), tlStop.Minute(), 0, 0, tl.loc()) 173 | 174 | if !stop.After(start) { 175 | stop = stop.AddDate(0, 0, 1) 176 | } 177 | 178 | return start, stop 179 | } 180 | 181 | func (tl TimeLord) loc() *time.Location { 182 | if tl.Location != nil { 183 | return (*time.Location)(tl.Location) 184 | } 185 | 186 | return time.UTC 187 | } 188 | -------------------------------------------------------------------------------- /lord/time_lord_test.go: -------------------------------------------------------------------------------- 1 | package lord_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "github.com/concourse/time-resource/lord" 10 | "github.com/concourse/time-resource/models" 11 | ) 12 | 13 | type expectedTime struct { 14 | isZero bool 15 | hour int 16 | minute int 17 | weekday time.Weekday 18 | } 19 | 20 | type testCase struct { 21 | interval string 22 | 23 | location string 24 | 25 | start string 26 | stop string 27 | 28 | days []time.Weekday 29 | 30 | prev string 31 | prevDay time.Weekday 32 | 33 | now string 34 | extraTime time.Duration 35 | nowDay time.Weekday 36 | 37 | result bool 38 | latest expectedTime 39 | list []expectedTime 40 | } 41 | 42 | const exampleFormatWithTZ = "3:04 PM -0700 2006" 43 | const exampleFormatWithoutTZ = "3:04 PM 2006" 44 | 45 | func (tc testCase) Run() { 46 | var tl lord.TimeLord 47 | 48 | if tc.location != "" { 49 | loc, err := time.LoadLocation(tc.location) 50 | Expect(err).NotTo(HaveOccurred()) 51 | 52 | tl.Location = (*models.Location)(loc) 53 | } 54 | 55 | var format string 56 | if tl.Location != nil { 57 | format = exampleFormatWithoutTZ 58 | } else { 59 | format = exampleFormatWithTZ 60 | } 61 | 62 | if tc.start != "" { 63 | tc.start += " 2018" 64 | startTime, err := time.Parse(format, tc.start) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | start := models.NewTimeOfDay(startTime.UTC()) 68 | tl.Start = &start 69 | } 70 | 71 | if tc.stop != "" { 72 | tc.stop += " 2018" 73 | stopTime, err := time.Parse(format, tc.stop) 74 | Expect(err).NotTo(HaveOccurred()) 75 | 76 | stop := models.NewTimeOfDay(stopTime.UTC()) 77 | tl.Stop = &stop 78 | } 79 | 80 | if tc.interval != "" { 81 | interval, err := time.ParseDuration(tc.interval) 82 | Expect(err).NotTo(HaveOccurred()) 83 | 84 | tl.Interval = (*models.Interval)(&interval) 85 | } 86 | 87 | tl.Days = make([]models.Weekday, len(tc.days)) 88 | for i, d := range tc.days { 89 | tl.Days[i] = models.Weekday(d) 90 | } 91 | 92 | now, err := time.Parse(exampleFormatWithTZ, tc.now+" 2018") 93 | Expect(err).NotTo(HaveOccurred()) 94 | 95 | for now.Weekday() != tc.nowDay { 96 | now = now.AddDate(0, 0, 1) 97 | } 98 | 99 | if tc.prev != "" { 100 | tc.prev += " 2018" 101 | prev, err := time.Parse(exampleFormatWithTZ, tc.prev) 102 | Expect(err).NotTo(HaveOccurred()) 103 | 104 | for prev.Weekday() != tc.prevDay { 105 | prev = prev.AddDate(0, 0, 1) 106 | } 107 | 108 | tl.PreviousTime = prev 109 | } 110 | 111 | result := tl.Check(now.UTC()) 112 | Expect(result).To(Equal(tc.result)) 113 | 114 | latest := tl.Latest(now.UTC()) 115 | Expect(latest.IsZero()).To(Equal(tc.latest.isZero)) 116 | if !tc.latest.isZero { 117 | Expect(latest.Hour()).To(Equal(tc.latest.hour)) 118 | Expect(latest.Minute()).To(Equal(tc.latest.minute)) 119 | Expect(latest.Second()).To(Equal(0)) 120 | Expect(latest.Weekday()).To(Equal(tc.latest.weekday)) 121 | if tc.list == nil { 122 | tc.list = []expectedTime{tc.latest} 123 | } 124 | } 125 | 126 | list := tl.List(now.UTC()) 127 | Expect(len(list)).To(Equal(len(tc.list))) 128 | for idx, actual := range list { 129 | expected := tc.list[idx] 130 | Expect(actual.Hour()).To(Equal(expected.hour)) 131 | Expect(actual.Minute()).To(Equal(expected.minute)) 132 | Expect(latest.Second()).To(Equal(0)) 133 | Expect(actual.Weekday()).To(Equal(expected.weekday)) 134 | } 135 | } 136 | 137 | var _ = DescribeTable("A range without a previous time", (testCase).Run, 138 | Entry("between the start and stop time", testCase{ 139 | start: "2:00 AM +0000", 140 | stop: "4:00 AM +0000", 141 | now: "3:00 AM +0000", 142 | result: true, 143 | latest: expectedTime{hour: 2}, 144 | }), 145 | Entry("between the start and stop time down to the minute", testCase{ 146 | start: "2:01 AM +0000", 147 | stop: "2:03 AM +0000", 148 | now: "2:02 AM +0000", 149 | result: true, 150 | latest: expectedTime{hour: 2, minute: 1}, 151 | }), 152 | Entry("not between the start and stop time", testCase{ 153 | start: "2:00 AM +0000", 154 | stop: "4:00 AM +0000", 155 | now: "5:00 AM +0000", 156 | result: false, 157 | latest: expectedTime{isZero: true}, 158 | }), 159 | Entry("after the stop time, down to the minute", testCase{ 160 | start: "2:00 AM +0000", 161 | stop: "4:00 AM +0000", 162 | now: "4:10 AM +0000", 163 | result: false, 164 | latest: expectedTime{isZero: true}, 165 | }), 166 | Entry("before the start time, down to the minute", testCase{ 167 | start: "11:07 AM +0000", 168 | stop: "11:10 AM +0000", 169 | now: "11:05 AM +0000", 170 | result: false, 171 | latest: expectedTime{isZero: true}, 172 | }), 173 | Entry("one nanosecond before the start time", testCase{ 174 | start: "3:04 AM +0000", 175 | stop: "3:07 AM +0000", 176 | now: "3:03 AM +0000", 177 | extraTime: time.Minute - time.Nanosecond, 178 | result: false, 179 | latest: expectedTime{isZero: true}, 180 | }), 181 | Entry("equal to the start time", testCase{ 182 | start: "3:04 AM +0000", 183 | stop: "3:07 AM +0000", 184 | now: "3:04 AM +0000", 185 | result: true, 186 | latest: expectedTime{hour: 3, minute: 4}, 187 | }), 188 | Entry("one nanosecond before the stop time", testCase{ 189 | start: "3:04 AM +0000", 190 | stop: "3:07 AM +0000", 191 | now: "3:06 AM +0000", 192 | extraTime: time.Minute - time.Nanosecond, 193 | result: true, 194 | latest: expectedTime{hour: 3, minute: 4}, 195 | }), 196 | Entry("equal to the stop time", testCase{ 197 | start: "3:04 AM +0000", 198 | stop: "3:07 AM +0000", 199 | now: "3:07 AM +0000", 200 | result: false, 201 | latest: expectedTime{isZero: true}, 202 | }), 203 | 204 | Entry("between the start and stop time but the stop time is before the start time, spanning more than a day", testCase{ 205 | start: "5:00 AM +0000", 206 | stop: "1:00 AM +0000", 207 | now: "6:00 AM +0000", 208 | result: true, 209 | latest: expectedTime{hour: 5}, 210 | }), 211 | Entry("between the start and stop time but the stop time is before the start time, spanning half a day", testCase{ 212 | start: "8:00 PM +0000", 213 | stop: "8:00 AM +0000", 214 | now: "1:00 AM +0000", 215 | result: true, 216 | latest: expectedTime{hour: 20, weekday: time.Saturday}, 217 | }), 218 | Entry("between the start and stop time but the stop time is before the start time and now is in the stop day", testCase{ 219 | start: "8:00 PM +0000", 220 | stop: "8:00 AM +0000", 221 | now: "7:00 AM +0000", 222 | result: true, 223 | latest: expectedTime{hour: 20, weekday: time.Saturday}, 224 | }), 225 | 226 | Entry("between the start and stop time but the compare time is in a different timezone", testCase{ 227 | start: "2:00 AM -0600", 228 | stop: "6:00 AM -0600", 229 | now: "1:00 AM -0700", 230 | result: true, 231 | latest: expectedTime{hour: 8}, 232 | }), 233 | 234 | Entry("covering almost a full day", testCase{ 235 | start: "12:01 AM -0700", 236 | stop: "11:59 PM -0700", 237 | now: "1:10 AM +0000", 238 | result: true, 239 | latest: expectedTime{hour: 7, minute: 1, weekday: time.Saturday}, 240 | }), 241 | ) 242 | 243 | var _ = DescribeTable("A range with a previous time", (testCase).Run, 244 | Entry("an hour before start", testCase{ 245 | start: "2:00 AM +0000", 246 | stop: "4:00 AM +0000", 247 | now: "3:00 AM +0000", 248 | prev: "1:00 AM +0000", 249 | result: true, 250 | latest: expectedTime{hour: 2}, 251 | }), 252 | Entry("with stop before start and prev in the start day and now in the stop day", testCase{ 253 | start: "10:00 AM +0000", 254 | stop: "5:00 AM +0000", 255 | now: "4:00 AM +0000", 256 | nowDay: time.Tuesday, 257 | prev: "11:00 AM +0000", 258 | prevDay: time.Monday, 259 | result: false, 260 | latest: expectedTime{isZero: true}, 261 | }), 262 | Entry("with stop before start and prev outside the range and now in the stop day", testCase{ 263 | start: "10:00 AM +0000", 264 | stop: "5:00 AM +0000", 265 | now: "4:00 AM +0000", 266 | nowDay: time.Tuesday, 267 | prev: "9:00 AM +0000", 268 | prevDay: time.Monday, 269 | result: true, 270 | latest: expectedTime{hour: 10, weekday: time.Monday}, 271 | }), 272 | Entry("after now and in range on same day as now", testCase{ 273 | start: "2:00 AM +0000", 274 | stop: "4:00 AM +0000", 275 | now: "3:00 AM +0000", 276 | prev: "3:30 AM +0000", 277 | result: false, 278 | latest: expectedTime{isZero: true}, 279 | }), 280 | Entry("after now and out of range on same day as now", testCase{ 281 | start: "2:00 AM +0000", 282 | stop: "4:00 AM +0000", 283 | now: "3:00 AM +0000", 284 | prev: "5:00 AM +0000", 285 | result: false, 286 | latest: expectedTime{isZero: true}, 287 | }), 288 | ) 289 | 290 | var _ = DescribeTable("A range with a location and no previous time", (testCase).Run, 291 | Entry("between the start and stop time in a given location", testCase{ 292 | location: "America/Indiana/Indianapolis", 293 | start: "1:00 PM", 294 | stop: "3:00 PM", 295 | now: "6:00 PM +0000", 296 | result: true, 297 | latest: expectedTime{hour: 13}, 298 | }), 299 | Entry("between the start and stop time in a given location on a matching day", testCase{ 300 | location: "America/Indiana/Indianapolis", 301 | start: "1:00 PM", 302 | stop: "3:00 PM", 303 | days: []time.Weekday{time.Wednesday}, 304 | now: "6:00 PM +0000", 305 | nowDay: time.Wednesday, 306 | result: true, 307 | latest: expectedTime{hour: 13, weekday: time.Wednesday}, 308 | }), 309 | Entry("not between the start and stop time in a given location", testCase{ 310 | location: "America/Indiana/Indianapolis", 311 | start: "1:00 PM", 312 | stop: "3:00 PM", 313 | now: "8:00 PM +0000", 314 | result: false, 315 | latest: expectedTime{isZero: true}, 316 | }), 317 | Entry("between the start and stop time in a given location but not on a matching day", testCase{ 318 | location: "America/Indiana/Indianapolis", 319 | start: "1:00 PM", 320 | stop: "3:00 PM", 321 | days: []time.Weekday{time.Wednesday}, 322 | now: "6:00 PM +0000", 323 | nowDay: time.Thursday, 324 | result: false, 325 | latest: expectedTime{isZero: true}, 326 | }), 327 | Entry("between the start and stop time in a given location and on a matching day compared to UTC", testCase{ 328 | location: "America/Indiana/Indianapolis", 329 | start: "9:00 PM", 330 | stop: "11:00 PM", 331 | days: []time.Weekday{time.Wednesday}, 332 | now: "2:00 AM +0000", 333 | nowDay: time.Thursday, 334 | result: true, 335 | latest: expectedTime{hour: 21, weekday: time.Wednesday}, 336 | }), 337 | ) 338 | 339 | var _ = DescribeTable("A range with a location and a previous time", (testCase).Run, 340 | Entry("between the start and stop time in a given location, on a new day", testCase{ 341 | location: "America/Indiana/Indianapolis", 342 | start: "1:00 PM", 343 | stop: "3:00 PM", 344 | 345 | prev: "6:00 PM +0000", 346 | prevDay: time.Wednesday, 347 | now: "6:00 PM +0000", 348 | nowDay: time.Thursday, 349 | 350 | result: true, 351 | latest: expectedTime{hour: 13, weekday: time.Thursday}, 352 | list: []expectedTime{ 353 | {hour: 13, weekday: time.Wednesday}, 354 | {hour: 13, weekday: time.Thursday}, 355 | }, 356 | }), 357 | Entry("not between the start and stop time in a given location, on the same day", testCase{ 358 | location: "America/Indiana/Indianapolis", 359 | start: "1:00 PM", 360 | stop: "3:00 PM", 361 | 362 | prev: "6:00 PM +0000", 363 | prevDay: time.Wednesday, 364 | now: "6:01 PM +0000", 365 | nowDay: time.Wednesday, 366 | 367 | result: false, 368 | latest: expectedTime{hour: 13, weekday: time.Wednesday}, 369 | }), 370 | ) 371 | 372 | var _ = DescribeTable("An interval", (testCase).Run, 373 | Entry("without a previous time", testCase{ 374 | interval: "2m", 375 | now: "12:00 PM +0000", 376 | result: true, 377 | latest: expectedTime{hour: 12}, 378 | }), 379 | Entry("with a previous time that has not elapsed", testCase{ 380 | interval: "2m", 381 | prev: "12:00 PM +0000", 382 | now: "12:01 PM +0000", 383 | result: false, 384 | latest: expectedTime{hour: 12}, 385 | }), 386 | Entry("with a previous time that has elapsed", testCase{ 387 | interval: "2m", 388 | prev: "12:00 PM +0000", 389 | now: "12:02 PM +0000", 390 | result: true, 391 | latest: expectedTime{hour: 12, minute: 2}, 392 | list: []expectedTime{ 393 | {hour: 12}, 394 | {hour: 12, minute: 2}, 395 | }, 396 | }), 397 | ) 398 | 399 | var _ = DescribeTable("A range with an interval and a previous time", (testCase).Run, 400 | Entry("between the start and stop time, on a new day", testCase{ 401 | interval: "2m", 402 | 403 | start: "1:00 PM +0000", 404 | stop: "3:00 PM +0000", 405 | 406 | prev: "2:58 PM +0000", 407 | prevDay: time.Wednesday, 408 | now: "1:00 PM +0000", 409 | nowDay: time.Thursday, 410 | 411 | result: true, 412 | latest: expectedTime{hour: 13, weekday: time.Thursday}, 413 | list: []expectedTime{ 414 | {hour: 14, minute: 58, weekday: time.Wednesday}, 415 | {hour: 13, weekday: time.Thursday}, 416 | }, 417 | }), 418 | Entry("between the start and stop time, elapsed", testCase{ 419 | interval: "2m", 420 | 421 | start: "1:00 PM +0000", 422 | stop: "3:00 PM +0000", 423 | 424 | prev: "1:02 PM +0000", 425 | now: "1:04 PM +0000", 426 | 427 | result: true, 428 | latest: expectedTime{hour: 13, minute: 4}, 429 | list: []expectedTime{ 430 | {hour: 13, minute: 2}, 431 | {hour: 13, minute: 4}, 432 | }, 433 | }), 434 | Entry("between the start and stop time, not elapsed", testCase{ 435 | interval: "2m", 436 | 437 | start: "1:00 PM +0000", 438 | stop: "3:00 PM +0000", 439 | 440 | prev: "1:02 PM +0000", 441 | now: "1:03 PM +0000", 442 | 443 | result: false, 444 | latest: expectedTime{hour: 13, minute: 2}, 445 | }), 446 | Entry("not between the start and stop time, elapsed", testCase{ 447 | interval: "2m", 448 | 449 | start: "1:00 PM +0000", 450 | stop: "3:00 PM +0000", 451 | 452 | prev: "2:58 PM +0000", 453 | now: "3:02 PM +0000", 454 | 455 | result: false, 456 | latest: expectedTime{hour: 14, minute: 58}, 457 | }), 458 | ) 459 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Version struct { 12 | Time time.Time `json:"time"` 13 | } 14 | 15 | type InRequest struct { 16 | Source Source `json:"source"` 17 | Version Version `json:"version"` 18 | } 19 | 20 | type InResponse struct { 21 | Version Version `json:"version"` 22 | Metadata Metadata `json:"metadata"` 23 | } 24 | 25 | type OutRequest struct { 26 | Source Source `json:"source"` 27 | } 28 | 29 | type OutResponse struct { 30 | Version Version `json:"version"` 31 | Metadata Metadata `json:"metadata"` 32 | } 33 | 34 | type CheckRequest struct { 35 | Source Source `json:"source"` 36 | Version Version `json:"version"` 37 | } 38 | 39 | type CheckResponse []Version 40 | 41 | type Source struct { 42 | InitialVersion bool `json:"initial_version"` 43 | Interval *Interval `json:"interval"` 44 | Start *TimeOfDay `json:"start"` 45 | Stop *TimeOfDay `json:"stop"` 46 | Days []Weekday `json:"days"` 47 | Location *Location `json:"location"` 48 | } 49 | 50 | func (source Source) Validate() error { 51 | // Validate start and stop are both set or both unset 52 | if (source.Start != nil) != (source.Stop != nil) { 53 | if source.Start != nil { 54 | return errors.New("must configure 'stop' if 'start' is set") 55 | } 56 | return errors.New("must configure 'start' if 'stop' is set") 57 | } 58 | 59 | // Validate days if specified 60 | for _, day := range source.Days { 61 | if day < 0 || day > 6 { 62 | return fmt.Errorf("invalid day: %v", day) 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | type Metadata []MetadataField 70 | 71 | type MetadataField struct { 72 | Name string `json:"name"` 73 | Value string `json:"value"` 74 | } 75 | 76 | type Interval time.Duration 77 | 78 | func (i *Interval) UnmarshalJSON(payload []byte) error { 79 | var durStr string 80 | err := json.Unmarshal(payload, &durStr) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | duration, err := time.ParseDuration(durStr) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | *i = Interval(duration) 91 | 92 | return nil 93 | } 94 | 95 | func (i Interval) MarshalJSON() ([]byte, error) { 96 | return json.Marshal(time.Duration(i).String()) 97 | } 98 | 99 | type Location time.Location 100 | 101 | func (l *Location) UnmarshalJSON(payload []byte) error { 102 | var locStr string 103 | err := json.Unmarshal(payload, &locStr) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | location, err := time.LoadLocation(locStr) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | *l = Location(*location) 114 | 115 | return nil 116 | } 117 | 118 | func (l Location) MarshalJSON() ([]byte, error) { 119 | return json.Marshal((*time.Location)(&l).String()) 120 | } 121 | 122 | var timeFormats []string 123 | 124 | func init() { 125 | timeFormats = append(timeFormats, "3:04 PM -0700") 126 | timeFormats = append(timeFormats, "3PM -0700") 127 | timeFormats = append(timeFormats, "3 PM -0700") 128 | timeFormats = append(timeFormats, "15:04 -0700") 129 | timeFormats = append(timeFormats, "1504 -0700") 130 | timeFormats = append(timeFormats, "3:04 PM") 131 | timeFormats = append(timeFormats, "3PM") 132 | timeFormats = append(timeFormats, "3 PM") 133 | timeFormats = append(timeFormats, "15:04") 134 | timeFormats = append(timeFormats, "1504") 135 | } 136 | 137 | type TimeOfDay time.Duration 138 | 139 | func NewTimeOfDay(t time.Time) TimeOfDay { 140 | return TimeOfDay(time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute) 141 | } 142 | 143 | func (tod *TimeOfDay) UnmarshalJSON(payload []byte) error { 144 | var timeStr string 145 | err := json.Unmarshal(payload, &timeStr) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | var t time.Time 151 | for _, format := range timeFormats { 152 | t, err = time.Parse(format, timeStr) 153 | if err == nil { 154 | break 155 | } 156 | } 157 | if err != nil { 158 | return fmt.Errorf("invalid time format: %s, must be one of: %s", timeStr, strings.Join(timeFormats, ", ")) 159 | } 160 | 161 | *tod = NewTimeOfDay(t.UTC()) 162 | 163 | return nil 164 | } 165 | 166 | func (tod TimeOfDay) MarshalJSON() ([]byte, error) { 167 | return json.Marshal(tod.String()) 168 | } 169 | 170 | func (tod TimeOfDay) Hour() int { 171 | return int(time.Duration(tod) / time.Hour) 172 | } 173 | 174 | func (tod TimeOfDay) Minute() int { 175 | return int(time.Duration(tod) % time.Hour / time.Minute) 176 | } 177 | 178 | func (tod TimeOfDay) String() string { 179 | return fmt.Sprintf("%d:%02d", tod.Hour(), tod.Minute()) 180 | } 181 | 182 | type Weekday time.Weekday 183 | 184 | func ParseWeekday(wdStr string) (time.Weekday, error) { 185 | switch strings.ToLower(wdStr) { 186 | case "sun", "sunday": 187 | return time.Sunday, nil 188 | case "mon", "monday": 189 | return time.Monday, nil 190 | case "tue", "tuesday": 191 | return time.Tuesday, nil 192 | case "wed", "wednesday": 193 | return time.Wednesday, nil 194 | case "thu", "thursday": 195 | return time.Thursday, nil 196 | case "fri", "friday": 197 | return time.Friday, nil 198 | case "sat", "saturday": 199 | return time.Saturday, nil 200 | } 201 | 202 | return 0, fmt.Errorf("unknown weekday: %s", wdStr) 203 | } 204 | 205 | func (x *Weekday) UnmarshalJSON(payload []byte) error { 206 | var wdStr string 207 | err := json.Unmarshal(payload, &wdStr) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | wd, err := ParseWeekday(wdStr) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | *x = Weekday(wd) 218 | 219 | return nil 220 | } 221 | 222 | func (wd Weekday) MarshalJSON() ([]byte, error) { 223 | return json.Marshal(time.Weekday(wd).String()) 224 | } 225 | -------------------------------------------------------------------------------- /models/models_suite_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestModels(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Models Suite") 13 | } 14 | -------------------------------------------------------------------------------- /models/source_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "github.com/concourse/time-resource/models" 10 | ) 11 | 12 | var _ = Describe("Source", func() { 13 | var ( 14 | config string 15 | 16 | source models.Source 17 | err error 18 | ) 19 | 20 | BeforeEach(func() { 21 | config = "" 22 | source = models.Source{} 23 | err = nil 24 | }) 25 | 26 | JustBeforeEach(func() { 27 | err = json.Unmarshal([]byte(config), &source) 28 | }) 29 | 30 | Context("a start with no stop", func() { 31 | BeforeEach(func() { 32 | config = `{ "start": "3:04" }` 33 | }) 34 | 35 | It("generates a validation error", func() { 36 | Expect(err).ToNot(HaveOccurred()) 37 | 38 | err = source.Validate() 39 | Expect(err).To(HaveOccurred()) 40 | Expect(err.Error()).To(Equal("must configure 'stop' if 'start' is set")) 41 | }) 42 | }) 43 | 44 | Context("a stop with no start", func() { 45 | BeforeEach(func() { 46 | config = `{ "stop": "3:04" }` 47 | }) 48 | 49 | It("generates a validation error", func() { 50 | Expect(err).ToNot(HaveOccurred()) 51 | 52 | err = source.Validate() 53 | Expect(err).To(HaveOccurred()) 54 | Expect(err.Error()).To(Equal("must configure 'start' if 'stop' is set")) 55 | }) 56 | }) 57 | 58 | Context("when the range is given in another timezone", func() { 59 | BeforeEach(func() { 60 | config = `{ "start": "3:04 -0100", "stop": "9:04 -0700" }` 61 | }) 62 | 63 | It("is valid", func() { 64 | Expect(err).ToNot(HaveOccurred()) 65 | 66 | err = source.Validate() 67 | Expect(err).ToNot(HaveOccurred()) 68 | 69 | Expect(source.Start).ToNot(BeNil()) 70 | Expect(source.Stop).ToNot(BeNil()) 71 | 72 | Expect(source.Stop.Minute()).To(Equal(source.Start.Minute())) 73 | Expect(source.Stop.Hour()).To(Equal(source.Start.Hour() + 12)) 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /offset.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | "math" 7 | "os" 8 | "time" 9 | 10 | "github.com/concourse/time-resource/lord" 11 | ) 12 | 13 | const BUILD_TEAM_NAME = "BUILD_TEAM_NAME" 14 | const BUILD_PIPELINE_NAME = "BUILD_PIPELINE_NAME" 15 | const BUILD_PIPELINE_INSTANCE_VARS = "BUILD_PIPELINE_INSTANCE_VARS" 16 | 17 | const maxHashValue = int64(math.MaxUint32) 18 | 19 | var msPerMinute = time.Minute.Milliseconds() 20 | 21 | func Offset(tl lord.TimeLord, reference time.Time) time.Time { 22 | str := fmt.Sprintf( 23 | "%s/%s/%s", 24 | os.Getenv(BUILD_TEAM_NAME), 25 | os.Getenv(BUILD_PIPELINE_NAME), 26 | os.Getenv(BUILD_PIPELINE_INSTANCE_VARS), 27 | ) 28 | hasher := fnv.New32a() 29 | if _, err := hasher.Write([]byte(str)); err != nil { 30 | fmt.Fprintln(os.Stderr, "hash error:", err.Error()) 31 | os.Exit(1) 32 | } 33 | hash := int64(hasher.Sum32()) 34 | 35 | start, stop := tl.LatestRangeBefore(reference) 36 | rangeDuration := stop.Sub(start) 37 | 38 | if tl.Interval != nil { 39 | if intervalDuration := time.Duration(*tl.Interval); intervalDuration < rangeDuration { 40 | rangeDuration = intervalDuration 41 | start = reference.Truncate(rangeDuration) 42 | } 43 | } 44 | 45 | if rangeDuration <= time.Minute { 46 | return start 47 | } 48 | 49 | rangeMs := rangeDuration.Milliseconds() 50 | if rangeMs <= 0 { 51 | return start 52 | } 53 | 54 | minutesInRange := rangeMs / msPerMinute 55 | if minutesInRange <= 0 { 56 | minutesInRange = 1 57 | } 58 | 59 | hashPerMinute := maxHashValue / minutesInRange 60 | if hashPerMinute <= 0 { 61 | hashPerMinute = 1 62 | } 63 | 64 | minutesToOffset := hash / hashPerMinute 65 | 66 | // Guard against overflows 67 | if minutesToOffset < 0 { 68 | minutesToOffset = 0 69 | } 70 | 71 | // Ensure the offset doesn't exceed the range duration 72 | if minutesToOffset > minutesInRange { 73 | minutesToOffset = minutesInRange 74 | } 75 | 76 | offsetDuration := time.Duration(minutesToOffset) * time.Minute 77 | return start.Add(offsetDuration) 78 | } 79 | -------------------------------------------------------------------------------- /offset_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | resource "github.com/concourse/time-resource" 8 | "github.com/concourse/time-resource/lord" 9 | "github.com/concourse/time-resource/models" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | type testEnvironment struct { 15 | teamName string 16 | pipelineName string 17 | pipelineInstanceVars string 18 | hashPercentile float64 19 | } 20 | 21 | var xsmallOffset = testEnvironment{ 22 | teamName: "a", 23 | pipelineName: "b", 24 | pipelineInstanceVars: "", 25 | hashPercentile: 0.11040721324049105, 26 | } 27 | 28 | var smallOffset = testEnvironment{ 29 | teamName: "concourse", 30 | pipelineName: "time-resource", 31 | pipelineInstanceVars: "", 32 | hashPercentile: 0.4723325777967303, 33 | } 34 | 35 | var largeOffset = testEnvironment{ 36 | teamName: "concourse", 37 | pipelineName: "concourse", 38 | pipelineInstanceVars: "large", 39 | hashPercentile: 0.8069350870342309, 40 | } 41 | 42 | var xlargeOffset = testEnvironment{ 43 | teamName: "foo", 44 | pipelineName: "bar", 45 | pipelineInstanceVars: "baz", 46 | hashPercentile: 0.9322489704778998, 47 | } 48 | 49 | var _ = Describe("Offset", func() { 50 | originalTeam := os.Getenv(resource.BUILD_TEAM_NAME) 51 | originalPipeline := os.Getenv(resource.BUILD_PIPELINE_NAME) 52 | originalPipelineInstanceVars := os.Getenv(resource.BUILD_PIPELINE_INSTANCE_VARS) 53 | 54 | var ( 55 | env testEnvironment 56 | now time.Time 57 | loc *time.Location 58 | 59 | tl lord.TimeLord 60 | dayDuration time.Duration 61 | reference time.Time 62 | 63 | actualOffsetTime time.Time 64 | expectedOffsetTime time.Time 65 | ) 66 | 67 | BeforeEach(func() { 68 | env = testEnvironment{} 69 | now = time.Now() 70 | actualOffsetTime = time.Time{} 71 | expectedOffsetTime = time.Time{} 72 | }) 73 | 74 | JustBeforeEach(func() { 75 | os.Setenv(resource.BUILD_TEAM_NAME, env.teamName) 76 | os.Setenv(resource.BUILD_PIPELINE_NAME, env.pipelineName) 77 | os.Setenv(resource.BUILD_PIPELINE_INSTANCE_VARS, env.pipelineInstanceVars) 78 | actualOffsetTime = resource.Offset(tl, reference) 79 | }) 80 | 81 | AfterEach(func() { 82 | os.Setenv(resource.BUILD_TEAM_NAME, originalTeam) 83 | os.Setenv(resource.BUILD_PIPELINE_NAME, originalPipeline) 84 | os.Setenv(resource.BUILD_PIPELINE_INSTANCE_VARS, originalPipelineInstanceVars) 85 | }) 86 | 87 | validateExpectedTime := func() { 88 | Expect(actualOffsetTime.Unix()).To(Equal(expectedOffsetTime.Unix())) 89 | } 90 | 91 | RunIntervalAndOrRangeTests := func() { 92 | var rangeDuration time.Duration 93 | 94 | Context("when a range is not specified", func() { 95 | BeforeEach(func() { 96 | rangeDuration = dayDuration 97 | 98 | tl.Start = nil 99 | tl.Stop = nil 100 | }) 101 | 102 | Context("when an interval is not specified", func() { 103 | BeforeEach(func() { 104 | tl.Interval = nil 105 | }) 106 | 107 | JustBeforeEach(func() { 108 | expectedOffsetTime = time.Date(reference.Year(), reference.Month(), reference.Day(), 0, 0, 0, 0, loc).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute) 109 | }) 110 | 111 | Context("when using a team name and pipeline name that generates a very small offset", func() { 112 | BeforeEach(func() { env = xsmallOffset }) 113 | It("generates an overnight version", validateExpectedTime) 114 | }) 115 | 116 | Context("when using a team name and pipeline name that generates a small offset", func() { 117 | BeforeEach(func() { env = smallOffset }) 118 | It("generates an morning version", validateExpectedTime) 119 | }) 120 | 121 | Context("when using a team name and pipeline name that generates a large offset", func() { 122 | BeforeEach(func() { env = largeOffset }) 123 | It("generates an afternoon version", validateExpectedTime) 124 | }) 125 | 126 | Context("when using a team name and pipeline name that generates a very large offset", func() { 127 | BeforeEach(func() { env = xlargeOffset }) 128 | It("generates an evening version", validateExpectedTime) 129 | }) 130 | }) 131 | 132 | Context("when an interval is specified", func() { 133 | var intervalDuration time.Duration 134 | 135 | BeforeEach(func() { 136 | tl.Interval = new(models.Interval) 137 | }) 138 | 139 | Context("when the interval can't get any smaller", func() { 140 | BeforeEach(func() { 141 | intervalDuration = time.Minute 142 | *tl.Interval = models.Interval(intervalDuration) 143 | }) 144 | 145 | for _, testEnv := range []testEnvironment{xsmallOffset, smallOffset, largeOffset, xlargeOffset} { 146 | Context("for the "+env.teamName+"/"+env.pipelineName+"/"+env.pipelineInstanceVars+" pipeline", func() { 147 | BeforeEach(func() { 148 | env = testEnv 149 | expectedOffsetTime = reference.Truncate(time.Minute) 150 | }) 151 | It("returns the reference time", validateExpectedTime) 152 | }) 153 | } 154 | }) 155 | 156 | Context("when the interval is smaller than the range", func() { 157 | BeforeEach(func() { 158 | intervalDuration = time.Hour 159 | *tl.Interval = models.Interval(intervalDuration) 160 | }) 161 | 162 | JustBeforeEach(func() { 163 | expectedOffsetTime = reference.Truncate(intervalDuration).Add(time.Duration(intervalDuration.Minutes()*env.hashPercentile) * time.Minute) 164 | }) 165 | 166 | Context("when using a team name and pipeline name that generates a very small offset", func() { 167 | BeforeEach(func() { env = xsmallOffset }) 168 | It("generates a version at the very beginning of the interval", validateExpectedTime) 169 | }) 170 | 171 | Context("when using a team name and pipeline name that generates a small offset", func() { 172 | BeforeEach(func() { env = smallOffset }) 173 | It("generates a version toward the beginning of the interval", validateExpectedTime) 174 | }) 175 | 176 | Context("when using a team name and pipeline name that generates a large offset", func() { 177 | BeforeEach(func() { env = largeOffset }) 178 | It("generates a version toward the end of the interval", validateExpectedTime) 179 | }) 180 | 181 | Context("when using a team name and pipeline name that generates a very large offset", func() { 182 | BeforeEach(func() { env = xlargeOffset }) 183 | It("generates a version at the very end of the interval", validateExpectedTime) 184 | }) 185 | }) 186 | 187 | Context("when the interval is larger than the range", func() { 188 | BeforeEach(func() { 189 | intervalDuration = time.Hour * 168 190 | *tl.Interval = models.Interval(intervalDuration) 191 | }) 192 | 193 | JustBeforeEach(func() { 194 | expectedOffsetTime = time.Date(reference.Year(), reference.Month(), reference.Day(), 0, 0, 0, 0, loc).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute) 195 | }) 196 | 197 | Context("when using a team name and pipeline name that generates a very small offset", func() { 198 | BeforeEach(func() { env = xsmallOffset }) 199 | It("generates a version at the very beginning of the interval", validateExpectedTime) 200 | }) 201 | 202 | Context("when using a team name and pipeline name that generates a small offset", func() { 203 | BeforeEach(func() { env = smallOffset }) 204 | It("generates a version toward the beginning of the interval", validateExpectedTime) 205 | }) 206 | 207 | Context("when using a team name and pipeline name that generates a large offset", func() { 208 | BeforeEach(func() { env = largeOffset }) 209 | It("generates a version toward the end of the interval", validateExpectedTime) 210 | }) 211 | 212 | Context("when using a team name and pipeline name that generates a very large offset", func() { 213 | BeforeEach(func() { env = xlargeOffset }) 214 | It("generates a version at the very end of the interval", validateExpectedTime) 215 | }) 216 | }) 217 | }) 218 | }) 219 | 220 | Context("when a range is specified", func() { 221 | BeforeEach(func() { 222 | rangeDuration = 6 * time.Hour 223 | 224 | tlStart := reference.Truncate(rangeDuration) 225 | tl.Start = new(models.TimeOfDay) 226 | *tl.Start = models.NewTimeOfDay(tlStart) 227 | 228 | tlStop := tlStart.Add(rangeDuration) 229 | tl.Stop = new(models.TimeOfDay) 230 | *tl.Stop = models.NewTimeOfDay(tlStop) 231 | }) 232 | 233 | Context("when an interval is not specified", func() { 234 | BeforeEach(func() { 235 | tl.Interval = nil 236 | }) 237 | 238 | JustBeforeEach(func() { 239 | expectedOffsetTime = reference.Truncate(rangeDuration).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute) 240 | }) 241 | 242 | Context("when using a team name and pipeline name that generates a very small offset", func() { 243 | BeforeEach(func() { env = xsmallOffset }) 244 | It("generates a version at the very beginning of the range", validateExpectedTime) 245 | }) 246 | 247 | Context("when using a team name and pipeline name that generates a small offset", func() { 248 | BeforeEach(func() { env = smallOffset }) 249 | It("generates a version toward the beginning of the range", validateExpectedTime) 250 | }) 251 | 252 | Context("when using a team name and pipeline name that generates a large offset", func() { 253 | BeforeEach(func() { env = largeOffset }) 254 | It("generates a version toward the end of the range", validateExpectedTime) 255 | }) 256 | 257 | Context("when using a team name and pipeline name that generates a very large offset", func() { 258 | BeforeEach(func() { env = xlargeOffset }) 259 | It("generates a version at the very end of the range", validateExpectedTime) 260 | }) 261 | }) 262 | 263 | Context("when an interval is specified", func() { 264 | var intervalDuration time.Duration 265 | 266 | BeforeEach(func() { 267 | tl.Interval = new(models.Interval) 268 | }) 269 | 270 | Context("when the interval can't get any smaller", func() { 271 | BeforeEach(func() { 272 | intervalDuration = time.Minute 273 | *tl.Interval = models.Interval(intervalDuration) 274 | }) 275 | 276 | for _, testEnv := range []testEnvironment{xsmallOffset, smallOffset, largeOffset, xlargeOffset} { 277 | Context("for the "+env.teamName+"/"+env.pipelineName+"/"+env.pipelineInstanceVars+" pipeline", func() { 278 | BeforeEach(func() { 279 | env = testEnv 280 | expectedOffsetTime = reference.Truncate(time.Minute) 281 | }) 282 | It("returns the reference time", validateExpectedTime) 283 | }) 284 | } 285 | }) 286 | 287 | Context("when the interval is smaller than the range", func() { 288 | BeforeEach(func() { 289 | intervalDuration = time.Hour 290 | *tl.Interval = models.Interval(intervalDuration) 291 | }) 292 | 293 | JustBeforeEach(func() { 294 | expectedOffsetTime = reference.Truncate(intervalDuration).Add(time.Duration(intervalDuration.Minutes()*env.hashPercentile) * time.Minute) 295 | }) 296 | 297 | Context("when using a team name and pipeline name that generates a very small offset", func() { 298 | BeforeEach(func() { env = xsmallOffset }) 299 | It("generates a version at the very beginning of the interval", validateExpectedTime) 300 | }) 301 | 302 | Context("when using a team name and pipeline name that generates a small offset", func() { 303 | BeforeEach(func() { env = smallOffset }) 304 | It("generates a version toward the beginning of the interval", validateExpectedTime) 305 | }) 306 | 307 | Context("when using a team name and pipeline name that generates a large offset", func() { 308 | BeforeEach(func() { env = largeOffset }) 309 | It("generates a version toward the end of the interval", validateExpectedTime) 310 | }) 311 | 312 | Context("when using a team name and pipeline name that generates a very large offset", func() { 313 | BeforeEach(func() { env = xlargeOffset }) 314 | It("generates a version at the very end of the interval", validateExpectedTime) 315 | }) 316 | }) 317 | 318 | Context("when the interval is larger than the range", func() { 319 | BeforeEach(func() { 320 | intervalDuration = time.Hour * 168 321 | *tl.Interval = models.Interval(intervalDuration) 322 | }) 323 | 324 | JustBeforeEach(func() { 325 | expectedOffsetTime = reference.Truncate(rangeDuration).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute) 326 | }) 327 | 328 | Context("when using a team name and pipeline name that generates a very small offset", func() { 329 | BeforeEach(func() { env = xsmallOffset }) 330 | It("generates a version at the very beginning of the range", validateExpectedTime) 331 | }) 332 | 333 | Context("when using a team name and pipeline name that generates a small offset", func() { 334 | BeforeEach(func() { env = smallOffset }) 335 | It("generates a version toward the beginning of the range", validateExpectedTime) 336 | }) 337 | 338 | Context("when using a team name and pipeline name that generates a large offset", func() { 339 | BeforeEach(func() { env = largeOffset }) 340 | It("generates a version toward the end of the range", validateExpectedTime) 341 | }) 342 | 343 | Context("when using a team name and pipeline name that generates a very large offset", func() { 344 | BeforeEach(func() { env = xlargeOffset }) 345 | It("generates a version at the very end of the range", validateExpectedTime) 346 | }) 347 | }) 348 | }) 349 | }) 350 | } 351 | 352 | Context("when a location is not specified", func() { 353 | BeforeEach(func() { 354 | loc = time.UTC 355 | tl.Location = nil 356 | reference = time.Date(2012, time.April, 21, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), loc) 357 | dayDuration = 24 * time.Hour 358 | }) 359 | 360 | RunIntervalAndOrRangeTests() 361 | }) 362 | 363 | Context("when a location is specified", func() { 364 | BeforeEach(func() { 365 | var err error 366 | 367 | loc, err = time.LoadLocation("America/New_York") 368 | Expect(err).NotTo(HaveOccurred()) 369 | 370 | tlLoc := models.Location(*loc) 371 | tl.Location = &tlLoc 372 | }) 373 | 374 | Context("when referencing a 23-hour day", func() { 375 | BeforeEach(func() { 376 | reference = time.Date(2012, time.March, 11, 6, now.Minute(), now.Second(), now.Nanosecond(), loc) 377 | dayDuration = 23 * time.Hour 378 | }) 379 | 380 | RunIntervalAndOrRangeTests() 381 | }) 382 | 383 | Context("when referencing a 25-hour day", func() { 384 | BeforeEach(func() { 385 | reference = time.Date(2012, time.November, 4, 13, now.Minute(), now.Second(), now.Nanosecond(), loc) 386 | dayDuration = 25 * time.Hour 387 | }) 388 | 389 | RunIntervalAndOrRangeTests() 390 | }) 391 | }) 392 | }) 393 | -------------------------------------------------------------------------------- /out/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | resource "github.com/concourse/time-resource" 9 | "github.com/concourse/time-resource/models" 10 | ) 11 | 12 | func main() { 13 | var request models.OutRequest 14 | 15 | err := json.NewDecoder(os.Stdin).Decode(&request) 16 | if err != nil { 17 | fmt.Fprintln(os.Stderr, "parse error:", err.Error()) 18 | os.Exit(1) 19 | } 20 | 21 | command := resource.OutCommand{} 22 | 23 | response, err := command.Run(request) 24 | if err != nil { 25 | fmt.Fprintln(os.Stderr, "running command:", err.Error()) 26 | os.Exit(1) 27 | } 28 | 29 | json.NewEncoder(os.Stdout).Encode(response) 30 | } 31 | -------------------------------------------------------------------------------- /out_command.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/concourse/time-resource/models" 7 | ) 8 | 9 | type OutCommand struct { 10 | } 11 | 12 | func (*OutCommand) Run(request models.OutRequest) (models.OutResponse, error) { 13 | currentTime := time.Now().UTC() 14 | specifiedLocation := request.Source.Location 15 | if specifiedLocation != nil { 16 | currentTime = currentTime.In((*time.Location)(specifiedLocation)) 17 | } 18 | 19 | outVersion := models.Version{Time: currentTime} 20 | response := models.OutResponse{Version: outVersion} 21 | 22 | return response, nil 23 | } 24 | -------------------------------------------------------------------------------- /out_command_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "time" 7 | 8 | resource "github.com/concourse/time-resource" 9 | "github.com/concourse/time-resource/models" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("Out", func() { 15 | var ( 16 | now time.Time 17 | 18 | tmpdir string 19 | 20 | source models.Source 21 | response models.OutResponse 22 | 23 | err error 24 | ) 25 | 26 | BeforeEach(func() { 27 | now = time.Now().UTC() 28 | 29 | tmpdir, err = os.MkdirTemp("", "out-source") 30 | Expect(err).NotTo(HaveOccurred()) 31 | 32 | source = models.Source{} 33 | }) 34 | 35 | JustBeforeEach(func() { 36 | command := resource.OutCommand{} 37 | response, err = command.Run(models.OutRequest{ 38 | Source: source, 39 | }) 40 | }) 41 | 42 | AfterEach(func() { 43 | os.RemoveAll(tmpdir) 44 | }) 45 | 46 | Context("when executed", func() { 47 | JustBeforeEach(func() { 48 | Expect(err).NotTo(HaveOccurred()) 49 | }) 50 | 51 | Context("when a location is specified", func() { 52 | BeforeEach(func() { 53 | loc, err := time.LoadLocation("America/Indiana/Indianapolis") 54 | Expect(err).ToNot(HaveOccurred()) 55 | 56 | srcLoc := models.Location(*loc) 57 | source.Location = &srcLoc 58 | 59 | now = now.In(loc) 60 | }) 61 | 62 | It("reports specified location's current time (offset: -0400) as the version", func() { 63 | _, expectedOffset := now.Zone() 64 | 65 | _, versionOffset := response.Version.Time.Zone() 66 | Expect(versionOffset).To(Equal(expectedOffset)) 67 | }) 68 | }) 69 | 70 | Context("when a location is not specified", func() { 71 | It("reports the current time (offset: 0000) as the version", func() { 72 | // An example of response.Version.Time.String() is 73 | // 2019-04-03 18:53:10.964705 +0000 UTC 74 | contained := strings.Contains(response.Version.Time.String(), "0000") 75 | Expect(contained).To(BeTrue()) 76 | }) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /resource_suite_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | "github.com/onsi/gomega/gexec" 9 | ) 10 | 11 | var _ = AfterSuite(func() { 12 | gexec.CleanupBuildArtifacts() 13 | }) 14 | 15 | func TestTimeResource(t *testing.T) { 16 | RegisterFailHandler(Fail) 17 | RunSpecs(t, "Time Resource Suite") 18 | } 19 | --------------------------------------------------------------------------------